diff --git a/.github/workflows/build-ci-image.yaml b/.github/workflows/build-ci-image.yaml new file mode 100644 index 0000000000..5cc34c0c99 --- /dev/null +++ b/.github/workflows/build-ci-image.yaml @@ -0,0 +1,61 @@ +name: Build CI image + +on: + push: + branches: [main, staging] + paths: + - 'Dockerfile' + - '.github/workflows/build-ci-image.yaml' + workflow_dispatch: + +env: + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/stackwallet-ci + +jobs: + build: + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + + - uses: docker/setup-buildx-action@v4 + + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push full image + uses: docker/build-push-action@v7 + with: + context: . + target: full + push: true + tags: | + ${{ env.GHCR_IMAGE }}:latest + ${{ env.GHCR_IMAGE }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push android image + uses: docker/build-push-action@v7 + with: + context: . + target: android + push: true + tags: ${{ env.GHCR_IMAGE }}:android + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push test image + uses: docker/build-push-action@v7 + with: + context: . + target: test + push: true + tags: ${{ env.GHCR_IMAGE }}:test + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000000..e4f1868062 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,1670 @@ +name: Build + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + branches: + - staging + workflow_dispatch: + inputs: + version: + description: 'App version string (e.g. 1.2.3)' + required: true + default: '0.0.1' + build_number: + description: 'Build number (integer)' + required: true + default: '1' + +jobs: + + build-disclaimer: + runs-on: ubuntu-24.04 + steps: + - name: Post tester disclaimer + run: | + cat >> $GITHUB_STEP_SUMMARY << 'EOF' + > [!CAUTION] + > **These are unverified, unsupported development builds — not official releases.** + > They have not undergone QA testing and may contain bugs or incomplete features. + > Download and use entirely at your own risk. Do not use with real funds. + > Official releases are published on the [Releases page](https://github.com/cypherstack/stack_wallet/releases). + EOF + + build-linux: + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: read + container: + image: ghcr.io/${{ github.repository_owner }}/stackwallet-ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ github.token }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + BUILD_NUMBER="${{ github.run_number }}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + BUILD_NUMBER="${{ inputs.build_number }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + BUILD_NUMBER="${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + + - name: Configure app + run: | + cd scripts + echo "yes" | ./build_app.sh \ + -v "${{ steps.ver.outputs.version }}" \ + -b "${{ steps.ver.outputs.build_number }}" \ + -p linux -a stack_wallet -d -s + + - 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 }} + run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart + + - name: Build + env: + USE_SYSTEM_SECURE_STORAGE_DEPS: "1" + run: flutter build linux --release --verbose + + - name: Package + run: | + tar -czf "stack_wallet-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz" \ + -C build/linux/x64/release bundle + + - uses: actions/upload-artifact@v7 + with: + name: stack_wallet-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz + path: stack_wallet-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz + + build-android: + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: read + container: + image: ghcr.io/${{ github.repository_owner }}/stackwallet-ci:android + credentials: + username: ${{ github.actor }} + password: ${{ github.token }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + BUILD_NUMBER="${{ github.run_number }}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + BUILD_NUMBER="${{ inputs.build_number }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + BUILD_NUMBER="${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + + - name: Configure app + run: | + cd scripts + echo "yes" | ./build_app.sh \ + -v "${{ steps.ver.outputs.version }}" \ + -b "${{ steps.ver.outputs.build_number }}" \ + -p android -a stack_wallet -d -s + + - 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 }} + run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart + + - name: Set up Android local.properties + run: | + cat > android/local.properties < android/keystore-orig.jks + [ -s android/keystore-orig.jks ] || { echo "ERROR: ANDROID_KEYSTORE_BASE64 secret is empty or not set"; exit 1; } + keytool -importkeystore \ + -srckeystore android/keystore-orig.jks \ + -destkeystore android/keystore.jks \ + -deststoretype pkcs12 \ + -srcstorepass "${{ secrets.ANDROID_STORE_PASSWORD }}" \ + -deststorepass "${{ secrets.ANDROID_STORE_PASSWORD }}" \ + -noprompt \ + -J-Dkeystore.pkcs12.legacy + rm android/keystore-orig.jks + cat > android/key.properties <> $GITHUB_OUTPUT + echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.9' + channel: 'stable' + + - uses: actions/setup-go@v6 + with: + go-version: '1.24.13' + + - name: Flutter doctor + run: flutter doctor -v + + - name: Configure app + run: | + cd scripts + echo "yes" | ./build_app.sh \ + -v "${{ steps.ver.outputs.version }}" \ + -b "${{ steps.ver.outputs.build_number }}" \ + -p windows -a stack_wallet -d -s + + # The Actions windows-2022 runner user lacks SeCreateSymbolicLinkPrivilege, + # so link_assets.sh's mklink /D calls either fail or produce broken reparse + # points that Flutter's asset resolver cannot traverse. Replace each of + # the five gitignored asset directories with real copies instead. + - name: Replace asset symlinks with copies (CI workaround) + run: | + set -euo pipefail + for dirname in default_themes icon lottie in_app_logo_icons svg; do + target="assets/${dirname}" + source="asset_sources/${dirname}/stack_wallet" + # Remove whatever link_assets.sh left (reparse point, symlink, or nothing). + # cmd.exe rmdir on a junction/symlink removes the link, not the target. + if [ -e "$target" ] || [ -L "$target" ]; then + cmd.exe /c rmdir "$(cygpath -w "$target")" 2>/dev/null || rm -rf "$target" + fi + mkdir -p "$target" + cp -r "${source}/." "$target/" + done + + - 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 }} + run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart + + - name: Build secp256k1.dll for Windows + run: dart run coinlib:build_windows + + - name: Build + run: flutter build windows --release + + - name: Package + shell: pwsh + run: | + Compress-Archive -Path "build/windows/x64/runner/Release/*" ` + -DestinationPath "stack_wallet-windows-x86_64-${{ steps.ver.outputs.version }}.zip" + + - uses: actions/upload-artifact@v7 + with: + name: stack_wallet-windows-x86_64-${{ steps.ver.outputs.version }}.zip + path: stack_wallet-windows-x86_64-${{ steps.ver.outputs.version }}.zip + + build-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + BUILD_NUMBER="${{ github.run_number }}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + BUILD_NUMBER="${{ inputs.build_number }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + BUILD_NUMBER="${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.9' + channel: 'stable' + + - uses: actions/setup-go@v6 + with: + go-version: '1.24.13' + + - name: Configure app + run: | + cd scripts + echo "yes" | ./build_app.sh \ + -v "${{ steps.ver.outputs.version }}" \ + -b "${{ steps.ver.outputs.build_number }}" \ + -p macos -a stack_wallet -d -s + + - 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 }} + run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart + + - name: Build + run: flutter build macos --release + + - name: Package + run: | + cd "build/macos/Build/Products/Release" + zip -r "$GITHUB_WORKSPACE/stack_wallet-macos-aarch64-${{ steps.ver.outputs.version }}.zip" \ + "Stack Wallet.app" + + - uses: actions/upload-artifact@v7 + with: + name: stack_wallet-macos-aarch64-${{ steps.ver.outputs.version }}.zip + path: stack_wallet-macos-aarch64-${{ steps.ver.outputs.version }}.zip + + build-ios: + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + BUILD_NUMBER="${{ github.run_number }}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + BUILD_NUMBER="${{ inputs.build_number }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + BUILD_NUMBER="${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + + - uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + targets: aarch64-apple-ios + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.9' + channel: 'stable' + + - uses: actions/setup-go@v6 + with: + go-version: '1.24.13' + + - name: Configure app + run: | + cd scripts + echo "yes" | ./build_app.sh \ + -v "${{ steps.ver.outputs.version }}" \ + -b "${{ steps.ver.outputs.build_number }}" \ + -p ios -a stack_wallet -d -s + + - 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 }} + run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart + + - name: Build + run: flutter build ios --release --no-codesign + + - name: Package IPA + run: | + mkdir Payload + cp -r build/ios/iphoneos/Runner.app Payload/ + zip -r "stack_wallet-ios-aarch64-${{ steps.ver.outputs.version }}.ipa" Payload/ + + - uses: actions/upload-artifact@v7 + with: + name: stack_wallet-ios-aarch64-${{ steps.ver.outputs.version }}.ipa + path: stack_wallet-ios-aarch64-${{ steps.ver.outputs.version }}.ipa + + build-campfire-linux: + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: read + container: + image: ghcr.io/${{ github.repository_owner }}/stackwallet-ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ github.token }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + BUILD_NUMBER="${{ github.run_number }}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + BUILD_NUMBER="${{ inputs.build_number }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + BUILD_NUMBER="${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + + - name: Configure app + run: | + cd scripts + echo "yes" | ./build_app.sh \ + -v "${{ steps.ver.outputs.version }}" \ + -b "${{ steps.ver.outputs.build_number }}" \ + -p linux -a campfire -d -s + + - name: Get dependencies + run: flutter pub get + + - name: Decode secrets + env: + CHANGE_NOW: ${{ secrets.CHANGE_NOW }} + run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart + + - name: Build + env: + USE_SYSTEM_SECURE_STORAGE_DEPS: "1" + run: flutter build linux --release --verbose + + - name: Package + run: | + tar -czf "campfire-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz" \ + -C build/linux/x64/release bundle + + - uses: actions/upload-artifact@v7 + with: + name: campfire-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz + path: campfire-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz + + build-stack-duo-linux: + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: read + container: + image: ghcr.io/${{ github.repository_owner }}/stackwallet-ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ github.token }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + BUILD_NUMBER="${{ github.run_number }}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + BUILD_NUMBER="${{ inputs.build_number }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + BUILD_NUMBER="${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + + - name: Configure app + run: | + cd scripts + echo "yes" | ./build_app.sh \ + -v "${{ steps.ver.outputs.version }}" \ + -b "${{ steps.ver.outputs.build_number }}" \ + -p linux -a stack_duo -d -s + + - name: Get dependencies + run: flutter pub get + + - name: Decode secrets + env: + CHANGE_NOW: ${{ secrets.CHANGE_NOW }} + run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart + + - name: Build + env: + USE_SYSTEM_SECURE_STORAGE_DEPS: "1" + run: flutter build linux --release --verbose + + - name: Package + run: | + tar -czf "stack_duo-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz" \ + -C build/linux/x64/release bundle + + - uses: actions/upload-artifact@v7 + with: + name: stack_duo-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz + path: stack_duo-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz + + build-flatpak: + runs-on: ubuntu-24.04 + needs: build-linux + steps: + - uses: actions/checkout@v6 + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + - name: Download Linux bundle + uses: actions/download-artifact@v8 + with: + name: stack_wallet-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz + + - name: Stage bundle and icon + run: | + tar -xzf "stack_wallet-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz" -C flatpak/ + cp asset_sources/icon/stack_wallet/icon.png flatpak/com.cypherstack.stackwallet.png + + - name: Install Flatpak tools + run: | + sudo apt-get update -q + sudo apt-get install -y flatpak flatpak-builder + + - name: Set up Flathub remote + run: flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + + - name: Cache Flatpak SDK + uses: actions/cache@v5 + with: + path: ~/.local/share/flatpak + key: flatpak-freedesktop-24.08-v1 + + - name: Install Flatpak SDK + run: | + flatpak install --user --noninteractive flathub \ + org.freedesktop.Platform//24.08 \ + org.freedesktop.Sdk//24.08 + + - name: Build Flatpak + run: | + flatpak-builder --user --force-clean \ + --repo=flatpak-repo \ + build-flatpak flatpak/com.cypherstack.stackwallet.yaml + + - name: Bundle Flatpak + run: | + flatpak build-bundle flatpak-repo \ + --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo \ + "stack_wallet-flatpak-x86_64-${{ steps.ver.outputs.version }}.flatpak" \ + com.cypherstack.stackwallet + + - uses: actions/upload-artifact@v7 + with: + name: stack_wallet-flatpak-x86_64-${{ steps.ver.outputs.version }}.flatpak + path: stack_wallet-flatpak-x86_64-${{ steps.ver.outputs.version }}.flatpak + + build-appimage: + runs-on: ubuntu-24.04 + needs: build-linux + steps: + - uses: actions/checkout@v6 + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + - name: Download Linux bundle + uses: actions/download-artifact@v8 + with: + name: stack_wallet-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz + + - name: Install AppImage tools + run: | + sudo apt-get update -q + sudo apt-get install -y squashfs-tools + + - name: Build AppImage + run: | + VERSION="${{ steps.ver.outputs.version }}" + tar -xzf "stack_wallet-linux-x86_64-${VERSION}.tar.gz" + mkdir -p AppDir + cp -r bundle/* AppDir/ + cp appimage/stack_wallet/AppRun AppDir/AppRun + chmod +x AppDir/AppRun + cp appimage/stack_wallet/stack_wallet.desktop AppDir/ + cp asset_sources/icon/stack_wallet/icon.png AppDir/stack_wallet.png + curl -fsSL -o appimagetool \ + https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage + chmod +x appimagetool + ARCH=x86_64 ./appimagetool --appimage-extract-and-run AppDir \ + "stack_wallet-appimage-x86_64-${VERSION}.AppImage" + + - uses: actions/upload-artifact@v7 + with: + name: stack_wallet-appimage-x86_64-${{ steps.ver.outputs.version }}.AppImage + path: stack_wallet-appimage-x86_64-${{ steps.ver.outputs.version }}.AppImage + + build-campfire-android: + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: read + container: + image: ghcr.io/${{ github.repository_owner }}/stackwallet-ci:android + credentials: + username: ${{ github.actor }} + password: ${{ github.token }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + BUILD_NUMBER="${{ github.run_number }}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + BUILD_NUMBER="${{ inputs.build_number }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + BUILD_NUMBER="${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + + - name: Configure app + run: | + cd scripts + echo "yes" | ./build_app.sh \ + -v "${{ steps.ver.outputs.version }}" \ + -b "${{ steps.ver.outputs.build_number }}" \ + -p android -a campfire -d -s + + - name: Get dependencies + run: flutter pub get + + - name: Decode secrets + env: + CHANGE_NOW: ${{ secrets.CHANGE_NOW }} + run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart + + - name: Set up Android local.properties + run: | + cat > android/local.properties < android/keystore-orig.jks + [ -s android/keystore-orig.jks ] || { echo "ERROR: ANDROID_KEYSTORE_BASE64 secret is empty or not set"; exit 1; } + keytool -importkeystore \ + -srckeystore android/keystore-orig.jks \ + -destkeystore android/keystore.jks \ + -deststoretype pkcs12 \ + -srcstorepass "${{ secrets.ANDROID_STORE_PASSWORD }}" \ + -deststorepass "${{ secrets.ANDROID_STORE_PASSWORD }}" \ + -noprompt \ + -J-Dkeystore.pkcs12.legacy + rm android/keystore-orig.jks + cat > android/key.properties <> $GITHUB_OUTPUT + echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.9' + channel: 'stable' + + - uses: actions/setup-go@v6 + with: + go-version: '1.24.13' + + - name: Flutter doctor + run: flutter doctor -v + + - name: Install Rust toolchains + run: | + rustup toolchain install 1.85.1 + rustup toolchain install 1.89.0 + rustup default 1.89.0 + + - name: Configure app + run: | + cd scripts + echo "yes" | ./build_app.sh \ + -v "${{ steps.ver.outputs.version }}" \ + -b "${{ steps.ver.outputs.build_number }}" \ + -p windows -a campfire -d -s + + - name: Replace asset symlinks with copies (CI workaround) + run: | + set -euo pipefail + for dirname in default_themes icon lottie in_app_logo_icons svg; do + target="assets/${dirname}" + source="asset_sources/${dirname}/campfire" + if [ -e "$target" ] || [ -L "$target" ]; then + cmd.exe /c rmdir "$(cygpath -w "$target")" 2>/dev/null || rm -rf "$target" + fi + mkdir -p "$target" + cp -r "${source}/." "$target/" + done + + - name: Get dependencies + run: flutter pub get + + - name: Decode secrets + env: + CHANGE_NOW: ${{ secrets.CHANGE_NOW }} + run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart + + - name: Build secp256k1.dll for Windows + run: dart run coinlib:build_windows + + - name: Build + run: flutter build windows --release + + - name: Package + shell: pwsh + run: | + Compress-Archive -Path "build/windows/x64/runner/Release/*" ` + -DestinationPath "campfire-windows-x86_64-${{ steps.ver.outputs.version }}.zip" + + - uses: actions/upload-artifact@v7 + with: + name: campfire-windows-x86_64-${{ steps.ver.outputs.version }}.zip + path: campfire-windows-x86_64-${{ steps.ver.outputs.version }}.zip + + build-campfire-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + BUILD_NUMBER="${{ github.run_number }}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + BUILD_NUMBER="${{ inputs.build_number }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + BUILD_NUMBER="${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.9' + channel: 'stable' + + - uses: actions/setup-go@v6 + with: + go-version: '1.24.13' + + - name: Install Rust toolchains + run: | + rustup toolchain install 1.85.1 + rustup toolchain install 1.89.0 + rustup default 1.89.0 + + - name: Configure app + run: | + cd scripts + echo "yes" | ./build_app.sh \ + -v "${{ steps.ver.outputs.version }}" \ + -b "${{ steps.ver.outputs.build_number }}" \ + -p macos -a campfire -d -s + + - name: Get dependencies + run: flutter pub get + + - name: Decode secrets + env: + CHANGE_NOW: ${{ secrets.CHANGE_NOW }} + run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart + + - name: Build + run: flutter build macos --release + + - name: Package + run: | + cd "build/macos/Build/Products/Release" + zip -r "$GITHUB_WORKSPACE/campfire-macos-aarch64-${{ steps.ver.outputs.version }}.zip" \ + "Campfire.app" + + - uses: actions/upload-artifact@v7 + with: + name: campfire-macos-aarch64-${{ steps.ver.outputs.version }}.zip + path: campfire-macos-aarch64-${{ steps.ver.outputs.version }}.zip + + build-campfire-ios: + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + BUILD_NUMBER="${{ github.run_number }}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + BUILD_NUMBER="${{ inputs.build_number }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + BUILD_NUMBER="${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + + - uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + targets: aarch64-apple-ios + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.9' + channel: 'stable' + + - uses: actions/setup-go@v6 + with: + go-version: '1.24.13' + + - name: Install additional Rust toolchains + run: | + rustup toolchain install 1.85.1 + rustup toolchain install 1.89.0 + rustup default 1.89.0 + rustup target add aarch64-apple-ios --toolchain 1.89.0 + rustup target add x86_64-apple-ios --toolchain 1.89.0 + + - name: Configure app + run: | + cd scripts + echo "yes" | ./build_app.sh \ + -v "${{ steps.ver.outputs.version }}" \ + -b "${{ steps.ver.outputs.build_number }}" \ + -p ios -a campfire -d -s + + - name: Get dependencies + run: flutter pub get + + - name: Decode secrets + env: + CHANGE_NOW: ${{ secrets.CHANGE_NOW }} + run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart + + - name: Build + run: flutter build ios --release --no-codesign + + - name: Package IPA + run: | + mkdir Payload + cp -r build/ios/iphoneos/Runner.app Payload/ + zip -r "campfire-ios-aarch64-${{ steps.ver.outputs.version }}.ipa" Payload/ + + - uses: actions/upload-artifact@v7 + with: + name: campfire-ios-aarch64-${{ steps.ver.outputs.version }}.ipa + path: campfire-ios-aarch64-${{ steps.ver.outputs.version }}.ipa + + build-campfire-flatpak: + runs-on: ubuntu-24.04 + needs: build-campfire-linux + steps: + - uses: actions/checkout@v6 + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + - name: Download Linux bundle + uses: actions/download-artifact@v8 + with: + name: campfire-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz + + - name: Stage bundle and icon + run: | + tar -xzf "campfire-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz" -C flatpak/ + cp asset_sources/icon/campfire/icon.png flatpak/com.cypherstack.campfire.png + + - name: Install Flatpak tools + run: | + sudo apt-get update -q + sudo apt-get install -y flatpak flatpak-builder + + - name: Set up Flathub remote + run: flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + + - name: Cache Flatpak SDK + uses: actions/cache@v5 + with: + path: ~/.local/share/flatpak + key: flatpak-freedesktop-24.08-v1 + + - name: Install Flatpak SDK + run: | + flatpak install --user --noninteractive flathub \ + org.freedesktop.Platform//24.08 \ + org.freedesktop.Sdk//24.08 + + - name: Build Flatpak + run: | + flatpak-builder --user --force-clean \ + --repo=flatpak-repo \ + build-flatpak flatpak/com.cypherstack.campfire.yaml + + - name: Bundle Flatpak + run: | + flatpak build-bundle flatpak-repo \ + --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo \ + "campfire-flatpak-x86_64-${{ steps.ver.outputs.version }}.flatpak" \ + com.cypherstack.campfire + + - uses: actions/upload-artifact@v7 + with: + name: campfire-flatpak-x86_64-${{ steps.ver.outputs.version }}.flatpak + path: campfire-flatpak-x86_64-${{ steps.ver.outputs.version }}.flatpak + + build-campfire-appimage: + runs-on: ubuntu-24.04 + needs: build-campfire-linux + steps: + - uses: actions/checkout@v6 + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + - name: Download Linux bundle + uses: actions/download-artifact@v8 + with: + name: campfire-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz + + - name: Install AppImage tools + run: | + sudo apt-get update -q + sudo apt-get install -y squashfs-tools + + - name: Build AppImage + run: | + VERSION="${{ steps.ver.outputs.version }}" + tar -xzf "campfire-linux-x86_64-${VERSION}.tar.gz" + mkdir -p AppDir + cp -r bundle/* AppDir/ + cp appimage/campfire/AppRun AppDir/AppRun + chmod +x AppDir/AppRun + cp appimage/campfire/campfire.desktop AppDir/ + cp asset_sources/icon/campfire/icon.png AppDir/campfire.png + curl -fsSL -o appimagetool \ + https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage + chmod +x appimagetool + ARCH=x86_64 ./appimagetool --appimage-extract-and-run AppDir \ + "campfire-appimage-x86_64-${VERSION}.AppImage" + + - uses: actions/upload-artifact@v7 + with: + name: campfire-appimage-x86_64-${{ steps.ver.outputs.version }}.AppImage + path: campfire-appimage-x86_64-${{ steps.ver.outputs.version }}.AppImage + + build-stack-duo-android: + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: read + container: + image: ghcr.io/${{ github.repository_owner }}/stackwallet-ci:android + credentials: + username: ${{ github.actor }} + password: ${{ github.token }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + BUILD_NUMBER="${{ github.run_number }}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + BUILD_NUMBER="${{ inputs.build_number }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + BUILD_NUMBER="${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + + - name: Configure app + run: | + cd scripts + echo "yes" | ./build_app.sh \ + -v "${{ steps.ver.outputs.version }}" \ + -b "${{ steps.ver.outputs.build_number }}" \ + -p android -a stack_duo -d -s + + - name: Get dependencies + run: flutter pub get + + - name: Decode secrets + env: + CHANGE_NOW: ${{ secrets.CHANGE_NOW }} + run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart + + - name: Set up Android local.properties + run: | + cat > android/local.properties < android/keystore-orig.jks + [ -s android/keystore-orig.jks ] || { echo "ERROR: ANDROID_KEYSTORE_BASE64 secret is empty or not set"; exit 1; } + keytool -importkeystore \ + -srckeystore android/keystore-orig.jks \ + -destkeystore android/keystore.jks \ + -deststoretype pkcs12 \ + -srcstorepass "${{ secrets.ANDROID_STORE_PASSWORD }}" \ + -deststorepass "${{ secrets.ANDROID_STORE_PASSWORD }}" \ + -noprompt \ + -J-Dkeystore.pkcs12.legacy + rm android/keystore-orig.jks + cat > android/key.properties <> $GITHUB_OUTPUT + echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.9' + channel: 'stable' + + - uses: actions/setup-go@v6 + with: + go-version: '1.24.13' + + - name: Flutter doctor + run: flutter doctor -v + + - name: Install Rust toolchains + run: | + rustup toolchain install 1.85.1 + rustup toolchain install 1.89.0 + rustup default 1.89.0 + + - name: Configure app + run: | + cd scripts + echo "yes" | ./build_app.sh \ + -v "${{ steps.ver.outputs.version }}" \ + -b "${{ steps.ver.outputs.build_number }}" \ + -p windows -a stack_duo -d -s + + - name: Replace asset symlinks with copies (CI workaround) + run: | + set -euo pipefail + for dirname in default_themes icon lottie in_app_logo_icons svg; do + target="assets/${dirname}" + source="asset_sources/${dirname}/stack_duo" + if [ -e "$target" ] || [ -L "$target" ]; then + cmd.exe /c rmdir "$(cygpath -w "$target")" 2>/dev/null || rm -rf "$target" + fi + mkdir -p "$target" + cp -r "${source}/." "$target/" + done + + - name: Get dependencies + run: flutter pub get + + - name: Decode secrets + env: + CHANGE_NOW: ${{ secrets.CHANGE_NOW }} + run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart + + - name: Build secp256k1.dll for Windows + run: dart run coinlib:build_windows + + - name: Build + run: flutter build windows --release + + - name: Package + shell: pwsh + run: | + Compress-Archive -Path "build/windows/x64/runner/Release/*" ` + -DestinationPath "stack_duo-windows-x86_64-${{ steps.ver.outputs.version }}.zip" + + - uses: actions/upload-artifact@v7 + with: + name: stack_duo-windows-x86_64-${{ steps.ver.outputs.version }}.zip + path: stack_duo-windows-x86_64-${{ steps.ver.outputs.version }}.zip + + build-stack-duo-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + BUILD_NUMBER="${{ github.run_number }}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + BUILD_NUMBER="${{ inputs.build_number }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + BUILD_NUMBER="${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.9' + channel: 'stable' + + - uses: actions/setup-go@v6 + with: + go-version: '1.24.13' + + - name: Install Rust toolchains + run: | + rustup toolchain install 1.85.1 + rustup toolchain install 1.89.0 + rustup default 1.89.0 + rustup target add aarch64-apple-darwin --toolchain 1.89.0 + cargo install cargo-lipo + + - name: Configure app + run: | + cd scripts + echo "yes" | ./build_app.sh \ + -v "${{ steps.ver.outputs.version }}" \ + -b "${{ steps.ver.outputs.build_number }}" \ + -p macos -a stack_duo -d -s + + - name: Get dependencies + run: flutter pub get + + - name: Decode secrets + env: + CHANGE_NOW: ${{ secrets.CHANGE_NOW }} + run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart + + - name: Build + run: flutter build macos --release + + - name: Package + run: | + cd "build/macos/Build/Products/Release" + zip -r "$GITHUB_WORKSPACE/stack_duo-macos-aarch64-${{ steps.ver.outputs.version }}.zip" \ + "Stack Duo.app" + + - uses: actions/upload-artifact@v7 + with: + name: stack_duo-macos-aarch64-${{ steps.ver.outputs.version }}.zip + path: stack_duo-macos-aarch64-${{ steps.ver.outputs.version }}.zip + + build-stack-duo-ios: + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + BUILD_NUMBER="${{ github.run_number }}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + BUILD_NUMBER="${{ inputs.build_number }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + BUILD_NUMBER="${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + + - uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + targets: aarch64-apple-ios + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.9' + channel: 'stable' + + - uses: actions/setup-go@v6 + with: + go-version: '1.24.13' + + - name: Install additional Rust toolchains + run: | + rustup toolchain install 1.85.1 + rustup toolchain install 1.89.0 + rustup default 1.89.0 + rustup target add aarch64-apple-ios --toolchain 1.89.0 + rustup target add x86_64-apple-ios --toolchain 1.89.0 + + - name: Configure app + run: | + cd scripts + echo "yes" | ./build_app.sh \ + -v "${{ steps.ver.outputs.version }}" \ + -b "${{ steps.ver.outputs.build_number }}" \ + -p ios -a stack_duo -d -s + + - name: Get dependencies + run: flutter pub get + + - name: Decode secrets + env: + CHANGE_NOW: ${{ secrets.CHANGE_NOW }} + run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart + + - name: Build + run: flutter build ios --release --no-codesign + + - name: Package IPA + run: | + mkdir Payload + cp -r build/ios/iphoneos/Runner.app Payload/ + zip -r "stack_duo-ios-aarch64-${{ steps.ver.outputs.version }}.ipa" Payload/ + + - uses: actions/upload-artifact@v7 + with: + name: stack_duo-ios-aarch64-${{ steps.ver.outputs.version }}.ipa + path: stack_duo-ios-aarch64-${{ steps.ver.outputs.version }}.ipa + + build-stack-duo-flatpak: + runs-on: ubuntu-24.04 + needs: build-stack-duo-linux + steps: + - uses: actions/checkout@v6 + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + - name: Download Linux bundle + uses: actions/download-artifact@v8 + with: + name: stack_duo-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz + + - name: Stage bundle and icon + run: | + tar -xzf "stack_duo-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz" -C flatpak/ + cp asset_sources/icon/stack_duo/icon.png flatpak/com.cypherstack.stackduo.png + + - name: Install Flatpak tools + run: | + sudo apt-get update -q + sudo apt-get install -y flatpak flatpak-builder + + - name: Set up Flathub remote + run: flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + + - name: Cache Flatpak SDK + uses: actions/cache@v5 + with: + path: ~/.local/share/flatpak + key: flatpak-freedesktop-24.08-v1 + + - name: Install Flatpak SDK + run: | + flatpak install --user --noninteractive flathub \ + org.freedesktop.Platform//24.08 \ + org.freedesktop.Sdk//24.08 + + - name: Build Flatpak + run: | + flatpak-builder --user --force-clean \ + --repo=flatpak-repo \ + build-flatpak flatpak/com.cypherstack.stackduo.yaml + + - name: Bundle Flatpak + run: | + flatpak build-bundle flatpak-repo \ + --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo \ + "stack_duo-flatpak-x86_64-${{ steps.ver.outputs.version }}.flatpak" \ + com.cypherstack.stackduo + + - uses: actions/upload-artifact@v7 + with: + name: stack_duo-flatpak-x86_64-${{ steps.ver.outputs.version }}.flatpak + path: stack_duo-flatpak-x86_64-${{ steps.ver.outputs.version }}.flatpak + + build-stack-duo-appimage: + runs-on: ubuntu-24.04 + needs: build-stack-duo-linux + steps: + - uses: actions/checkout@v6 + + - name: Set version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION="0.0.0-staging.${{ github.run_number }}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + - name: Download Linux bundle + uses: actions/download-artifact@v8 + with: + name: stack_duo-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz + + - name: Install AppImage tools + run: | + sudo apt-get update -q + sudo apt-get install -y squashfs-tools + + - name: Build AppImage + run: | + VERSION="${{ steps.ver.outputs.version }}" + tar -xzf "stack_duo-linux-x86_64-${VERSION}.tar.gz" + mkdir -p AppDir + cp -r bundle/* AppDir/ + cp appimage/stack_duo/AppRun AppDir/AppRun + chmod +x AppDir/AppRun + cp appimage/stack_duo/stack_duo.desktop AppDir/ + cp asset_sources/icon/stack_duo/icon.png AppDir/stack_duo.png + curl -fsSL -o appimagetool \ + https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage + chmod +x appimagetool + ARCH=x86_64 ./appimagetool --appimage-extract-and-run AppDir \ + "stack_duo-appimage-x86_64-${VERSION}.AppImage" + + - uses: actions/upload-artifact@v7 + with: + name: stack_duo-appimage-x86_64-${{ steps.ver.outputs.version }}.AppImage + path: stack_duo-appimage-x86_64-${{ steps.ver.outputs.version }}.AppImage + diff --git a/.github/workflows/release-mwebd-windows.yaml b/.github/workflows/release-mwebd-windows.yaml new file mode 100644 index 0000000000..c826ac878a --- /dev/null +++ b/.github/workflows/release-mwebd-windows.yaml @@ -0,0 +1,50 @@ +name: Release mwebd Windows binary + +on: + workflow_dispatch: + inputs: + mwebd_version: + description: 'mwebd tag to build (must match _mwebdVersion in tool/build_standalone_mwebd_windows.dart)' + required: true + default: 'v0.1.8' + +permissions: + contents: write + +jobs: + build-and-release: + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Clone mwebd + run: git clone https://github.com/ltcmweb/mwebd.git --branch "${{ inputs.mwebd_version }}" mwebd + + - name: Build mwebd.exe + working-directory: mwebd + env: + CGO_ENABLED: '1' + run: go build -v -o ../mwebd.exe github.com/ltcmweb/mwebd/cmd/mwebd + + - name: Compute sha256 + run: sha256sum mwebd.exe | awk '{print $1}' > mwebd.exe.sha256 + + - name: Publish release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + TAG="mwebd-${{ inputs.mwebd_version }}" + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" mwebd.exe mwebd.exe.sha256 --clobber + else + gh release create "$TAG" \ + --title "mwebd ${{ inputs.mwebd_version }} (windows-amd64)" \ + --notes "Pre-built Windows binary for ltcmweb/mwebd ${{ inputs.mwebd_version }}, built with native Go on windows-latest. Used by the Stack Wallet Windows build via tool/build_standalone_mwebd_windows.dart --fetch." \ + mwebd.exe mwebd.exe.sha256 + fi diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e46a16c6cc..eca0145f8b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,116 +1,97 @@ -#should deny name: Test on: [pull_request] jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: read + container: + image: ghcr.io/${{ github.repository_owner }}/stackwallet-ci:test + credentials: + username: ${{ github.actor }} + password: ${{ github.token }} steps: - name: Prepare repository - uses: actions/checkout@v4 - - name: Install Flutter - uses: subosito/flutter-action@v2 + uses: actions/checkout@v6 with: - flutter-version: '3.19.6' - channel: 'stable' - - name: Setup | Rust - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - name: Checkout submodules - run: git submodule update --init --recursive - - name: install dependencies - run: | - cargo install cargo-ndk - rustup target add x86_64-unknown-linux-gnu - sudo apt clean - sudo apt update - sudo apt install -y unzip automake build-essential file pkg-config git python libtool libtinfo5 cmake openjdk-8-jre-headless libgit2-dev clang libncurses5-dev libncursesw5-dev zlib1g-dev llvm debhelper libclang-dev opencl-headers libssl-dev ocl-icd-opencl-dev libc6-dev-i386 - - name: Build Epic Cash + fetch-depth: 0 + submodules: recursive + + - name: Configure app run: | - cd crypto_plugins/flutter_libepiccash/scripts/linux/ - ./build_all.sh + 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 temp files - id: secret-file1 + + - name: Create git_versions.dart stubs run: | - $secretFileExchange = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath "lib/external_api_keys.dart"; - $encodedBytes = [System.Convert]::FromBase64String($env:CHANGE_NOW); - Set-Content $secretFileExchange -Value $encodedBytes -AsByteStream; - $secretFileExchangeHash = Get-FileHash $secretFileExchange; - Write-Output "Secret file $secretFileExchange has hash $($secretFileExchangeHash.Hash)"; - - $secretFileBitcoin = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath "test/services/coins/bitcoin/bitcoin_wallet_test_parameters.dart"; - $encodedBytes = [System.Convert]::FromBase64String($env:BITCOIN_TEST); - Set-Content $secretFileBitcoin -Value $encodedBytes -AsByteStream; - $secretFileBitcoinHash = Get-FileHash $secretFileBitcoin; - Write-Output "Secret file $secretFileBitcoin has hash $($secretFileBitcoinHash.Hash)"; - - $secretFileDogecoin = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath "test/services/coins/dogecoin/dogecoin_wallet_test_parameters.dart"; - $encodedBytes = [System.Convert]::FromBase64String($env:DOGECOIN_TEST); - Set-Content $secretFileDogecoin -Value $encodedBytes -AsByteStream; - $secretFileDogecoinHash = Get-FileHash $secretFileDogecoin; - Write-Output "Secret file $secretFileDogecoin has hash $($secretFileDogecoinHash.Hash)"; - - $secretFileFiro = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath "test/services/coins/firo/firo_wallet_test_parameters.dart"; - $encodedBytes = [System.Convert]::FromBase64String($env:FIRO_TEST); - Set-Content $secretFileFiro -Value $encodedBytes -AsByteStream; - $secretFileFiroHash = Get-FileHash $secretFileFiro; - Write-Output "Secret file $secretFileFiro has hash $($secretFileFiroHash.Hash)"; - - $secretFileBitcoinCash = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath "test/services/coins/bitcoincash/bitcoincash_wallet_test_parameters.dart"; - $encodedBytes = [System.Convert]::FromBase64String($env:BITCOINCASH_TEST); - Set-Content $secretFileBitcoinCash -Value $encodedBytes -AsByteStream; - $secretFileBitcoinCashHash = Get-FileHash $secretFileBitcoinCash; - Write-Output "Secret file $secretFileBitcoinCash has hash $($secretFileBitcoinCashHash.Hash)"; - - $secretFileNamecoin = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath "test/services/coins/namecoin/namecoin_wallet_test_parameters.dart"; - $encodedBytes = [System.Convert]::FromBase64String($env:NAMECOIN_TEST); - Set-Content $secretFileNamecoin -Value $encodedBytes -AsByteStream; - $secretFileNamecoinHash = Get-FileHash $secretFileNamecoin; - Write-Output "Secret file $secretFileNamecoin has hash $($secretFileNamecoinHash.Hash)"; - - $secretFileParticl = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath "test/services/coins/particl/particl_wallet_test_parameters.dart"; - $encodedBytes = [System.Convert]::FromBase64String($env:PARTICL_TEST); - Set-Content $secretFileParticl -Value $encodedBytes -AsByteStream; - $secretFileParticlHash = Get-FileHash $secretFileParticl; - Write-Output "Secret file $secretFileParticl has hash $($secretFileParticlHash.Hash)"; - - shell: pwsh + 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 + + cat > crypto_plugins/flutter_libmwc/lib/git_versions.dart << 'EOF' + String getPluginVersion() => "stub-for-tests"; + EOF + + - name: Decode secrets env: CHANGE_NOW: ${{ secrets.CHANGE_NOW }} - BITCOIN_TEST: ${{ secrets.BITCOIN_TEST }} - DOGECOIN_TEST: ${{ secrets.DOGECOIN_TEST }} - FIRO_TEST: ${{ secrets.FIRO_TEST }} - BITCOINCASH_TEST: ${{ secrets.BITCOINCASH_TEST }} - NAMECOIN_TEST: ${{ secrets.NAMECOIN_TEST }} - PARTICL_TEST: ${{ secrets.PARTICL_TEST }} + run: | + if [ -n "$CHANGE_NOW" ]; then + echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart + else + cat > lib/external_api_keys.dart << 'EOF' + const String kChangeNowApiKey = ""; + const String kSimpleSwapApiKey = ""; + const String kNanswapApiKey = ""; + const String kNanoSwapRpcApiKey = ""; + const String kWizSwapApiKey = ""; + const kShopInBitAccessKey = ""; + const kShopInBitPartnerSecret = ""; + const kCakePayApiToken = ""; + const kExolixApiKey = ""; + EOF + fi + + - name: Ensure app config for tests + run: bash scripts/ensure_test_app_config.sh + + - name: Create test stubs + 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 - run: flutter test --coverage + run: | + bash scripts/ensure_test_app_config.sh + test -s lib/app_config.g.dart + grep -Fq "part of 'app_config.dart';" lib/app_config.g.dart + flutter test --coverage - name: Upload to code coverage uses: codecov/codecov-action@v1.2.2 if: success() || failure() with: token: ${{secrets.CODECOV_TOKEN}} file: coverage/lcov.info - - name: Delete temp files - run: | - $secretFileExchange = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath "lib/external_api_keys.dart"; - $secretFileBitcoin = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath "test/services/coins/bitcoin/bitcoin_wallet_test_parameters.dart"; - $secretFileDogecoin = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath "test/services/coins/dogecoin/dogecoin_wallet_test_parameters.dart"; - $secretFileFiro = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath "test/services/coins/firo/firo_wallet_test_parameters.dart"; - $secretFileBitcoinCash = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath "test/services/coins/bitcoincash/bitcoincash_wallet_test_parameters.dart"; - $secretFileNamecoin = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath "test/services/coins/namecoin/namecoin_wallet_test_parameters.dart"; - $secretFileParticl = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath "test/services/coins/particl/particl_wallet_test_parameters.dart"; - - Remove-Item -Path $secretFileExchange; - Remove-Item -Path $secretFileBitcoin; - Remove-Item -Path $secretFileDogecoin; - Remove-Item -Path $secretFileFiro; - Remove-Item -Path $secretFileBitcoinCash; - Remove-Item -Path $secretFileNamecoin; - Remove-Item -Path $secretFileParticl; - shell: pwsh - if: always() diff --git a/.gitignore b/.gitignore index 0a8db4dcc3..47d368cb82 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ Microsoft.Windows* .pub/ /build/ android/app/.cxx +android/build/ # Web related lib/generated_plugin_registrant.dart @@ -70,10 +71,6 @@ secp256k1.dll /lib/app_config.g.dart /android/app/src/main/app_icon-playstore.png -# Dart generated files (Freezed, Riverpod, GoRouter etc..) -lib/**/*.g.dart -lib/**/*.freezed.dart - ## other generated project files pubspec.yaml @@ -85,6 +82,7 @@ pubspec.yaml /android/app/src/main/profile/AndroidManifest.xml /android/app/src/main/kotlin/com/cypherstack/stackwallet/MainActivity.kt /android/app/src/main/res/**/ic_launcher.png +/android/app/src/main/res/**/splash.png /ios/Runner/Info.plist /ios/Runner.xcodeproj/project.pbxproj @@ -122,3 +120,6 @@ lib/wl_gen/generated/ /linux/flutter/generated_plugins.cmake /windows/flutter/generated_plugins.cmake /macos/Flutter/GeneratedPluginRegistrant.swift + +/assets/windows/mwebd.exe +/tool/build diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..e657c616d3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,211 @@ +# syntax=docker/dockerfile:1.7 +FROM ubuntu:24.04 AS full + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 + + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl file git gnupg python3 sudo unzip xz-utils \ + automake build-essential cmake debhelper libtool meson ninja-build pkg-config rsync \ + clang libclang-dev llvm \ + libgcrypt20-dev libgirepository1.0-dev libgit2-dev libglib2.0-dev libgtk-3-dev \ + libjsoncpp-dev liblzma-dev libncurses5-dev libncursesw5-dev \ + libopencv-dev \ + libsecret-1-dev libssl-dev libtss2-dev \ + ocl-icd-opencl-dev opencl-headers valac zlib1g-dev \ + g++-aarch64-linux-gnu gcc-aarch64-linux-gnu \ + g++-mingw-w64-x86-64 gcc-mingw-w64-x86-64 \ + openjdk-21-jdk-headless \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +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.89.0 --profile minimal --no-modify-path \ + && rustup install 1.85.1 1.71.0 stable --profile minimal \ + && rustup target add x86_64-unknown-linux-gnu --toolchain 1.89.0 \ + && cargo install cargo-ndk \ + && chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME" + +ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 + +ENV ANDROID_SDK_ROOT=/opt/android-sdk \ + ANDROID_HOME=/opt/android-sdk \ + ANDROID_NDK_ROOT=/opt/android-sdk/ndk/28.2.13676358 \ + ANDROID_NDK_HOME=/opt/android-sdk/ndk/28.2.13676358 \ + PATH=/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:$PATH + +RUN mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" \ + && curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-14742923_latest.zip \ + -o /tmp/cmdline-tools.zip \ + && echo "48833c34b761c10cb20bcd16582129395d121b27 /tmp/cmdline-tools.zip" | sha1sum -c \ + && unzip -q /tmp/cmdline-tools.zip -d "$ANDROID_SDK_ROOT/cmdline-tools" \ + && mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest" \ + && rm /tmp/cmdline-tools.zip \ + && mkdir -p "$ANDROID_SDK_ROOT/licenses" \ + && printf '\n24333f8a63b6825ea9c5514f83c2829b004d1fee\n8933bad161af4178b1185d1a37fbf41ea5269c55d7b9237478ea8ec3307c27e4' \ + > "$ANDROID_SDK_ROOT/licenses/android-sdk-license" \ + && printf '\n84831b9409646a918e30573bab4c9c91346d8abd' \ + > "$ANDROID_SDK_ROOT/licenses/android-sdk-preview-license" \ + && printf '\n859f317696f67ef3d7f30a50a5560e7834b43903' \ + > "$ANDROID_SDK_ROOT/licenses/android-sdk-arm-dbt-license" \ + && sdkmanager \ + "platform-tools" \ + "build-tools;35.0.0" \ + "platforms;android-32" \ + "platforms;android-33" \ + "platforms;android-34" \ + "platforms;android-35" \ + "platforms;android-36" \ + "ndk;28.0.13004108" \ + "ndk;28.2.13676358" \ + "cmake;3.22.1" \ + && chmod -R a+rwX "$ANDROID_SDK_ROOT" + +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" \ + && git config --global --add safe.directory '*' \ + && flutter config --no-analytics \ + && flutter precache --linux --android \ + && chmod -R a+rwX "$FLUTTER_HOME" + +RUN git config --system --add safe.directory '*' + +RUN flutter --version && rustc --version && cargo --version && node --version && go version + + +# Android-only image: no Linux/Windows cross-compilers, no OpenCV/OpenCL, single Rust toolchain with android targets +FROM ubuntu:24.04 AS android + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl file git gnupg python3 sudo unzip xz-utils \ + build-essential cmake ninja-build pkg-config \ + libssl-dev zlib1g-dev \ + openjdk-21-jdk-headless \ + && rm -rf /var/lib/apt/lists/* + +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.89.0 --profile minimal --no-modify-path \ + && rustup target add \ + aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android \ + --toolchain 1.89.0 \ + && cargo install cargo-ndk \ + && chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME" + +ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 + +ENV ANDROID_SDK_ROOT=/opt/android-sdk \ + ANDROID_HOME=/opt/android-sdk \ + ANDROID_NDK_ROOT=/opt/android-sdk/ndk/28.2.13676358 \ + ANDROID_NDK_HOME=/opt/android-sdk/ndk/28.2.13676358 \ + PATH=/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:$PATH + +RUN mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" \ + && curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-14742923_latest.zip \ + -o /tmp/cmdline-tools.zip \ + && echo "48833c34b761c10cb20bcd16582129395d121b27 /tmp/cmdline-tools.zip" | sha1sum -c \ + && unzip -q /tmp/cmdline-tools.zip -d "$ANDROID_SDK_ROOT/cmdline-tools" \ + && mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest" \ + && rm /tmp/cmdline-tools.zip \ + && mkdir -p "$ANDROID_SDK_ROOT/licenses" \ + && printf '\n24333f8a63b6825ea9c5514f83c2829b004d1fee\n8933bad161af4178b1185d1a37fbf41ea5269c55d7b9237478ea8ec3307c27e4' \ + > "$ANDROID_SDK_ROOT/licenses/android-sdk-license" \ + && printf '\n84831b9409646a918e30573bab4c9c91346d8abd' \ + > "$ANDROID_SDK_ROOT/licenses/android-sdk-preview-license" \ + && printf '\n859f317696f67ef3d7f30a50a5560e7834b43903' \ + > "$ANDROID_SDK_ROOT/licenses/android-sdk-arm-dbt-license" \ + && sdkmanager \ + "platform-tools" \ + "build-tools;35.0.0" \ + "platforms;android-32" \ + "platforms;android-33" \ + "platforms;android-34" \ + "platforms;android-35" \ + "platforms;android-36" \ + "ndk;28.0.13004108" \ + "ndk;28.2.13676358" \ + "cmake;3.22.1" \ + && chmod -R a+rwX "$ANDROID_SDK_ROOT" + +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" \ + && git config --global --add safe.directory '*' \ + && flutter config --no-analytics \ + && flutter precache --android \ + && chmod -R a+rwX "$FLUTTER_HOME" + +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) +FROM ubuntu:24.04 AS test + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl file git unzip xz-utils \ + build-essential cmake ninja-build pkg-config \ + clang libclang-dev \ + libgirepository1.0-dev libglib2.0-dev libgtk-3-dev \ + libjsoncpp-dev liblzma-dev libsecret-1-dev libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +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" \ + && 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 diff --git a/analysis_options.yaml b/analysis_options.yaml index 1252b9a85b..f63ed52ef1 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -94,6 +94,7 @@ linter: constant_identifier_names: false prefer_final_locals: true prefer_final_in_for_each: true + lines_longer_than_80_chars: true # require_trailing_commas: true // causes issues with dart 3.7 # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 6a7964eae8..e1c48c38c0 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -32,4 +32,9 @@ -keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken # required for flutter file_picker --keep class androidx.lifecycle.DefaultLifecycleObserver \ No newline at end of file +-keep class androidx.lifecycle.DefaultLifecycleObserver + +# required for flutter_secure_storage +-dontwarn com.google.errorprone.annotations.** +-dontwarn javax.annotation.Nullable +-dontwarn javax.annotation.concurrent.GuardedBy diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png deleted file mode 100644 index b57b77cf00..0000000000 Binary files a/android/app/src/main/res/drawable-hdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png deleted file mode 100644 index 47903b9092..0000000000 Binary files a/android/app/src/main/res/drawable-mdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night-v21/background.png b/android/app/src/main/res/drawable-night-v21/background.png new file mode 100644 index 0000000000..5596c666ea Binary files /dev/null and b/android/app/src/main/res/drawable-night-v21/background.png differ diff --git a/android/app/src/main/res/drawable-night-v21/launch_background.xml b/android/app/src/main/res/drawable-night-v21/launch_background.xml new file mode 100644 index 0000000000..3cc4948a14 --- /dev/null +++ b/android/app/src/main/res/drawable-night-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable-night/background.png b/android/app/src/main/res/drawable-night/background.png new file mode 100644 index 0000000000..5596c666ea Binary files /dev/null and b/android/app/src/main/res/drawable-night/background.png differ diff --git a/android/app/src/main/res/drawable-night/launch_background.xml b/android/app/src/main/res/drawable-night/launch_background.xml new file mode 100644 index 0000000000..3cc4948a14 --- /dev/null +++ b/android/app/src/main/res/drawable-night/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png index 8a4950a508..60661e9a30 100644 Binary files a/android/app/src/main/res/drawable-v21/background.png and b/android/app/src/main/res/drawable-v21/background.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml index 3fe6b2e882..3cc4948a14 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -6,4 +6,4 @@ - \ No newline at end of file + diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png deleted file mode 100644 index 863332e3c4..0000000000 Binary files a/android/app/src/main/res/drawable-xhdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png deleted file mode 100644 index 17c2de611a..0000000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png deleted file mode 100644 index 9b86db9d41..0000000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png index 8a4950a508..60661e9a30 100644 Binary files a/android/app/src/main/res/drawable/background.png and b/android/app/src/main/res/drawable/background.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 3fe6b2e882..3cc4948a14 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -6,4 +6,4 @@ - \ No newline at end of file + diff --git a/android/app/src/main/res/raw/keep.xml b/android/app/src/main/res/raw/keep.xml deleted file mode 100644 index 1d6c664db0..0000000000 --- a/android/app/src/main/res/raw/keep.xml +++ /dev/null @@ -1,3 +0,0 @@ - - \ No newline at end of file diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000000..640c7ab463 --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000000..dbc9ea9f1b --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml index e02ab7e5e7..8ba5d9a0bc 100644 --- a/android/app/src/main/res/values-v31/styles.xml +++ b/android/app/src/main/res/values-v31/styles.xml @@ -2,10 +2,11 @@ + + + - \ No newline at end of file + diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index afa1e8eb0a..e4ef43fb98 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/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.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 04c37e5f2b..ebf08564f2 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version '8.7.0' apply false + id "com.android.application" version '8.11.1' apply false id "org.jetbrains.kotlin.android" version "2.2.20" apply false } diff --git a/appimage/campfire/AppRun b/appimage/campfire/AppRun new file mode 100755 index 0000000000..2d22d3d224 --- /dev/null +++ b/appimage/campfire/AppRun @@ -0,0 +1,5 @@ +#!/bin/bash +SELF=$(readlink -f "$0") +HERE=${SELF%/*} +export LD_LIBRARY_PATH="${HERE}/lib:${LD_LIBRARY_PATH}" +exec "${HERE}/campfire" "$@" diff --git a/appimage/campfire/campfire.desktop b/appimage/campfire/campfire.desktop new file mode 100644 index 0000000000..71b7b612c0 --- /dev/null +++ b/appimage/campfire/campfire.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Name=Campfire +Comment=Your privacy. Your wallet. Your Firo. +Exec=campfire +Icon=campfire +Type=Application +Categories=Finance; diff --git a/appimage/stack_duo/AppRun b/appimage/stack_duo/AppRun new file mode 100755 index 0000000000..9b8f349b2b --- /dev/null +++ b/appimage/stack_duo/AppRun @@ -0,0 +1,5 @@ +#!/bin/bash +SELF=$(readlink -f "$0") +HERE=${SELF%/*} +export LD_LIBRARY_PATH="${HERE}/lib:${LD_LIBRARY_PATH}" +exec "${HERE}/stack_duo" "$@" diff --git a/appimage/stack_duo/stack_duo.desktop b/appimage/stack_duo/stack_duo.desktop new file mode 100644 index 0000000000..64b9f6de44 --- /dev/null +++ b/appimage/stack_duo/stack_duo.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Name=Stack Duo +Comment=An open-source, multicoin wallet for everyone +Exec=stack_duo +Icon=stack_duo +Type=Application +Categories=Finance; diff --git a/appimage/stack_wallet/AppRun b/appimage/stack_wallet/AppRun new file mode 100755 index 0000000000..6038f4b29d --- /dev/null +++ b/appimage/stack_wallet/AppRun @@ -0,0 +1,5 @@ +#!/bin/bash +SELF=$(readlink -f "$0") +HERE=${SELF%/*} +export LD_LIBRARY_PATH="${HERE}/lib:${LD_LIBRARY_PATH}" +exec "${HERE}/stack_wallet" "$@" diff --git a/appimage/stack_wallet/stack_wallet.desktop b/appimage/stack_wallet/stack_wallet.desktop new file mode 100644 index 0000000000..19c6fcce56 --- /dev/null +++ b/appimage/stack_wallet/stack_wallet.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Name=Stack Wallet +Comment=Open-source non-custodial cryptocurrency wallet +Exec=stack_wallet +Icon=stack_wallet +Type=Application +Categories=Finance; diff --git a/asset_sources/icon/campfire/icon.png b/asset_sources/icon/campfire/icon.png index bea9f072ee..d0f2ad98a9 100644 Binary files a/asset_sources/icon/campfire/icon.png and b/asset_sources/icon/campfire/icon.png differ diff --git a/asset_sources/icon/stack_duo/splash.png b/asset_sources/icon/stack_duo/splash.png new file mode 100644 index 0000000000..3078ee9e27 Binary files /dev/null and b/asset_sources/icon/stack_duo/splash.png differ diff --git a/asset_sources/svg/campfire/exchange_icons/cyphergoat.svg b/asset_sources/svg/campfire/exchange_icons/cyphergoat.svg new file mode 100644 index 0000000000..0ea11c36da --- /dev/null +++ b/asset_sources/svg/campfire/exchange_icons/cyphergoat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset_sources/svg/campfire/exchange_icons/exolix.png b/asset_sources/svg/campfire/exchange_icons/exolix.png new file mode 100644 index 0000000000..dfb155feb4 Binary files /dev/null and b/asset_sources/svg/campfire/exchange_icons/exolix.png differ diff --git a/asset_sources/svg/campfire/exchange_icons/letsexchange.svg b/asset_sources/svg/campfire/exchange_icons/letsexchange.svg new file mode 100644 index 0000000000..b4438fb3bb --- /dev/null +++ b/asset_sources/svg/campfire/exchange_icons/letsexchange.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset_sources/svg/campfire/exchange_icons/wizard.svg b/asset_sources/svg/campfire/exchange_icons/wizard.svg new file mode 100644 index 0000000000..703b2ba26d --- /dev/null +++ b/asset_sources/svg/campfire/exchange_icons/wizard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset_sources/svg/campfire/sib.svg b/asset_sources/svg/campfire/sib.svg new file mode 100644 index 0000000000..7fe9cbc569 --- /dev/null +++ b/asset_sources/svg/campfire/sib.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset_sources/svg/stack_duo/exchange_icons/cyphergoat.svg b/asset_sources/svg/stack_duo/exchange_icons/cyphergoat.svg new file mode 100644 index 0000000000..0ea11c36da --- /dev/null +++ b/asset_sources/svg/stack_duo/exchange_icons/cyphergoat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset_sources/svg/stack_duo/exchange_icons/exolix.png b/asset_sources/svg/stack_duo/exchange_icons/exolix.png new file mode 100644 index 0000000000..dfb155feb4 Binary files /dev/null and b/asset_sources/svg/stack_duo/exchange_icons/exolix.png differ diff --git a/asset_sources/svg/stack_duo/exchange_icons/letsexchange.svg b/asset_sources/svg/stack_duo/exchange_icons/letsexchange.svg new file mode 100644 index 0000000000..b4438fb3bb --- /dev/null +++ b/asset_sources/svg/stack_duo/exchange_icons/letsexchange.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset_sources/svg/stack_duo/exchange_icons/wizard.svg b/asset_sources/svg/stack_duo/exchange_icons/wizard.svg new file mode 100644 index 0000000000..703b2ba26d --- /dev/null +++ b/asset_sources/svg/stack_duo/exchange_icons/wizard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset_sources/svg/stack_duo/sib.svg b/asset_sources/svg/stack_duo/sib.svg new file mode 100644 index 0000000000..7fe9cbc569 --- /dev/null +++ b/asset_sources/svg/stack_duo/sib.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset_sources/svg/stack_wallet/exchange_icons/cyphergoat.svg b/asset_sources/svg/stack_wallet/exchange_icons/cyphergoat.svg new file mode 100644 index 0000000000..0ea11c36da --- /dev/null +++ b/asset_sources/svg/stack_wallet/exchange_icons/cyphergoat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset_sources/svg/stack_wallet/exchange_icons/exolix.png b/asset_sources/svg/stack_wallet/exchange_icons/exolix.png new file mode 100644 index 0000000000..dfb155feb4 Binary files /dev/null and b/asset_sources/svg/stack_wallet/exchange_icons/exolix.png differ diff --git a/asset_sources/svg/stack_wallet/exchange_icons/letsexchange.svg b/asset_sources/svg/stack_wallet/exchange_icons/letsexchange.svg new file mode 100644 index 0000000000..b4438fb3bb --- /dev/null +++ b/asset_sources/svg/stack_wallet/exchange_icons/letsexchange.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset_sources/svg/stack_wallet/exchange_icons/wizard.svg b/asset_sources/svg/stack_wallet/exchange_icons/wizard.svg new file mode 100644 index 0000000000..703b2ba26d --- /dev/null +++ b/asset_sources/svg/stack_wallet/exchange_icons/wizard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset_sources/svg/stack_wallet/sib.svg b/asset_sources/svg/stack_wallet/sib.svg new file mode 100644 index 0000000000..7fe9cbc569 --- /dev/null +++ b/asset_sources/svg/stack_wallet/sib.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crypto_plugins/flutter_libepiccash b/crypto_plugins/flutter_libepiccash index 5a705486d0..f4a55aa9e5 160000 --- a/crypto_plugins/flutter_libepiccash +++ b/crypto_plugins/flutter_libepiccash @@ -1 +1 @@ -Subproject commit 5a705486d07f13ef0c5a044e7b9588dea4c989ff +Subproject commit f4a55aa9e5b6066428402291ed228aa0dd921534 diff --git a/crypto_plugins/flutter_libmwc b/crypto_plugins/flutter_libmwc index 9df2771253..c8db22aed2 160000 --- a/crypto_plugins/flutter_libmwc +++ b/crypto_plugins/flutter_libmwc @@ -1 +1 @@ -Subproject commit 9df27712534c7cccedb19376cb0768b6f538cacb +Subproject commit c8db22aed2c50aa1e95dfc532abb0a4961c543d7 diff --git a/crypto_plugins/frostdart b/crypto_plugins/frostdart index 39171c0f24..395765297a 160000 --- a/crypto_plugins/frostdart +++ b/crypto_plugins/frostdart @@ -1 +1 @@ -Subproject commit 39171c0f24af01780a14b969051aa1a574961f85 +Subproject commit 395765297a52c5f867ae6256636cf51e0ad20876 diff --git a/docs/building.md b/docs/building.md index 6aa647e53b..af2d328a94 100644 --- a/docs/building.md +++ b/docs/building.md @@ -4,7 +4,7 @@ Here you will find instructions on how to install the necessary tools for buildi ## Prerequisites -- The only OS supported for building Android and Linux desktop is Ubuntu 20.04. Windows builds require using Ubuntu 20.04 on WSL2. macOS builds for itself and iOS. Advanced users may also be able to build on other Debian-based distributions like Linux Mint. +- The only OS supported for building Android and Linux desktop is Ubuntu 24.04. Windows builds require using Ubuntu 24.04 on WSL2. macOS builds for itself and iOS. Advanced users may also be able to build on other Debian-based distributions like Linux Mint. - Android setup ([Android Studio](https://developer.android.com/studio) and subsequent dependencies) - 100 GB of storage - Install go: [https://go.dev/doc/install](https://go.dev/doc/install) @@ -13,6 +13,9 @@ Here you will find instructions on how to install the necessary tools for buildi The following instructions are for building and running on a Linux host. Alternatively, see the [Mac](#mac-host) and/or [Windows](#windows-host) section. This entire section (except for the Android Studio section) needs to be completed in WSL if building on a Windows host. +### Flutter +Install Flutter 3.38.5 by [following their guide](https://docs.flutter.dev/get-started/install/linux/desktop?tab=download#install-the-flutter-sdk). Run `flutter doctor` in a terminal to confirm its installation. + ### Android Studio Install Android Studio. Follow instructions here [https://developer.android.com/studio/install#linux](https://developer.android.com/studio/install#linux) or install via snap: ``` @@ -21,7 +24,7 @@ sudo apt install -y openjdk-11-jdk sudo snap install android-studio --classic ``` -Use `Tools > SDK Manager` to install: +Use `Tools > SDK Manager` and navigate to `Languages & Frameworks > Android SDK > SDK tools` to install: - `SDK Tools > Android SDK command line tools` - `SDK Tools > CMake` and for Android builds, @@ -40,18 +43,7 @@ sudo apt-get install libc6:i386 libncurses5:i386 libstdc++6:i386 lib32z1 libbz2- ### Build dependencies Install basic dependencies ``` -sudo apt-get install libssl-dev curl unzip automake build-essential file pkg-config git python3 libtool libtinfo6 cmake libgit2-dev clang libncurses5-dev libncursesw5-dev zlib1g-dev llvm g++ gcc gperf libopencv-dev python3-typogrify xsltproc valac gobject-introspection meson -``` - -For Ubuntu 20.04, -``` -sudo apt-get install valac python3-pip -pip3 install --upgrade meson==0.64.1 markdown==3.4.1 markupsafe==2.1.1 jinja2==3.1.2 pygments==2.13.0 toml==0.10.2 typogrify==2.0.7 tomli==2.0.1 -``` - -For Ubuntu 24.04, -``` -sudo apt install pipx libgcrypt20-dev libglib2.0-dev libsecret-1-dev +sudo apt-get install libssl-dev curl unzip automake build-essential file pkg-config git python3 libtool libtinfo6 cmake libgit2-dev clang libncurses5-dev libncursesw5-dev zlib1g-dev llvm lld g++ gcc gperf libopencv-dev python3-typogrify xsltproc valac gobject-introspection meson pipx libgcrypt20-dev libglib2.0-dev libsecret-1-dev pipx install meson==0.64.1 markdown==3.4.1 markupsafe==2.1.1 jinja2==3.1.2 pygments==2.13.0 toml==0.10.2 typogrify==2.0.7 tomli==2.0.1 ``` @@ -59,8 +51,8 @@ Install [Rust](https://www.rust-lang.org/tools/install) via [rustup.rs](https:// ``` curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source ~/.bashrc -rustup install 1.85.1 1.81.0 -rustup default 1.85.1 +rustup install 1.89.0 1.85.1 1.81.0 +rustup default 1.89.0 cargo install cargo-ndk ``` @@ -72,21 +64,12 @@ rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-andro Linux desktop specific dependencies: ``` -sudo apt-get install clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev meson python3-pip libgirepository1.0-dev valac xsltproc docbook-xsl +sudo apt-get install clang cmake lld ninja-build pkg-config libgtk-3-dev liblzma-dev meson python3-pip libgirepository1.0-dev valac xsltproc docbook-xsl pip3 install --upgrade meson==0.64.1 markdown==3.4.1 markupsafe==2.1.1 jinja2==3.1.2 pygments==2.13.0 toml==0.10.2 typogrify==2.0.7 tomli==2.0.1 ``` ### Flutter -Install Flutter 3.29.2 by [following their guide](https://docs.flutter.dev/get-started/install/linux/desktop?tab=download#install-the-flutter-sdk). You can also clone https://github.com/flutter/flutter, check out the `3.29.2` tag, and add its `flutter/bin` folder to your PATH as in -```sh -FLUTTER_DIR="$HOME/development/flutter" -git clone https://github.com/flutter/flutter.git "$FLUTTER_DIR" -cd "$FLUTTER_DIR" -git checkout 3.29.2 -echo 'export PATH="$PATH:'"$FLUTTER_DIR"'/bin"' >> "$HOME/.profile" -source "$HOME/.profile" -flutter precache -``` +Install Flutter 3.38.5 by [following their guide](https://docs.flutter.dev/install/manual). Run `flutter doctor` in a terminal to confirm its installation. @@ -95,7 +78,7 @@ After installing the prerequisites listed above, download the code and init the ``` git clone https://github.com/cypherstack/stack_wallet.git cd stack_wallet -git submodule update --init --recursive +git submodule foreach 'git fetch --tags' && git submodule update --init --recursive ``` Build the secure storage dependencies in order to target Linux (not needed for Windows or other platforms): @@ -158,18 +141,29 @@ cd scripts ``` #### Building plugins and configure for Windows +*This step is only necessary inside WSL2 for building on a Windows host.* + Install dependencies like MXE: ``` cd scripts/windows ./deps.sh ``` -install go in WSL [https://go.dev/doc/install](https://go.dev/doc/install) (follow linux instructions) and ensure you have `x86_64-w64-mingw32-gcc` +Upgrade the version of cmake >= 3.31.6, the default version of ubuntu 24.04 (3.28.1) will be too low to build libepiccash. +You can use pip to install a specific version +``` +sudo apt remove cmake +pip install cmake==3.31.6 +``` + +install go in WSL [https://go.dev/doc/install](https://go.dev/doc/install) (follow linux instructions) and ensure you have `mingw-w64` package installed to get the `x86_64-w64-mingw32-gcc` compiler. + +go version should be at least 1.24 -and use `scripts/build_app.sh` to build plugins: +and use `scripts/build_app.sh` to build plugins: (see the [Build script section](#build-script-build_appsh) to understand the arguments) ``` cd .. -./build_app.sh -a stack_wallet -p windows -v 2.1.0 -b 210 +./build_app.sh -a stack_wallet -p windows -v 2.4.4 -b 301 ``` ### Running @@ -212,12 +206,12 @@ brew install brotli cairo coreutils gdbm gettext glib gmp libevent libidn2 libng ``` -Download and install [Rust](https://www.rust-lang.org/tools/install). [Rustup](https://rustup.rs/) is recommended for Rust setup. Use `rustc` to confirm successful installation. Install toolchains 1.81.0 and 1.85.1 and `cbindgen` and `cargo-lipo` too. You will also have to add the platform target(s) `aarch64-apple-ios` and/or `aarch64-apple-darwin`. You can use the command(s): +Download and install [Rust](https://www.rust-lang.org/tools/install). [Rustup](https://rustup.rs/) is recommended for Rust setup. Use `rustc` to confirm successful installation. Install toolchains 1.81.0, 1.85.1, and 1.89.0 as well as `cbindgen` and `cargo-lipo` too. You will also have to add the platform target(s) `aarch64-apple-ios` and/or `aarch64-apple-darwin`. You can use the command(s): ``` curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source ~/.bashrc -rustup install 1.85.1 1.81.0 -rustup default 1.85.1 +rustup install 1.89.0 1.85.1 1.81.0 +rustup default 1.89.0 cargo install cargo-ndk cargo install cbindgen cargo-lipo rustup target add aarch64-apple-ios aarch64-apple-darwin @@ -226,7 +220,7 @@ rustup target add aarch64-apple-ios aarch64-apple-darwin Optionally download [Android Studio](https://developer.android.com/studio) as an IDE and activate its Dart and Flutter plugins. VS Code may work as an alternative, but this is not recommended. ### Flutter -Install [Flutter](https://docs.flutter.dev/get-started/install) 3.29.2 on your Mac host by following [these instructions](https://docs.flutter.dev/get-started/install/macos). Run `flutter doctor` in a terminal to confirm its installation. +Install 3.38.5 on your Mac host by [following their guide](https://docs.flutter.dev/install/manual). Run `flutter doctor` in a terminal to confirm its installation. ### Build plugins and configure #### Building plugins for iOS @@ -269,17 +263,33 @@ flutter run macos ## Windows host ### Visual Studio -Visual Studio is required for Windows development with the Flutter SDK. Download it at https://visualstudio.microsoft.com/downloads/ and install the "Desktop development with C++", "Linux development with C++", and "Visual C++ build tools" workloads. You may also need the Windows 10, 11, and/or Universal SDK workloads depending on your Windows version. +Visual Studio 2022 is required for Windows development with the Flutter SDK. Download it at https://learn.microsoft.com/en-us/visualstudio/releases/2022/release-history and install the "Desktop development with C++", "Linux development with C++", and "Visual C++ build tools" workloads. You may also need the Windows 10, 11, and/or Universal SDK workloads depending on your Windows version. ### Build plugins in WSL2 -Set up Ubuntu 20.04 in WSL2. Follow the entire Linux host section in the WSL2 Ubuntu 20.04 host to get set up to build. The Android Studio section may be skipped in WSL (it's only needed on the Windows host). +Set up Ubuntu 24.04 in WSL2. Follow the entire Linux host section in the WSL2 Ubuntu 24.04 host to get set up to build. The Android Studio section may be skipped in WSL (it's only needed on the Windows host). Install the following libraries: ``` -sudo apt-get install libgtk2.0-dev +sudo apt-get install libgtk2.0-dev nasm mingw-w64 ``` -The WSL2 host may optionally be navigated to the `stack_wallet` repository on the Windows host in order to build the plugins in-place and skip the next section in which you copy the `dll`s from WSL2 to Windows. Then build windows `dll` libraries by running the following script on the WSL2 Ubuntu 20.04 host: +The WSL2 host may optionally be navigated to the `stack_wallet` repository on the Windows host in order to build the plugins in-place and skip the next section in which you copy the `dll`s from WSL2 to Windows. + +In this case, you need to enable "metadata" in your wsl setup to be able to modify files on your Windows filesystem. +Add this content to your /etc/wsl.conf in WSL. +``` +[automount] +options = "metadata" +``` +Then restart the wsl from Windows +``` +wsl --shutdown +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: - `stack_wallet/scripts/windows/build_all.sh` @@ -292,24 +302,13 @@ If the DLLs were built on the WSL filesystem instead of on Windows, copy the res Frostdart will be built by the Windows host later. ### Install Flutter on Windows host -Install Flutter 3.29.2 on your Windows host (not in WSL2) by [following their guide](https://docs.flutter.dev/get-started/install/windows/desktop?tab=download#install-the-flutter-sdk) or by cloning https://github.com/flutter/flutter, checking out the `3.29.2` tag, and adding its `flutter/bin` folder to your PATH as in -```bat -@echo off -set "FLUTTER_DIR=%USERPROFILE%\development\flutter" -git clone https://github.com/flutter/flutter.git "%FLUTTER_DIR%" -cd /d "%FLUTTER_DIR%" -git checkout 3.29.2 -setx PATH "%PATH%;%FLUTTER_DIR%\bin" -echo Flutter setup completed. Please restart your command prompt. -``` - -Run `flutter doctor` in PowerShell to confirm its installation. +Install Flutter 3.38.5 on your Windows host (not in WSL2) by [following their guide](https://docs.flutter.dev/install/manual). Run `flutter doctor` in PowerShell to confirm its installation. ### Rust Install [Rust](https://www.rust-lang.org/tools/install) on the Windows host (not in WSL2). Download the installer from [rustup.rs](https://rustup.rs), make sure it works on the commandline (you may need to open a new terminal), and install the following versions: ``` -rustup install 1.85.1 1.81.0 -rustup default 1.85.1 +rustup install 1.89.0 1.85.1 1.81.0 +rustup default 1.89.0 cargo install cargo-ndk ``` @@ -321,11 +320,27 @@ Enable Developer Mode for symlink support, start ms-settings:developers ``` -You may need to install NuGet and CppWinRT / C++/WinRT SDKs version `2.0.210806.1`: +Or enable it automatically from powershell: +``` +PS C:\WINDOWS\system32> reg add "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" +``` + + +Install NuGet: +``` +winget install 9WZDNCRDMDM3 --accept-package-agreements # NuGet, can also use Microsoft.NuGet +``` + +Then restart your terminal and add a source to nuget: ``` -winget install 9WZDNCRDMDM3 # NuGet, can also use Microsoft.NuGet -winget install Microsoft.Windows.CppWinRT -Version 2.0.210806.1 +nuget sources add -Name "nuget.org" -Source "https://api.nuget.org/v3/index.json" +``` + +Install and CppWinRT / C++/WinRT SDKs version `2.0.210806.1` with the help of nuget: ``` +nuget install Microsoft.Windows.CppWinRT --Version 2.0.210806.1 +``` + or [download the package](https://www.nuget.org/packages/Microsoft.Windows.CppWinRT/2.0.210806.1) and [manually install it](https://github.com/Baseflow/flutter-permission-handler/issues/1025#issuecomment-1518576722) by placing it in `flutter/bin` with [nuget.exe](https://dist.nuget.org/win-x86-commandline/latest/nuget.exe) and installing by running `nuget install Microsoft.Windows.CppWinRT -Version 2.0.210806.1` in the root `stack_wallet` folder. @@ -334,16 +349,18 @@ or [download the package](https://www.nuget.org/packages/Microsoft.Windows.CppWi Certain test wallet parameter and API key template files must be created in order to run Stack Wallet on Windows. These can be created by script using PowerShell on the Windows host as in ``` cd scripts -./prebuild.ps1 +powershell -ExecutionPolicy Bypass -File prebuild.ps1 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 +cd crypto_plugins/frostdart/scripts/windows ./build_all.bat cd .. // When finished go back to the root directory. ``` @@ -353,6 +370,7 @@ cd .. // When finished go back to the root directory. Run the following commands: ``` flutter pub get +dart run coinlib:build_windows flutter run -d windows ``` diff --git a/flatpak/campfire.sh b/flatpak/campfire.sh new file mode 100644 index 0000000000..0bd7154333 --- /dev/null +++ b/flatpak/campfire.sh @@ -0,0 +1,2 @@ +#!/bin/sh +exec /app/lib/campfire/campfire "$@" diff --git a/flatpak/com.cypherstack.campfire.desktop b/flatpak/com.cypherstack.campfire.desktop new file mode 100644 index 0000000000..512ef2cc0c --- /dev/null +++ b/flatpak/com.cypherstack.campfire.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Name=Campfire +Comment=Your privacy. Your wallet. Your Firo. +Exec=campfire +Icon=com.cypherstack.campfire +Type=Application +Categories=Finance; diff --git a/flatpak/com.cypherstack.campfire.metainfo.xml b/flatpak/com.cypherstack.campfire.metainfo.xml new file mode 100644 index 0000000000..f355750b40 --- /dev/null +++ b/flatpak/com.cypherstack.campfire.metainfo.xml @@ -0,0 +1,16 @@ + + + com.cypherstack.campfire + CC0-1.0 + GPL-3.0-only + Campfire + Your privacy. Your wallet. Your Firo. + +

+ Campfire is an open-source, non-custodial Firo wallet. +

+
+ https://campfireprivacy.com + https://github.com/cypherstack/stack_wallet/issues + +
diff --git a/flatpak/com.cypherstack.campfire.yaml b/flatpak/com.cypherstack.campfire.yaml new file mode 100644 index 0000000000..6dfcca8638 --- /dev/null +++ b/flatpak/com.cypherstack.campfire.yaml @@ -0,0 +1,33 @@ +app-id: com.cypherstack.campfire +runtime: org.freedesktop.Platform +runtime-version: '24.08' +sdk: org.freedesktop.Sdk +command: campfire + +finish-args: + - --share=network + - --share=ipc + - --socket=fallback-x11 + - --socket=wayland + - --device=dri + - --filesystem=~/.campfire:create + - --talk-name=org.freedesktop.secrets + - --talk-name=org.freedesktop.Notifications + +modules: + - name: campfire + buildsystem: simple + build-commands: + - mkdir -p /app/lib/campfire + - install -Dm755 bundle/campfire /app/lib/campfire/campfire + - cp -r bundle/lib bundle/data /app/lib/campfire/ + - install -Dm755 campfire.sh /app/bin/campfire + - install -Dm644 com.cypherstack.campfire.desktop + /app/share/applications/com.cypherstack.campfire.desktop + - install -Dm644 com.cypherstack.campfire.metainfo.xml + /app/share/metainfo/com.cypherstack.campfire.metainfo.xml + - install -Dm644 com.cypherstack.campfire.png + /app/share/icons/hicolor/512x512/apps/com.cypherstack.campfire.png + sources: + - type: dir + path: . diff --git a/flatpak/com.cypherstack.stackduo.desktop b/flatpak/com.cypherstack.stackduo.desktop new file mode 100644 index 0000000000..219c835064 --- /dev/null +++ b/flatpak/com.cypherstack.stackduo.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Name=Stack Duo +Comment=An open-source, multicoin wallet for everyone +Exec=stack_duo +Icon=com.cypherstack.stackduo +Type=Application +Categories=Finance; diff --git a/flatpak/com.cypherstack.stackduo.metainfo.xml b/flatpak/com.cypherstack.stackduo.metainfo.xml new file mode 100644 index 0000000000..1e929dec2b --- /dev/null +++ b/flatpak/com.cypherstack.stackduo.metainfo.xml @@ -0,0 +1,16 @@ + + + com.cypherstack.stackduo + CC0-1.0 + GPL-3.0-only + Stack Duo + An open-source, multicoin wallet for everyone + +

+ Stack Duo is an open-source, non-custodial cryptocurrency wallet. +

+
+ https://stackwallet.com + https://github.com/cypherstack/stack_wallet/issues + +
diff --git a/flatpak/com.cypherstack.stackduo.yaml b/flatpak/com.cypherstack.stackduo.yaml new file mode 100644 index 0000000000..8b6b74269f --- /dev/null +++ b/flatpak/com.cypherstack.stackduo.yaml @@ -0,0 +1,33 @@ +app-id: com.cypherstack.stackduo +runtime: org.freedesktop.Platform +runtime-version: '24.08' +sdk: org.freedesktop.Sdk +command: stack_duo + +finish-args: + - --share=network + - --share=ipc + - --socket=fallback-x11 + - --socket=wayland + - --device=dri + - --filesystem=~/.stackduo:create + - --talk-name=org.freedesktop.secrets + - --talk-name=org.freedesktop.Notifications + +modules: + - name: stack_duo + buildsystem: simple + build-commands: + - mkdir -p /app/lib/stack_duo + - install -Dm755 bundle/stack_duo /app/lib/stack_duo/stack_duo + - cp -r bundle/lib bundle/data /app/lib/stack_duo/ + - install -Dm755 stack_duo.sh /app/bin/stack_duo + - install -Dm644 com.cypherstack.stackduo.desktop + /app/share/applications/com.cypherstack.stackduo.desktop + - install -Dm644 com.cypherstack.stackduo.metainfo.xml + /app/share/metainfo/com.cypherstack.stackduo.metainfo.xml + - install -Dm644 com.cypherstack.stackduo.png + /app/share/icons/hicolor/512x512/apps/com.cypherstack.stackduo.png + sources: + - type: dir + path: . diff --git a/flatpak/com.cypherstack.stackwallet.desktop b/flatpak/com.cypherstack.stackwallet.desktop new file mode 100644 index 0000000000..d5b7d55d22 --- /dev/null +++ b/flatpak/com.cypherstack.stackwallet.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Name=Stack Wallet +Comment=Open-source non-custodial cryptocurrency wallet +Exec=stack_wallet +Icon=com.cypherstack.stackwallet +Type=Application +Categories=Finance; diff --git a/flatpak/com.cypherstack.stackwallet.metainfo.xml b/flatpak/com.cypherstack.stackwallet.metainfo.xml new file mode 100644 index 0000000000..abf3b19c93 --- /dev/null +++ b/flatpak/com.cypherstack.stackwallet.metainfo.xml @@ -0,0 +1,17 @@ + + + com.cypherstack.stackwallet + CC0-1.0 + GPL-3.0-only + Stack Wallet + Open-source non-custodial cryptocurrency wallet + +

+ Stack Wallet is an open-source, non-custodial, privacy-focused + cryptocurrency wallet supporting multiple coins. +

+
+ https://stackwallet.com + https://github.com/cypherstack/stack_wallet/issues + +
diff --git a/flatpak/com.cypherstack.stackwallet.yaml b/flatpak/com.cypherstack.stackwallet.yaml new file mode 100644 index 0000000000..4bdaa87c2b --- /dev/null +++ b/flatpak/com.cypherstack.stackwallet.yaml @@ -0,0 +1,36 @@ +app-id: com.cypherstack.stackwallet +runtime: org.freedesktop.Platform +runtime-version: '24.08' +sdk: org.freedesktop.Sdk +command: stack_wallet + +finish-args: + - --share=network + - --share=ipc + - --socket=fallback-x11 + - --socket=wayland + - --device=dri + - --filesystem=~/.stackwallet:create + - --talk-name=org.freedesktop.secrets + - --talk-name=org.freedesktop.Notifications + +modules: + - name: stack_wallet + buildsystem: simple + build-commands: + # Install the pre-built Flutter bundle under /app/lib/stack_wallet/ so + # the binary's $ORIGIN/lib and $ORIGIN/data lookups resolve correctly. + - mkdir -p /app/lib/stack_wallet + - install -Dm755 bundle/stack_wallet /app/lib/stack_wallet/stack_wallet + - cp -r bundle/lib bundle/data /app/lib/stack_wallet/ + # Wrapper script so the Flatpak command path resolves to the binary. + - install -Dm755 stack_wallet.sh /app/bin/stack_wallet + - install -Dm644 com.cypherstack.stackwallet.desktop + /app/share/applications/com.cypherstack.stackwallet.desktop + - install -Dm644 com.cypherstack.stackwallet.metainfo.xml + /app/share/metainfo/com.cypherstack.stackwallet.metainfo.xml + - install -Dm644 com.cypherstack.stackwallet.png + /app/share/icons/hicolor/512x512/apps/com.cypherstack.stackwallet.png + sources: + - type: dir + path: . diff --git a/flatpak/stack_duo.sh b/flatpak/stack_duo.sh new file mode 100644 index 0000000000..522eab523e --- /dev/null +++ b/flatpak/stack_duo.sh @@ -0,0 +1,2 @@ +#!/bin/sh +exec /app/lib/stack_duo/stack_duo "$@" diff --git a/flatpak/stack_wallet.sh b/flatpak/stack_wallet.sh new file mode 100644 index 0000000000..db2f325b1d --- /dev/null +++ b/flatpak/stack_wallet.sh @@ -0,0 +1,2 @@ +#!/bin/sh +exec /app/lib/stack_wallet/stack_wallet "$@" diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json index 9f447e1b38..8bb185b107 100644 --- a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json +++ b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json @@ -2,16 +2,17 @@ "images" : [ { "filename" : "background.png", - "idiom" : "universal", - "scale" : "1x" + "idiom" : "universal" }, { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "darkbackground.png", + "idiom" : "universal" } ], "info" : { diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png index 8a4950a508..60661e9a30 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png and b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png new file mode 100644 index 0000000000..5596c666ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png differ diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard index 0430c335af..7aa6dfbc25 100644 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -41,4 +41,4 @@ - \ No newline at end of file + diff --git a/lib/app_config.dart b/lib/app_config.dart index 3004413d2a..7ee72af745 100644 --- a/lib/app_config.dart +++ b/lib/app_config.dart @@ -6,7 +6,7 @@ import 'wallets/crypto_currency/intermediate/frost_currency.dart'; part 'app_config.g.dart'; -enum AppFeature { themeSelection, buy, swap, tor } +enum AppFeature { themeSelection, buy, swap, tor, shopinBit, cakePay } abstract class AppConfig { static const appName = _prefix + _separator + suffix; @@ -16,6 +16,8 @@ abstract class AppConfig { static const emptyWalletsMessage = _emptyWalletsMessage; + static const windowsMwebdExeHash = _mwebdExeHash; + static String get appDefaultDataDirName => _appDataDirName; static String get shortDescriptionText => _shortDescriptionText; static String get commitHash => _commitHash; @@ -83,7 +85,10 @@ abstract class AppConfig { try { return coins.firstWhere( - (e) => e.identifier.toLowerCase() == name || e.prettyName == prettyName, + (e) => + e.identifier.toLowerCase() == name || + e.prettyName == prettyName || + (e is Epiccash && prettyName == "Epic Private Internet Cash"), ); } catch (_) { throw Exception("getCryptoCurrencyByPrettyName($prettyName) failed!"); diff --git a/lib/db/db_version_migration.dart b/lib/db/db_version_migration.dart index ab2b8bcde1..2df1213c5c 100644 --- a/lib/db/db_version_migration.dart +++ b/lib/db/db_version_migration.dart @@ -170,12 +170,11 @@ class DbVersionMigrator with WalletDB { final count = await MainDB.instance.isar.addresses.count(); // add change/receiving tags to address labels for (var i = 0; i < count; i += 50) { - final addresses = - await MainDB.instance.isar.addresses - .where() - .offset(i) - .limit(50) - .findAll(); + final addresses = await MainDB.instance.isar.addresses + .where() + .offset(i) + .limit(50) + .findAll(); final List labels = []; for (final address in addresses) { @@ -203,14 +202,13 @@ class DbVersionMigrator with WalletDB { // update/create label if tags is not empty if (tags != null) { - isar_models.AddressLabel? label = - await MainDB.instance.isar.addressLabels - .where() - .addressStringWalletIdEqualTo( - address.value, - address.walletId, - ) - .findFirst(); + isar_models.AddressLabel? label = await MainDB + .instance + .isar + .addressLabels + .where() + .addressStringWalletIdEqualTo(address.value, address.walletId) + .findFirst(); if (label == null) { label = isar_models.AddressLabel( walletId: address.walletId, @@ -268,13 +266,12 @@ class DbVersionMigrator with WalletDB { Bitcoincash(CryptoCurrencyNetwork.main).identifier || info.coinIdentifier == Bitcoincash(CryptoCurrencyNetwork.test).identifier) { - final ids = - await MainDB.instance - .getAddresses(walletId) - .filter() - .typeEqualTo(isar_models.AddressType.p2sh) - .idProperty() - .findAll(); + final ids = await MainDB.instance + .getAddresses(walletId) + .filter() + .typeEqualTo(isar_models.AddressType.p2sh) + .idProperty() + .findAll(); await MainDB.instance.isar.writeTxn(() async { await MainDB.instance.isar.addresses.deleteAll(ids); @@ -376,6 +373,32 @@ class DbVersionMigrator with WalletDB { // try to continue migrating return await migrate(15, secureStore: secureStore); + case 15: + // Clear stale MWC wallet handles from older builds. + await DB.instance.hive.openBox(DB.boxNameAllWalletsData); + final mwcMigrationWalletsService = WalletsService(); + final mwcMigrationWalletNames = + await mwcMigrationWalletsService.walletNames; + final mwcIdentifier = Mimblewimblecoin( + CryptoCurrencyNetwork.main, + ).identifier; + for (final walletId in mwcMigrationWalletNames.keys) { + if (mwcMigrationWalletNames[walletId]!.coinIdentifier == + mwcIdentifier) { + await secureStore.delete(key: '${walletId}_wallet'); + } + } + + // update version + await DB.instance.put( + boxName: DB.boxNameDBInfo, + key: "hive_data_version", + value: 16, + ); + + // try to continue migrating + return await migrate(16, secureStore: secureStore); + default: // finally return return; @@ -421,17 +444,15 @@ class DbVersionMigrator with WalletDB { walletId: walletId, txid: tx.txid, timestamp: tx.timestamp, - type: - isIncoming - ? isar_models.TransactionType.incoming - : isar_models.TransactionType.outgoing, + type: isIncoming + ? isar_models.TransactionType.incoming + : isar_models.TransactionType.outgoing, subType: isar_models.TransactionSubType.none, amount: tx.amount, - amountString: - Amount( - rawValue: BigInt.from(tx.amount), - fractionDigits: epic.fractionDigits, - ).toJsonString(), + amountString: Amount( + rawValue: BigInt.from(tx.amount), + fractionDigits: epic.fractionDigits, + ).toJsonString(), fee: tx.fees, height: tx.height, isCancelled: tx.isCancelled, @@ -453,14 +474,12 @@ class DbVersionMigrator with WalletDB { publicKey: [], derivationIndex: isIncoming ? rcvIndex : -1, derivationPath: null, - type: - isIncoming - ? isar_models.AddressType.mimbleWimble - : isar_models.AddressType.unknown, - subType: - isIncoming - ? isar_models.AddressSubType.receiving - : isar_models.AddressSubType.unknown, + type: isIncoming + ? isar_models.AddressType.mimbleWimble + : isar_models.AddressType.unknown, + subType: isIncoming + ? isar_models.AddressSubType.receiving + : isar_models.AddressSubType.unknown, ); transactionsData.add(Tuple2(iTx, address)); } @@ -518,28 +537,25 @@ class DbVersionMigrator with WalletDB { final crypto = AppConfig.getCryptoCurrencyFor(info.coinIdentifier)!; for (var i = 0; i < count; i += 50) { - final txns = - await MainDB.instance - .getTransactions(walletId) - .offset(i) - .limit(50) - .findAll(); + final txns = await MainDB.instance + .getTransactions(walletId) + .offset(i) + .limit(50) + .findAll(); // migrate amount to serialized amount string - final txnsData = - txns - .map( - (tx) => Tuple2( - tx - ..amountString = - Amount( - rawValue: BigInt.from(tx.amount), - fractionDigits: crypto.fractionDigits, - ).toJsonString(), - tx.address.value, - ), - ) - .toList(); + final txnsData = txns + .map( + (tx) => Tuple2( + tx + ..amountString = Amount( + rawValue: BigInt.from(tx.amount), + fractionDigits: crypto.fractionDigits, + ).toJsonString(), + tx.address.value, + ), + ) + .toList(); // update db records await MainDB.instance.addNewTransactionData(txnsData, walletId); diff --git a/lib/db/drift/shared_db/shared_database.dart b/lib/db/drift/shared_db/shared_database.dart new file mode 100644 index 0000000000..df07f9f619 --- /dev/null +++ b/lib/db/drift/shared_db/shared_database.dart @@ -0,0 +1,356 @@ +import 'package:drift/drift.dart'; +import 'package:drift_flutter/drift_flutter.dart'; +import 'package:path/path.dart' as path; + +import "../../../models/shopinbit/shopinbit_enums.dart"; +import "../../../services/shopinbit/src/models/message.dart"; +import '../../../utilities/stack_file_system.dart'; +import 'tables/cakepay_orders.dart'; +import 'tables/notifications.dart'; +import 'tables/shopin_bit_settings.dart'; +import 'tables/shopin_bit_tickets.dart'; + +part 'shared_database.g.dart'; + +abstract final class SharedDrift { + static bool _didInit = false; + + static SharedDatabase? _db; + + static SharedDatabase get() { + if (!_didInit) { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + _didInit = true; + } + + return _db ??= SharedDatabase._(); + } +} + +@DriftDatabase( + tables: [ + CakepayOrders, + ShopInBitSettings, + ShopInBitTickets, + AppNotifications, + ], + daos: [ShopInBitSettingsDao, ShopInBitTicketsDao, AppNotificationsDao], +) +final class SharedDatabase extends _$SharedDatabase { + SharedDatabase._([QueryExecutor? executor]) + : super(executor ?? _openConnection()); + + @override + int get schemaVersion => 3; + + @override + MigrationStrategy get migration => MigrationStrategy( + onUpgrade: (m, from, to) async { + if (from < 3) { + // deletion is fine here because sib was not used before this + await m.deleteTable(shopInBitSettings.actualTableName); + await m.deleteTable(shopInBitTickets.actualTableName); + + await m.createTable(shopInBitSettings); + await m.createTable(shopInBitTickets); + await m.createTable(appNotifications); + await m.createIndex(appNotificationsScope); + await m.createIndex(appNotificationsTarget); + } + }, + ); + + static QueryExecutor _openConnection() { + return driftDatabase( + name: "shared", + native: DriftNativeOptions( + shareAcrossIsolates: true, + databasePath: () async { + final dir = await StackFileSystem.applicationDriftDirectory(); + return path.join(dir.path, "shared", "shared.db"); + }, + ), + ); + } +} + +@DriftAccessor(tables: [ShopInBitTickets]) +class ShopInBitTicketsDao extends DatabaseAccessor + with _$ShopInBitTicketsDaoMixin { + ShopInBitTicketsDao(super.db); + + // -- Reads -- + + Future getByApiId(int apiTicketId) { + return (select( + shopInBitTickets, + )..where((t) => t.apiTicketId.equals(apiTicketId))).getSingleOrNull(); + } + + Stream watchByApiId(int apiTicketId) { + return (select( + shopInBitTickets, + )..where((t) => t.apiTicketId.equals(apiTicketId))).watchSingleOrNull(); + } + + /// All tickets for the active customer key, newest first. + Stream> watchByCustomerKey(String customerKey) { + return (select(shopInBitTickets) + ..where((t) => t.customerKey.equals(customerKey)) + ..orderBy([(t) => OrderingTerm.desc(t.createdAt)])) + .watch(); + } + + // -- Writes -- + + /// Insert a brand-new ticket. Caller must supply every required field; + /// pass nullable fields through the companion's `Value(...)` wrappers. + Future insertTicket(ShopInBitTicketsCompanion companion) async { + await into(shopInBitTickets).insert(companion); + } + + /// Patch an existing ticket. Use `Value.absent()` (the companion default) + /// for fields you don't want to touch. Returns true if a row was updated. + Future updateTicket( + int apiTicketId, + ShopInBitTicketsCompanion patch, + ) async { + final int rows = await (update( + shopInBitTickets, + )..where((t) => t.apiTicketId.equals(apiTicketId))).write(patch); + return rows > 0; + } + + Future markRead(int apiTicketId, [DateTime? readAt]) async { + final int rows = + await (update(shopInBitTickets)..where( + (t) => + t.apiTicketId.equals(apiTicketId) & + t.lastAgentMessageAt.isNotNull() & + (t.lastReadAt.isNull() | + t.lastAgentMessageAt.isBiggerThan(t.lastReadAt)), + )) + .write( + ShopInBitTicketsCompanion( + lastReadAt: Value(readAt ?? DateTime.now().toUtc()), + ), + ); + return rows > 0; + } + + Future deleteByApiId(int apiTicketId) { + return (delete( + shopInBitTickets, + )..where((t) => t.apiTicketId.equals(apiTicketId))).go(); + } + + Future deleteByCustomerKey(String customerKey) { + return (delete( + shopInBitTickets, + )..where((t) => t.customerKey.equals(customerKey))).go(); + } +} + +@DriftAccessor(tables: [AppNotifications]) +class AppNotificationsDao extends DatabaseAccessor + with _$AppNotificationsDaoMixin { + AppNotificationsDao(super.db); + + Stream> watchByScope( + AppNotificationType type, + String scopeId, + ) { + return (select(appNotifications) + ..where((t) => t.type.equalsValue(type) & t.scopeId.equals(scopeId)) + ..orderBy([ + (t) => OrderingTerm.desc(t.createdAt), + (t) => OrderingTerm.desc(t.id), + ])) + .watch(); + } + + Expression _unreadScope({AppNotificationType? type, String? scopeId}) { + Expression pred = appNotifications.read.equals(false); + if (type != null) { + pred = pred & appNotifications.type.equalsValue(type); + } + if (scopeId != null) { + pred = pred & appNotifications.scopeId.equals(scopeId); + } + return pred; + } + + Stream watchUnreadCount({AppNotificationType? type, String? scopeId}) { + final count = countAll(); + final query = selectOnly(appNotifications) + ..addColumns([count]) + ..where(_unreadScope(type: type, scopeId: scopeId)); + return query.watchSingle().map((row) => row.read(count) ?? 0); + } + + Future add(AppNotificationsCompanion row) async { + await into(appNotifications).insert(row); + } + + Future markReadByTarget(AppNotificationType type, String targetId) { + return (update(appNotifications)..where( + (t) => + t.type.equalsValue(type) & + t.targetId.equals(targetId) & + t.read.equals(false), + )) + .write(const AppNotificationsCompanion(read: Value(true))); + } + + /// Mark all unread notifications read, optionally scoped to [type]/[scopeId]. + Future markAllRead({AppNotificationType? type, String? scopeId}) { + return (update(appNotifications) + ..where((_) => _unreadScope(type: type, scopeId: scopeId))) + .write(const AppNotificationsCompanion(read: Value(true))); + } + + Future pruneScope( + AppNotificationType type, + String scopeId, { + int keep = 200, + }) async { + final rows = + await (select(appNotifications) + ..where( + (t) => t.type.equalsValue(type) & t.scopeId.equals(scopeId), + ) + ..orderBy([(t) => OrderingTerm.desc(t.id)])) + .get(); + if (rows.length <= keep) return; + final prunable = rows + .skip(keep) + .where((r) => r.read) + .map((r) => r.id) + .toList(); + if (prunable.isEmpty) return; + await (delete(appNotifications)..where((t) => t.id.isIn(prunable))).go(); + } +} + +@DriftAccessor(tables: [ShopInBitSettings]) +class ShopInBitSettingsDao extends DatabaseAccessor + with _$ShopInBitSettingsDaoMixin { + ShopInBitSettingsDao(super.db); + + // -- "Current" (= most-recently-used) row -- + + /// Returns the settings row for the most-recently-used customer key, + /// or null if the user has never generated/recovered one. + Future getCurrentSettings() { + return (select(shopInBitSettings) + ..orderBy([(t) => OrderingTerm.desc(t.lastUsedAt)]) + ..limit(1)) + .getSingleOrNull(); + } + + Stream watchCurrentSettings() { + return (select(shopInBitSettings) + ..orderBy([(t) => OrderingTerm.desc(t.lastUsedAt)]) + ..limit(1)) + .watchSingleOrNull(); + } + + // -- Specific row by customer key -- + + Future getByKey(String customerKey) { + return (select( + shopInBitSettings, + )..where((t) => t.customerKey.equals(customerKey))).getSingleOrNull(); + } + + Stream watchByKey(String customerKey) { + return (select( + shopInBitSettings, + )..where((t) => t.customerKey.equals(customerKey))).watchSingleOrNull(); + } + + Stream> watchAll() { + return (select( + shopInBitSettings, + )..orderBy([(t) => OrderingTerm.desc(t.lastUsedAt)])).watch(); + } + + // -- Writes -- + + /// Insert if missing, otherwise bump [lastUsedAt]. Returns the row. + Future upsert(String customerKey) { + final DateTime now = DateTime.now(); + return into(shopInBitSettings).insertReturning( + ShopInBitSettingsCompanion.insert( + customerKey: customerKey, + createdAt: Value(now), + lastUsedAt: Value(now), + ), + onConflict: DoUpdate( + (_) => ShopInBitSettingsCompanion(lastUsedAt: Value(now)), + target: [shopInBitSettings.customerKey], + ), + ); + } + + Future touch(String customerKey) => _write( + customerKey, + ShopInBitSettingsCompanion(lastUsedAt: Value(DateTime.now())), + ); + + Future setPrivacyAccepted(String customerKey, bool value) => _write( + customerKey, + ShopInBitSettingsCompanion(privacyAccepted: Value(value)), + ); + + Future setGuidelinesAccepted( + String customerKey, + ShopInBitCategory category, + bool value, + ) { + final ShopInBitSettingsCompanion patch = switch (category) { + .concierge => ShopInBitSettingsCompanion( + conciergeGuidelinesAccepted: Value(value), + ), + .travel => ShopInBitSettingsCompanion( + travelGuidelinesAccepted: Value(value), + ), + .car => ShopInBitSettingsCompanion(carGuidelinesAccepted: Value(value)), + }; + return _write(customerKey, patch); + } + + Future setSetupComplete(String customerKey, bool value) => _write( + customerKey, + ShopInBitSettingsCompanion(setupComplete: Value(value)), + ); + + Future deleteByKey(String customerKey) { + return (delete( + shopInBitSettings, + )..where((t) => t.customerKey.equals(customerKey))).go(); + } + + Future _write(String customerKey, ShopInBitSettingsCompanion changes) { + return (update( + shopInBitSettings, + )..where((t) => t.customerKey.equals(customerKey))).write(changes); + } +} + +extension ShopInBitSettingGuidelines on ShopInBitSetting { + bool guidelinesAcceptedFor(ShopInBitCategory category) => switch (category) { + .concierge => conciergeGuidelinesAccepted, + .travel => travelGuidelinesAccepted, + .car => carGuidelinesAccepted, + }; +} + +extension ShopInBitTicketUnread on ShopInBitTicket { + bool get hasUnreadAgentMessage { + final DateTime? lastAgent = lastAgentMessageAt; + if (lastAgent == null) return false; + final DateTime? read = lastReadAt; + return read == null || lastAgent.isAfter(read); + } +} diff --git a/lib/db/drift/shared_db/shared_database.g.dart b/lib/db/drift/shared_db/shared_database.g.dart new file mode 100644 index 0000000000..ecb93df5a5 --- /dev/null +++ b/lib/db/drift/shared_db/shared_database.g.dart @@ -0,0 +1,3671 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'shared_database.dart'; + +// ignore_for_file: type=lint +class $CakepayOrdersTable extends CakepayOrders + with TableInfo<$CakepayOrdersTable, CakepayOrder> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CakepayOrdersTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _orderIdMeta = const VerificationMeta( + 'orderId', + ); + @override + late final GeneratedColumn orderId = GeneratedColumn( + 'order_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [orderId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'cakepay_orders'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('order_id')) { + context.handle( + _orderIdMeta, + orderId.isAcceptableOrUnknown(data['order_id']!, _orderIdMeta), + ); + } else if (isInserting) { + context.missing(_orderIdMeta); + } + return context; + } + + @override + Set get $primaryKey => {orderId}; + @override + CakepayOrder map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CakepayOrder( + orderId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_id'], + )!, + ); + } + + @override + $CakepayOrdersTable createAlias(String alias) { + return $CakepayOrdersTable(attachedDatabase, alias); + } +} + +class CakepayOrder extends DataClass implements Insertable { + final String orderId; + const CakepayOrder({required this.orderId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['order_id'] = Variable(orderId); + return map; + } + + CakepayOrdersCompanion toCompanion(bool nullToAbsent) { + return CakepayOrdersCompanion(orderId: Value(orderId)); + } + + factory CakepayOrder.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CakepayOrder(orderId: serializer.fromJson(json['orderId'])); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return {'orderId': serializer.toJson(orderId)}; + } + + CakepayOrder copyWith({String? orderId}) => + CakepayOrder(orderId: orderId ?? this.orderId); + CakepayOrder copyWithCompanion(CakepayOrdersCompanion data) { + return CakepayOrder( + orderId: data.orderId.present ? data.orderId.value : this.orderId, + ); + } + + @override + String toString() { + return (StringBuffer('CakepayOrder(') + ..write('orderId: $orderId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => orderId.hashCode; + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CakepayOrder && other.orderId == this.orderId); +} + +class CakepayOrdersCompanion extends UpdateCompanion { + final Value orderId; + final Value rowid; + const CakepayOrdersCompanion({ + this.orderId = const Value.absent(), + this.rowid = const Value.absent(), + }); + CakepayOrdersCompanion.insert({ + required String orderId, + this.rowid = const Value.absent(), + }) : orderId = Value(orderId); + static Insertable custom({ + Expression? orderId, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (orderId != null) 'order_id': orderId, + if (rowid != null) 'rowid': rowid, + }); + } + + CakepayOrdersCompanion copyWith({Value? orderId, Value? rowid}) { + return CakepayOrdersCompanion( + orderId: orderId ?? this.orderId, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (orderId.present) { + map['order_id'] = Variable(orderId.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CakepayOrdersCompanion(') + ..write('orderId: $orderId, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $ShopInBitSettingsTable extends ShopInBitSettings + with TableInfo<$ShopInBitSettingsTable, ShopInBitSetting> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ShopInBitSettingsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _customerKeyMeta = const VerificationMeta( + 'customerKey', + ); + @override + late final GeneratedColumn customerKey = GeneratedColumn( + 'customer_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _privacyAcceptedMeta = const VerificationMeta( + 'privacyAccepted', + ); + @override + late final GeneratedColumn privacyAccepted = GeneratedColumn( + 'privacy_accepted', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("privacy_accepted" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _conciergeGuidelinesAcceptedMeta = + const VerificationMeta('conciergeGuidelinesAccepted'); + @override + late final GeneratedColumn conciergeGuidelinesAccepted = + GeneratedColumn( + 'concierge_guidelines_accepted', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("concierge_guidelines_accepted" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _travelGuidelinesAcceptedMeta = + const VerificationMeta('travelGuidelinesAccepted'); + @override + late final GeneratedColumn travelGuidelinesAccepted = + GeneratedColumn( + 'travel_guidelines_accepted', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("travel_guidelines_accepted" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _carGuidelinesAcceptedMeta = + const VerificationMeta('carGuidelinesAccepted'); + @override + late final GeneratedColumn carGuidelinesAccepted = + GeneratedColumn( + 'car_guidelines_accepted', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("car_guidelines_accepted" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _setupCompleteMeta = const VerificationMeta( + 'setupComplete', + ); + @override + late final GeneratedColumn setupComplete = GeneratedColumn( + 'setup_complete', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("setup_complete" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _lastUsedAtMeta = const VerificationMeta( + 'lastUsedAt', + ); + @override + late final GeneratedColumn lastUsedAt = GeneratedColumn( + 'last_used_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [ + customerKey, + privacyAccepted, + conciergeGuidelinesAccepted, + travelGuidelinesAccepted, + carGuidelinesAccepted, + setupComplete, + createdAt, + lastUsedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'shop_in_bit_settings'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('customer_key')) { + context.handle( + _customerKeyMeta, + customerKey.isAcceptableOrUnknown( + data['customer_key']!, + _customerKeyMeta, + ), + ); + } else if (isInserting) { + context.missing(_customerKeyMeta); + } + if (data.containsKey('privacy_accepted')) { + context.handle( + _privacyAcceptedMeta, + privacyAccepted.isAcceptableOrUnknown( + data['privacy_accepted']!, + _privacyAcceptedMeta, + ), + ); + } + if (data.containsKey('concierge_guidelines_accepted')) { + context.handle( + _conciergeGuidelinesAcceptedMeta, + conciergeGuidelinesAccepted.isAcceptableOrUnknown( + data['concierge_guidelines_accepted']!, + _conciergeGuidelinesAcceptedMeta, + ), + ); + } + if (data.containsKey('travel_guidelines_accepted')) { + context.handle( + _travelGuidelinesAcceptedMeta, + travelGuidelinesAccepted.isAcceptableOrUnknown( + data['travel_guidelines_accepted']!, + _travelGuidelinesAcceptedMeta, + ), + ); + } + if (data.containsKey('car_guidelines_accepted')) { + context.handle( + _carGuidelinesAcceptedMeta, + carGuidelinesAccepted.isAcceptableOrUnknown( + data['car_guidelines_accepted']!, + _carGuidelinesAcceptedMeta, + ), + ); + } + if (data.containsKey('setup_complete')) { + context.handle( + _setupCompleteMeta, + setupComplete.isAcceptableOrUnknown( + data['setup_complete']!, + _setupCompleteMeta, + ), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + if (data.containsKey('last_used_at')) { + context.handle( + _lastUsedAtMeta, + lastUsedAt.isAcceptableOrUnknown( + data['last_used_at']!, + _lastUsedAtMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {customerKey}; + @override + ShopInBitSetting map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ShopInBitSetting( + customerKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}customer_key'], + )!, + privacyAccepted: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}privacy_accepted'], + )!, + conciergeGuidelinesAccepted: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}concierge_guidelines_accepted'], + )!, + travelGuidelinesAccepted: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}travel_guidelines_accepted'], + )!, + carGuidelinesAccepted: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}car_guidelines_accepted'], + )!, + setupComplete: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}setup_complete'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + lastUsedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_used_at'], + )!, + ); + } + + @override + $ShopInBitSettingsTable createAlias(String alias) { + return $ShopInBitSettingsTable(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; +} + +class ShopInBitSetting extends DataClass + implements Insertable { + final String customerKey; + final bool privacyAccepted; + final bool conciergeGuidelinesAccepted; + final bool travelGuidelinesAccepted; + final bool carGuidelinesAccepted; + final bool setupComplete; + final DateTime createdAt; + final DateTime lastUsedAt; + const ShopInBitSetting({ + required this.customerKey, + required this.privacyAccepted, + required this.conciergeGuidelinesAccepted, + required this.travelGuidelinesAccepted, + required this.carGuidelinesAccepted, + required this.setupComplete, + required this.createdAt, + required this.lastUsedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['customer_key'] = Variable(customerKey); + map['privacy_accepted'] = Variable(privacyAccepted); + map['concierge_guidelines_accepted'] = Variable( + conciergeGuidelinesAccepted, + ); + map['travel_guidelines_accepted'] = Variable( + travelGuidelinesAccepted, + ); + map['car_guidelines_accepted'] = Variable(carGuidelinesAccepted); + map['setup_complete'] = Variable(setupComplete); + map['created_at'] = Variable(createdAt); + map['last_used_at'] = Variable(lastUsedAt); + return map; + } + + ShopInBitSettingsCompanion toCompanion(bool nullToAbsent) { + return ShopInBitSettingsCompanion( + customerKey: Value(customerKey), + privacyAccepted: Value(privacyAccepted), + conciergeGuidelinesAccepted: Value(conciergeGuidelinesAccepted), + travelGuidelinesAccepted: Value(travelGuidelinesAccepted), + carGuidelinesAccepted: Value(carGuidelinesAccepted), + setupComplete: Value(setupComplete), + createdAt: Value(createdAt), + lastUsedAt: Value(lastUsedAt), + ); + } + + factory ShopInBitSetting.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ShopInBitSetting( + customerKey: serializer.fromJson(json['customerKey']), + privacyAccepted: serializer.fromJson(json['privacyAccepted']), + conciergeGuidelinesAccepted: serializer.fromJson( + json['conciergeGuidelinesAccepted'], + ), + travelGuidelinesAccepted: serializer.fromJson( + json['travelGuidelinesAccepted'], + ), + carGuidelinesAccepted: serializer.fromJson( + json['carGuidelinesAccepted'], + ), + setupComplete: serializer.fromJson(json['setupComplete']), + createdAt: serializer.fromJson(json['createdAt']), + lastUsedAt: serializer.fromJson(json['lastUsedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'customerKey': serializer.toJson(customerKey), + 'privacyAccepted': serializer.toJson(privacyAccepted), + 'conciergeGuidelinesAccepted': serializer.toJson( + conciergeGuidelinesAccepted, + ), + 'travelGuidelinesAccepted': serializer.toJson( + travelGuidelinesAccepted, + ), + 'carGuidelinesAccepted': serializer.toJson(carGuidelinesAccepted), + 'setupComplete': serializer.toJson(setupComplete), + 'createdAt': serializer.toJson(createdAt), + 'lastUsedAt': serializer.toJson(lastUsedAt), + }; + } + + ShopInBitSetting copyWith({ + String? customerKey, + bool? privacyAccepted, + bool? conciergeGuidelinesAccepted, + bool? travelGuidelinesAccepted, + bool? carGuidelinesAccepted, + bool? setupComplete, + DateTime? createdAt, + DateTime? lastUsedAt, + }) => ShopInBitSetting( + customerKey: customerKey ?? this.customerKey, + privacyAccepted: privacyAccepted ?? this.privacyAccepted, + conciergeGuidelinesAccepted: + conciergeGuidelinesAccepted ?? this.conciergeGuidelinesAccepted, + travelGuidelinesAccepted: + travelGuidelinesAccepted ?? this.travelGuidelinesAccepted, + carGuidelinesAccepted: carGuidelinesAccepted ?? this.carGuidelinesAccepted, + setupComplete: setupComplete ?? this.setupComplete, + createdAt: createdAt ?? this.createdAt, + lastUsedAt: lastUsedAt ?? this.lastUsedAt, + ); + ShopInBitSetting copyWithCompanion(ShopInBitSettingsCompanion data) { + return ShopInBitSetting( + customerKey: data.customerKey.present + ? data.customerKey.value + : this.customerKey, + privacyAccepted: data.privacyAccepted.present + ? data.privacyAccepted.value + : this.privacyAccepted, + conciergeGuidelinesAccepted: data.conciergeGuidelinesAccepted.present + ? data.conciergeGuidelinesAccepted.value + : this.conciergeGuidelinesAccepted, + travelGuidelinesAccepted: data.travelGuidelinesAccepted.present + ? data.travelGuidelinesAccepted.value + : this.travelGuidelinesAccepted, + carGuidelinesAccepted: data.carGuidelinesAccepted.present + ? data.carGuidelinesAccepted.value + : this.carGuidelinesAccepted, + setupComplete: data.setupComplete.present + ? data.setupComplete.value + : this.setupComplete, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + lastUsedAt: data.lastUsedAt.present + ? data.lastUsedAt.value + : this.lastUsedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ShopInBitSetting(') + ..write('customerKey: $customerKey, ') + ..write('privacyAccepted: $privacyAccepted, ') + ..write('conciergeGuidelinesAccepted: $conciergeGuidelinesAccepted, ') + ..write('travelGuidelinesAccepted: $travelGuidelinesAccepted, ') + ..write('carGuidelinesAccepted: $carGuidelinesAccepted, ') + ..write('setupComplete: $setupComplete, ') + ..write('createdAt: $createdAt, ') + ..write('lastUsedAt: $lastUsedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + customerKey, + privacyAccepted, + conciergeGuidelinesAccepted, + travelGuidelinesAccepted, + carGuidelinesAccepted, + setupComplete, + createdAt, + lastUsedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ShopInBitSetting && + other.customerKey == this.customerKey && + other.privacyAccepted == this.privacyAccepted && + other.conciergeGuidelinesAccepted == + this.conciergeGuidelinesAccepted && + other.travelGuidelinesAccepted == this.travelGuidelinesAccepted && + other.carGuidelinesAccepted == this.carGuidelinesAccepted && + other.setupComplete == this.setupComplete && + other.createdAt == this.createdAt && + other.lastUsedAt == this.lastUsedAt); +} + +class ShopInBitSettingsCompanion extends UpdateCompanion { + final Value customerKey; + final Value privacyAccepted; + final Value conciergeGuidelinesAccepted; + final Value travelGuidelinesAccepted; + final Value carGuidelinesAccepted; + final Value setupComplete; + final Value createdAt; + final Value lastUsedAt; + const ShopInBitSettingsCompanion({ + this.customerKey = const Value.absent(), + this.privacyAccepted = const Value.absent(), + this.conciergeGuidelinesAccepted = const Value.absent(), + this.travelGuidelinesAccepted = const Value.absent(), + this.carGuidelinesAccepted = const Value.absent(), + this.setupComplete = const Value.absent(), + this.createdAt = const Value.absent(), + this.lastUsedAt = const Value.absent(), + }); + ShopInBitSettingsCompanion.insert({ + required String customerKey, + this.privacyAccepted = const Value.absent(), + this.conciergeGuidelinesAccepted = const Value.absent(), + this.travelGuidelinesAccepted = const Value.absent(), + this.carGuidelinesAccepted = const Value.absent(), + this.setupComplete = const Value.absent(), + this.createdAt = const Value.absent(), + this.lastUsedAt = const Value.absent(), + }) : customerKey = Value(customerKey); + static Insertable custom({ + Expression? customerKey, + Expression? privacyAccepted, + Expression? conciergeGuidelinesAccepted, + Expression? travelGuidelinesAccepted, + Expression? carGuidelinesAccepted, + Expression? setupComplete, + Expression? createdAt, + Expression? lastUsedAt, + }) { + return RawValuesInsertable({ + if (customerKey != null) 'customer_key': customerKey, + if (privacyAccepted != null) 'privacy_accepted': privacyAccepted, + if (conciergeGuidelinesAccepted != null) + 'concierge_guidelines_accepted': conciergeGuidelinesAccepted, + if (travelGuidelinesAccepted != null) + 'travel_guidelines_accepted': travelGuidelinesAccepted, + if (carGuidelinesAccepted != null) + 'car_guidelines_accepted': carGuidelinesAccepted, + if (setupComplete != null) 'setup_complete': setupComplete, + if (createdAt != null) 'created_at': createdAt, + if (lastUsedAt != null) 'last_used_at': lastUsedAt, + }); + } + + ShopInBitSettingsCompanion copyWith({ + Value? customerKey, + Value? privacyAccepted, + Value? conciergeGuidelinesAccepted, + Value? travelGuidelinesAccepted, + Value? carGuidelinesAccepted, + Value? setupComplete, + Value? createdAt, + Value? lastUsedAt, + }) { + return ShopInBitSettingsCompanion( + customerKey: customerKey ?? this.customerKey, + privacyAccepted: privacyAccepted ?? this.privacyAccepted, + conciergeGuidelinesAccepted: + conciergeGuidelinesAccepted ?? this.conciergeGuidelinesAccepted, + travelGuidelinesAccepted: + travelGuidelinesAccepted ?? this.travelGuidelinesAccepted, + carGuidelinesAccepted: + carGuidelinesAccepted ?? this.carGuidelinesAccepted, + setupComplete: setupComplete ?? this.setupComplete, + createdAt: createdAt ?? this.createdAt, + lastUsedAt: lastUsedAt ?? this.lastUsedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (customerKey.present) { + map['customer_key'] = Variable(customerKey.value); + } + if (privacyAccepted.present) { + map['privacy_accepted'] = Variable(privacyAccepted.value); + } + if (conciergeGuidelinesAccepted.present) { + map['concierge_guidelines_accepted'] = Variable( + conciergeGuidelinesAccepted.value, + ); + } + if (travelGuidelinesAccepted.present) { + map['travel_guidelines_accepted'] = Variable( + travelGuidelinesAccepted.value, + ); + } + if (carGuidelinesAccepted.present) { + map['car_guidelines_accepted'] = Variable( + carGuidelinesAccepted.value, + ); + } + if (setupComplete.present) { + map['setup_complete'] = Variable(setupComplete.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (lastUsedAt.present) { + map['last_used_at'] = Variable(lastUsedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ShopInBitSettingsCompanion(') + ..write('customerKey: $customerKey, ') + ..write('privacyAccepted: $privacyAccepted, ') + ..write('conciergeGuidelinesAccepted: $conciergeGuidelinesAccepted, ') + ..write('travelGuidelinesAccepted: $travelGuidelinesAccepted, ') + ..write('carGuidelinesAccepted: $carGuidelinesAccepted, ') + ..write('setupComplete: $setupComplete, ') + ..write('createdAt: $createdAt, ') + ..write('lastUsedAt: $lastUsedAt') + ..write(')')) + .toString(); + } +} + +class $ShopInBitTicketsTable extends ShopInBitTickets + with TableInfo<$ShopInBitTicketsTable, ShopInBitTicket> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ShopInBitTicketsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _apiTicketIdMeta = const VerificationMeta( + 'apiTicketId', + ); + @override + late final GeneratedColumn apiTicketId = GeneratedColumn( + 'api_ticket_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _customerKeyMeta = const VerificationMeta( + 'customerKey', + ); + @override + late final GeneratedColumn customerKey = GeneratedColumn( + 'customer_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _ticketNumberMeta = const VerificationMeta( + 'ticketNumber', + ); + @override + late final GeneratedColumn ticketNumber = GeneratedColumn( + 'ticket_number', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + late final GeneratedColumnWithTypeConverter + category = GeneratedColumn( + 'category', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter($ShopInBitTicketsTable.$convertercategory); + static const VerificationMeta _requestDescriptionMeta = + const VerificationMeta('requestDescription'); + @override + late final GeneratedColumn requestDescription = + GeneratedColumn( + 'request_description', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _deliveryCountryMeta = const VerificationMeta( + 'deliveryCountry', + ); + @override + late final GeneratedColumn deliveryCountry = GeneratedColumn( + 'delivery_country', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + late final GeneratedColumnWithTypeConverter + status = + GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter( + $ShopInBitTicketsTable.$converterstatus, + ); + static const VerificationMeta _statusRawMeta = const VerificationMeta( + 'statusRaw', + ); + @override + late final GeneratedColumn statusRaw = GeneratedColumn( + 'status_raw', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _offerProductNameMeta = const VerificationMeta( + 'offerProductName', + ); + @override + late final GeneratedColumn offerProductName = GeneratedColumn( + 'offer_product_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _offerPriceMeta = const VerificationMeta( + 'offerPrice', + ); + @override + late final GeneratedColumn offerPrice = GeneratedColumn( + 'offer_price', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _paymentInvoiceStatusMeta = + const VerificationMeta('paymentInvoiceStatus'); + @override + late final GeneratedColumn paymentInvoiceStatus = + GeneratedColumn( + 'payment_invoice_status', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _trackingLinkMeta = const VerificationMeta( + 'trackingLink', + ); + @override + late final GeneratedColumn trackingLink = GeneratedColumn( + 'tracking_link', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter + lastAgentMessageAt = + GeneratedColumn( + 'last_agent_message_at', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $ShopInBitTicketsTable.$converterlastAgentMessageAtn, + ); + static const VerificationMeta _feeTicketNumberMeta = const VerificationMeta( + 'feeTicketNumber', + ); + @override + late final GeneratedColumn feeTicketNumber = GeneratedColumn( + 'fee_ticket_number', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter, String> + messages = + GeneratedColumn( + 'messages', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant("[]"), + ).withConverter>( + $ShopInBitTicketsTable.$convertermessages, + ); + @override + late final GeneratedColumnWithTypeConverter createdAt = + GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + clientDefault: () => + ShopInBitTickets.dateConverter.toSql(DateTime.now()), + ).withConverter($ShopInBitTicketsTable.$convertercreatedAt); + @override + late final GeneratedColumnWithTypeConverter updatedAt = + GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + clientDefault: () => + ShopInBitTickets.dateConverter.toSql(DateTime.now()), + ).withConverter($ShopInBitTicketsTable.$converterupdatedAt); + @override + late final GeneratedColumnWithTypeConverter lastReadAt = + GeneratedColumn( + 'last_read_at', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter($ShopInBitTicketsTable.$converterlastReadAtn); + @override + List get $columns => [ + apiTicketId, + customerKey, + ticketNumber, + category, + requestDescription, + deliveryCountry, + status, + statusRaw, + offerProductName, + offerPrice, + paymentInvoiceStatus, + trackingLink, + lastAgentMessageAt, + feeTicketNumber, + messages, + createdAt, + updatedAt, + lastReadAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'shop_in_bit_tickets'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('api_ticket_id')) { + context.handle( + _apiTicketIdMeta, + apiTicketId.isAcceptableOrUnknown( + data['api_ticket_id']!, + _apiTicketIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_apiTicketIdMeta); + } + if (data.containsKey('customer_key')) { + context.handle( + _customerKeyMeta, + customerKey.isAcceptableOrUnknown( + data['customer_key']!, + _customerKeyMeta, + ), + ); + } else if (isInserting) { + context.missing(_customerKeyMeta); + } + if (data.containsKey('ticket_number')) { + context.handle( + _ticketNumberMeta, + ticketNumber.isAcceptableOrUnknown( + data['ticket_number']!, + _ticketNumberMeta, + ), + ); + } else if (isInserting) { + context.missing(_ticketNumberMeta); + } + if (data.containsKey('request_description')) { + context.handle( + _requestDescriptionMeta, + requestDescription.isAcceptableOrUnknown( + data['request_description']!, + _requestDescriptionMeta, + ), + ); + } else if (isInserting) { + context.missing(_requestDescriptionMeta); + } + if (data.containsKey('delivery_country')) { + context.handle( + _deliveryCountryMeta, + deliveryCountry.isAcceptableOrUnknown( + data['delivery_country']!, + _deliveryCountryMeta, + ), + ); + } else if (isInserting) { + context.missing(_deliveryCountryMeta); + } + if (data.containsKey('status_raw')) { + context.handle( + _statusRawMeta, + statusRaw.isAcceptableOrUnknown(data['status_raw']!, _statusRawMeta), + ); + } else if (isInserting) { + context.missing(_statusRawMeta); + } + if (data.containsKey('offer_product_name')) { + context.handle( + _offerProductNameMeta, + offerProductName.isAcceptableOrUnknown( + data['offer_product_name']!, + _offerProductNameMeta, + ), + ); + } + if (data.containsKey('offer_price')) { + context.handle( + _offerPriceMeta, + offerPrice.isAcceptableOrUnknown(data['offer_price']!, _offerPriceMeta), + ); + } + if (data.containsKey('payment_invoice_status')) { + context.handle( + _paymentInvoiceStatusMeta, + paymentInvoiceStatus.isAcceptableOrUnknown( + data['payment_invoice_status']!, + _paymentInvoiceStatusMeta, + ), + ); + } + if (data.containsKey('tracking_link')) { + context.handle( + _trackingLinkMeta, + trackingLink.isAcceptableOrUnknown( + data['tracking_link']!, + _trackingLinkMeta, + ), + ); + } + if (data.containsKey('fee_ticket_number')) { + context.handle( + _feeTicketNumberMeta, + feeTicketNumber.isAcceptableOrUnknown( + data['fee_ticket_number']!, + _feeTicketNumberMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {apiTicketId}; + @override + ShopInBitTicket map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ShopInBitTicket( + apiTicketId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}api_ticket_id'], + )!, + customerKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}customer_key'], + )!, + ticketNumber: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}ticket_number'], + )!, + category: $ShopInBitTicketsTable.$convertercategory.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}category'], + )!, + ), + requestDescription: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}request_description'], + )!, + deliveryCountry: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}delivery_country'], + )!, + status: $ShopInBitTicketsTable.$converterstatus.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + ), + statusRaw: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status_raw'], + )!, + offerProductName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}offer_product_name'], + ), + offerPrice: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}offer_price'], + ), + paymentInvoiceStatus: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}payment_invoice_status'], + ), + trackingLink: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tracking_link'], + ), + lastAgentMessageAt: $ShopInBitTicketsTable.$converterlastAgentMessageAtn + .fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_agent_message_at'], + ), + ), + feeTicketNumber: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}fee_ticket_number'], + ), + messages: $ShopInBitTicketsTable.$convertermessages.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}messages'], + )!, + ), + createdAt: $ShopInBitTicketsTable.$convertercreatedAt.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}created_at'], + )!, + ), + updatedAt: $ShopInBitTicketsTable.$converterupdatedAt.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}updated_at'], + )!, + ), + lastReadAt: $ShopInBitTicketsTable.$converterlastReadAtn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_read_at'], + ), + ), + ); + } + + @override + $ShopInBitTicketsTable createAlias(String alias) { + return $ShopInBitTicketsTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 + $convertercategory = const EnumNameConverter( + ShopInBitCategory.values, + ); + static JsonTypeConverter2 + $converterstatus = const EnumNameConverter( + ShopInBitOrderStatus.values, + ); + static TypeConverter $converterlastAgentMessageAt = + ShopInBitTickets.dateConverter; + static TypeConverter $converterlastAgentMessageAtn = + NullAwareTypeConverter.wrap($converterlastAgentMessageAt); + static TypeConverter, String> $convertermessages = + const MessagesConverter(); + static TypeConverter $convertercreatedAt = + ShopInBitTickets.dateConverter; + static TypeConverter $converterupdatedAt = + ShopInBitTickets.dateConverter; + static TypeConverter $converterlastReadAt = + ShopInBitTickets.dateConverter; + static TypeConverter $converterlastReadAtn = + NullAwareTypeConverter.wrap($converterlastReadAt); + @override + bool get withoutRowId => true; +} + +class ShopInBitTicket extends DataClass implements Insertable { + final int apiTicketId; + final String customerKey; + final String ticketNumber; + final ShopInBitCategory category; + final String requestDescription; + final String deliveryCountry; + final ShopInBitOrderStatus status; + final String statusRaw; + final String? offerProductName; + final String? offerPrice; + final String? paymentInvoiceStatus; + final String? trackingLink; + final DateTime? lastAgentMessageAt; + final String? feeTicketNumber; + final List messages; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? lastReadAt; + const ShopInBitTicket({ + required this.apiTicketId, + required this.customerKey, + required this.ticketNumber, + required this.category, + required this.requestDescription, + required this.deliveryCountry, + required this.status, + required this.statusRaw, + this.offerProductName, + this.offerPrice, + this.paymentInvoiceStatus, + this.trackingLink, + this.lastAgentMessageAt, + this.feeTicketNumber, + required this.messages, + required this.createdAt, + required this.updatedAt, + this.lastReadAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['api_ticket_id'] = Variable(apiTicketId); + map['customer_key'] = Variable(customerKey); + map['ticket_number'] = Variable(ticketNumber); + { + map['category'] = Variable( + $ShopInBitTicketsTable.$convertercategory.toSql(category), + ); + } + map['request_description'] = Variable(requestDescription); + map['delivery_country'] = Variable(deliveryCountry); + { + map['status'] = Variable( + $ShopInBitTicketsTable.$converterstatus.toSql(status), + ); + } + map['status_raw'] = Variable(statusRaw); + if (!nullToAbsent || offerProductName != null) { + map['offer_product_name'] = Variable(offerProductName); + } + if (!nullToAbsent || offerPrice != null) { + map['offer_price'] = Variable(offerPrice); + } + if (!nullToAbsent || paymentInvoiceStatus != null) { + map['payment_invoice_status'] = Variable(paymentInvoiceStatus); + } + if (!nullToAbsent || trackingLink != null) { + map['tracking_link'] = Variable(trackingLink); + } + if (!nullToAbsent || lastAgentMessageAt != null) { + map['last_agent_message_at'] = Variable( + $ShopInBitTicketsTable.$converterlastAgentMessageAtn.toSql( + lastAgentMessageAt, + ), + ); + } + if (!nullToAbsent || feeTicketNumber != null) { + map['fee_ticket_number'] = Variable(feeTicketNumber); + } + { + map['messages'] = Variable( + $ShopInBitTicketsTable.$convertermessages.toSql(messages), + ); + } + { + map['created_at'] = Variable( + $ShopInBitTicketsTable.$convertercreatedAt.toSql(createdAt), + ); + } + { + map['updated_at'] = Variable( + $ShopInBitTicketsTable.$converterupdatedAt.toSql(updatedAt), + ); + } + if (!nullToAbsent || lastReadAt != null) { + map['last_read_at'] = Variable( + $ShopInBitTicketsTable.$converterlastReadAtn.toSql(lastReadAt), + ); + } + return map; + } + + ShopInBitTicketsCompanion toCompanion(bool nullToAbsent) { + return ShopInBitTicketsCompanion( + apiTicketId: Value(apiTicketId), + customerKey: Value(customerKey), + ticketNumber: Value(ticketNumber), + category: Value(category), + requestDescription: Value(requestDescription), + deliveryCountry: Value(deliveryCountry), + status: Value(status), + statusRaw: Value(statusRaw), + offerProductName: offerProductName == null && nullToAbsent + ? const Value.absent() + : Value(offerProductName), + offerPrice: offerPrice == null && nullToAbsent + ? const Value.absent() + : Value(offerPrice), + paymentInvoiceStatus: paymentInvoiceStatus == null && nullToAbsent + ? const Value.absent() + : Value(paymentInvoiceStatus), + trackingLink: trackingLink == null && nullToAbsent + ? const Value.absent() + : Value(trackingLink), + lastAgentMessageAt: lastAgentMessageAt == null && nullToAbsent + ? const Value.absent() + : Value(lastAgentMessageAt), + feeTicketNumber: feeTicketNumber == null && nullToAbsent + ? const Value.absent() + : Value(feeTicketNumber), + messages: Value(messages), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + lastReadAt: lastReadAt == null && nullToAbsent + ? const Value.absent() + : Value(lastReadAt), + ); + } + + factory ShopInBitTicket.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ShopInBitTicket( + apiTicketId: serializer.fromJson(json['apiTicketId']), + customerKey: serializer.fromJson(json['customerKey']), + ticketNumber: serializer.fromJson(json['ticketNumber']), + category: $ShopInBitTicketsTable.$convertercategory.fromJson( + serializer.fromJson(json['category']), + ), + requestDescription: serializer.fromJson( + json['requestDescription'], + ), + deliveryCountry: serializer.fromJson(json['deliveryCountry']), + status: $ShopInBitTicketsTable.$converterstatus.fromJson( + serializer.fromJson(json['status']), + ), + statusRaw: serializer.fromJson(json['statusRaw']), + offerProductName: serializer.fromJson(json['offerProductName']), + offerPrice: serializer.fromJson(json['offerPrice']), + paymentInvoiceStatus: serializer.fromJson( + json['paymentInvoiceStatus'], + ), + trackingLink: serializer.fromJson(json['trackingLink']), + lastAgentMessageAt: serializer.fromJson( + json['lastAgentMessageAt'], + ), + feeTicketNumber: serializer.fromJson(json['feeTicketNumber']), + messages: serializer.fromJson>(json['messages']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + lastReadAt: serializer.fromJson(json['lastReadAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'apiTicketId': serializer.toJson(apiTicketId), + 'customerKey': serializer.toJson(customerKey), + 'ticketNumber': serializer.toJson(ticketNumber), + 'category': serializer.toJson( + $ShopInBitTicketsTable.$convertercategory.toJson(category), + ), + 'requestDescription': serializer.toJson(requestDescription), + 'deliveryCountry': serializer.toJson(deliveryCountry), + 'status': serializer.toJson( + $ShopInBitTicketsTable.$converterstatus.toJson(status), + ), + 'statusRaw': serializer.toJson(statusRaw), + 'offerProductName': serializer.toJson(offerProductName), + 'offerPrice': serializer.toJson(offerPrice), + 'paymentInvoiceStatus': serializer.toJson(paymentInvoiceStatus), + 'trackingLink': serializer.toJson(trackingLink), + 'lastAgentMessageAt': serializer.toJson(lastAgentMessageAt), + 'feeTicketNumber': serializer.toJson(feeTicketNumber), + 'messages': serializer.toJson>(messages), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'lastReadAt': serializer.toJson(lastReadAt), + }; + } + + ShopInBitTicket copyWith({ + int? apiTicketId, + String? customerKey, + String? ticketNumber, + ShopInBitCategory? category, + String? requestDescription, + String? deliveryCountry, + ShopInBitOrderStatus? status, + String? statusRaw, + Value offerProductName = const Value.absent(), + Value offerPrice = const Value.absent(), + Value paymentInvoiceStatus = const Value.absent(), + Value trackingLink = const Value.absent(), + Value lastAgentMessageAt = const Value.absent(), + Value feeTicketNumber = const Value.absent(), + List? messages, + DateTime? createdAt, + DateTime? updatedAt, + Value lastReadAt = const Value.absent(), + }) => ShopInBitTicket( + apiTicketId: apiTicketId ?? this.apiTicketId, + customerKey: customerKey ?? this.customerKey, + ticketNumber: ticketNumber ?? this.ticketNumber, + category: category ?? this.category, + requestDescription: requestDescription ?? this.requestDescription, + deliveryCountry: deliveryCountry ?? this.deliveryCountry, + status: status ?? this.status, + statusRaw: statusRaw ?? this.statusRaw, + offerProductName: offerProductName.present + ? offerProductName.value + : this.offerProductName, + offerPrice: offerPrice.present ? offerPrice.value : this.offerPrice, + paymentInvoiceStatus: paymentInvoiceStatus.present + ? paymentInvoiceStatus.value + : this.paymentInvoiceStatus, + trackingLink: trackingLink.present ? trackingLink.value : this.trackingLink, + lastAgentMessageAt: lastAgentMessageAt.present + ? lastAgentMessageAt.value + : this.lastAgentMessageAt, + feeTicketNumber: feeTicketNumber.present + ? feeTicketNumber.value + : this.feeTicketNumber, + messages: messages ?? this.messages, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lastReadAt: lastReadAt.present ? lastReadAt.value : this.lastReadAt, + ); + ShopInBitTicket copyWithCompanion(ShopInBitTicketsCompanion data) { + return ShopInBitTicket( + apiTicketId: data.apiTicketId.present + ? data.apiTicketId.value + : this.apiTicketId, + customerKey: data.customerKey.present + ? data.customerKey.value + : this.customerKey, + ticketNumber: data.ticketNumber.present + ? data.ticketNumber.value + : this.ticketNumber, + category: data.category.present ? data.category.value : this.category, + requestDescription: data.requestDescription.present + ? data.requestDescription.value + : this.requestDescription, + deliveryCountry: data.deliveryCountry.present + ? data.deliveryCountry.value + : this.deliveryCountry, + status: data.status.present ? data.status.value : this.status, + statusRaw: data.statusRaw.present ? data.statusRaw.value : this.statusRaw, + offerProductName: data.offerProductName.present + ? data.offerProductName.value + : this.offerProductName, + offerPrice: data.offerPrice.present + ? data.offerPrice.value + : this.offerPrice, + paymentInvoiceStatus: data.paymentInvoiceStatus.present + ? data.paymentInvoiceStatus.value + : this.paymentInvoiceStatus, + trackingLink: data.trackingLink.present + ? data.trackingLink.value + : this.trackingLink, + lastAgentMessageAt: data.lastAgentMessageAt.present + ? data.lastAgentMessageAt.value + : this.lastAgentMessageAt, + feeTicketNumber: data.feeTicketNumber.present + ? data.feeTicketNumber.value + : this.feeTicketNumber, + messages: data.messages.present ? data.messages.value : this.messages, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + lastReadAt: data.lastReadAt.present + ? data.lastReadAt.value + : this.lastReadAt, + ); + } + + @override + String toString() { + return (StringBuffer('ShopInBitTicket(') + ..write('apiTicketId: $apiTicketId, ') + ..write('customerKey: $customerKey, ') + ..write('ticketNumber: $ticketNumber, ') + ..write('category: $category, ') + ..write('requestDescription: $requestDescription, ') + ..write('deliveryCountry: $deliveryCountry, ') + ..write('status: $status, ') + ..write('statusRaw: $statusRaw, ') + ..write('offerProductName: $offerProductName, ') + ..write('offerPrice: $offerPrice, ') + ..write('paymentInvoiceStatus: $paymentInvoiceStatus, ') + ..write('trackingLink: $trackingLink, ') + ..write('lastAgentMessageAt: $lastAgentMessageAt, ') + ..write('feeTicketNumber: $feeTicketNumber, ') + ..write('messages: $messages, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('lastReadAt: $lastReadAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + apiTicketId, + customerKey, + ticketNumber, + category, + requestDescription, + deliveryCountry, + status, + statusRaw, + offerProductName, + offerPrice, + paymentInvoiceStatus, + trackingLink, + lastAgentMessageAt, + feeTicketNumber, + messages, + createdAt, + updatedAt, + lastReadAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ShopInBitTicket && + other.apiTicketId == this.apiTicketId && + other.customerKey == this.customerKey && + other.ticketNumber == this.ticketNumber && + other.category == this.category && + other.requestDescription == this.requestDescription && + other.deliveryCountry == this.deliveryCountry && + other.status == this.status && + other.statusRaw == this.statusRaw && + other.offerProductName == this.offerProductName && + other.offerPrice == this.offerPrice && + other.paymentInvoiceStatus == this.paymentInvoiceStatus && + other.trackingLink == this.trackingLink && + other.lastAgentMessageAt == this.lastAgentMessageAt && + other.feeTicketNumber == this.feeTicketNumber && + other.messages == this.messages && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.lastReadAt == this.lastReadAt); +} + +class ShopInBitTicketsCompanion extends UpdateCompanion { + final Value apiTicketId; + final Value customerKey; + final Value ticketNumber; + final Value category; + final Value requestDescription; + final Value deliveryCountry; + final Value status; + final Value statusRaw; + final Value offerProductName; + final Value offerPrice; + final Value paymentInvoiceStatus; + final Value trackingLink; + final Value lastAgentMessageAt; + final Value feeTicketNumber; + final Value> messages; + final Value createdAt; + final Value updatedAt; + final Value lastReadAt; + const ShopInBitTicketsCompanion({ + this.apiTicketId = const Value.absent(), + this.customerKey = const Value.absent(), + this.ticketNumber = const Value.absent(), + this.category = const Value.absent(), + this.requestDescription = const Value.absent(), + this.deliveryCountry = const Value.absent(), + this.status = const Value.absent(), + this.statusRaw = const Value.absent(), + this.offerProductName = const Value.absent(), + this.offerPrice = const Value.absent(), + this.paymentInvoiceStatus = const Value.absent(), + this.trackingLink = const Value.absent(), + this.lastAgentMessageAt = const Value.absent(), + this.feeTicketNumber = const Value.absent(), + this.messages = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.lastReadAt = const Value.absent(), + }); + ShopInBitTicketsCompanion.insert({ + required int apiTicketId, + required String customerKey, + required String ticketNumber, + required ShopInBitCategory category, + required String requestDescription, + required String deliveryCountry, + required ShopInBitOrderStatus status, + required String statusRaw, + this.offerProductName = const Value.absent(), + this.offerPrice = const Value.absent(), + this.paymentInvoiceStatus = const Value.absent(), + this.trackingLink = const Value.absent(), + this.lastAgentMessageAt = const Value.absent(), + this.feeTicketNumber = const Value.absent(), + this.messages = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.lastReadAt = const Value.absent(), + }) : apiTicketId = Value(apiTicketId), + customerKey = Value(customerKey), + ticketNumber = Value(ticketNumber), + category = Value(category), + requestDescription = Value(requestDescription), + deliveryCountry = Value(deliveryCountry), + status = Value(status), + statusRaw = Value(statusRaw); + static Insertable custom({ + Expression? apiTicketId, + Expression? customerKey, + Expression? ticketNumber, + Expression? category, + Expression? requestDescription, + Expression? deliveryCountry, + Expression? status, + Expression? statusRaw, + Expression? offerProductName, + Expression? offerPrice, + Expression? paymentInvoiceStatus, + Expression? trackingLink, + Expression? lastAgentMessageAt, + Expression? feeTicketNumber, + Expression? messages, + Expression? createdAt, + Expression? updatedAt, + Expression? lastReadAt, + }) { + return RawValuesInsertable({ + if (apiTicketId != null) 'api_ticket_id': apiTicketId, + if (customerKey != null) 'customer_key': customerKey, + if (ticketNumber != null) 'ticket_number': ticketNumber, + if (category != null) 'category': category, + if (requestDescription != null) 'request_description': requestDescription, + if (deliveryCountry != null) 'delivery_country': deliveryCountry, + if (status != null) 'status': status, + if (statusRaw != null) 'status_raw': statusRaw, + if (offerProductName != null) 'offer_product_name': offerProductName, + if (offerPrice != null) 'offer_price': offerPrice, + if (paymentInvoiceStatus != null) + 'payment_invoice_status': paymentInvoiceStatus, + if (trackingLink != null) 'tracking_link': trackingLink, + if (lastAgentMessageAt != null) + 'last_agent_message_at': lastAgentMessageAt, + if (feeTicketNumber != null) 'fee_ticket_number': feeTicketNumber, + if (messages != null) 'messages': messages, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (lastReadAt != null) 'last_read_at': lastReadAt, + }); + } + + ShopInBitTicketsCompanion copyWith({ + Value? apiTicketId, + Value? customerKey, + Value? ticketNumber, + Value? category, + Value? requestDescription, + Value? deliveryCountry, + Value? status, + Value? statusRaw, + Value? offerProductName, + Value? offerPrice, + Value? paymentInvoiceStatus, + Value? trackingLink, + Value? lastAgentMessageAt, + Value? feeTicketNumber, + Value>? messages, + Value? createdAt, + Value? updatedAt, + Value? lastReadAt, + }) { + return ShopInBitTicketsCompanion( + apiTicketId: apiTicketId ?? this.apiTicketId, + customerKey: customerKey ?? this.customerKey, + ticketNumber: ticketNumber ?? this.ticketNumber, + category: category ?? this.category, + requestDescription: requestDescription ?? this.requestDescription, + deliveryCountry: deliveryCountry ?? this.deliveryCountry, + status: status ?? this.status, + statusRaw: statusRaw ?? this.statusRaw, + offerProductName: offerProductName ?? this.offerProductName, + offerPrice: offerPrice ?? this.offerPrice, + paymentInvoiceStatus: paymentInvoiceStatus ?? this.paymentInvoiceStatus, + trackingLink: trackingLink ?? this.trackingLink, + lastAgentMessageAt: lastAgentMessageAt ?? this.lastAgentMessageAt, + feeTicketNumber: feeTicketNumber ?? this.feeTicketNumber, + messages: messages ?? this.messages, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lastReadAt: lastReadAt ?? this.lastReadAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (apiTicketId.present) { + map['api_ticket_id'] = Variable(apiTicketId.value); + } + if (customerKey.present) { + map['customer_key'] = Variable(customerKey.value); + } + if (ticketNumber.present) { + map['ticket_number'] = Variable(ticketNumber.value); + } + if (category.present) { + map['category'] = Variable( + $ShopInBitTicketsTable.$convertercategory.toSql(category.value), + ); + } + if (requestDescription.present) { + map['request_description'] = Variable(requestDescription.value); + } + if (deliveryCountry.present) { + map['delivery_country'] = Variable(deliveryCountry.value); + } + if (status.present) { + map['status'] = Variable( + $ShopInBitTicketsTable.$converterstatus.toSql(status.value), + ); + } + if (statusRaw.present) { + map['status_raw'] = Variable(statusRaw.value); + } + if (offerProductName.present) { + map['offer_product_name'] = Variable(offerProductName.value); + } + if (offerPrice.present) { + map['offer_price'] = Variable(offerPrice.value); + } + if (paymentInvoiceStatus.present) { + map['payment_invoice_status'] = Variable( + paymentInvoiceStatus.value, + ); + } + if (trackingLink.present) { + map['tracking_link'] = Variable(trackingLink.value); + } + if (lastAgentMessageAt.present) { + map['last_agent_message_at'] = Variable( + $ShopInBitTicketsTable.$converterlastAgentMessageAtn.toSql( + lastAgentMessageAt.value, + ), + ); + } + if (feeTicketNumber.present) { + map['fee_ticket_number'] = Variable(feeTicketNumber.value); + } + if (messages.present) { + map['messages'] = Variable( + $ShopInBitTicketsTable.$convertermessages.toSql(messages.value), + ); + } + if (createdAt.present) { + map['created_at'] = Variable( + $ShopInBitTicketsTable.$convertercreatedAt.toSql(createdAt.value), + ); + } + if (updatedAt.present) { + map['updated_at'] = Variable( + $ShopInBitTicketsTable.$converterupdatedAt.toSql(updatedAt.value), + ); + } + if (lastReadAt.present) { + map['last_read_at'] = Variable( + $ShopInBitTicketsTable.$converterlastReadAtn.toSql(lastReadAt.value), + ); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ShopInBitTicketsCompanion(') + ..write('apiTicketId: $apiTicketId, ') + ..write('customerKey: $customerKey, ') + ..write('ticketNumber: $ticketNumber, ') + ..write('category: $category, ') + ..write('requestDescription: $requestDescription, ') + ..write('deliveryCountry: $deliveryCountry, ') + ..write('status: $status, ') + ..write('statusRaw: $statusRaw, ') + ..write('offerProductName: $offerProductName, ') + ..write('offerPrice: $offerPrice, ') + ..write('paymentInvoiceStatus: $paymentInvoiceStatus, ') + ..write('trackingLink: $trackingLink, ') + ..write('lastAgentMessageAt: $lastAgentMessageAt, ') + ..write('feeTicketNumber: $feeTicketNumber, ') + ..write('messages: $messages, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('lastReadAt: $lastReadAt') + ..write(')')) + .toString(); + } +} + +class $AppNotificationsTable extends AppNotifications + with TableInfo<$AppNotificationsTable, AppNotification> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $AppNotificationsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + @override + late final GeneratedColumnWithTypeConverter + type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter($AppNotificationsTable.$convertertype); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _bodyMeta = const VerificationMeta('body'); + @override + late final GeneratedColumn body = GeneratedColumn( + 'body', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant(""), + ); + static const VerificationMeta _iconAssetMeta = const VerificationMeta( + 'iconAsset', + ); + @override + late final GeneratedColumn iconAsset = GeneratedColumn( + 'icon_asset', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter createdAt = + GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + clientDefault: () => + ShopInBitTickets.dateConverter.toSql(DateTime.now()), + ).withConverter($AppNotificationsTable.$convertercreatedAt); + static const VerificationMeta _readMeta = const VerificationMeta('read'); + @override + late final GeneratedColumn read = GeneratedColumn( + 'read', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("read" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _scopeIdMeta = const VerificationMeta( + 'scopeId', + ); + @override + late final GeneratedColumn scopeId = GeneratedColumn( + 'scope_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _targetIdMeta = const VerificationMeta( + 'targetId', + ); + @override + late final GeneratedColumn targetId = GeneratedColumn( + 'target_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + type, + title, + body, + iconAsset, + createdAt, + read, + scopeId, + targetId, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'app_notifications'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('body')) { + context.handle( + _bodyMeta, + body.isAcceptableOrUnknown(data['body']!, _bodyMeta), + ); + } + if (data.containsKey('icon_asset')) { + context.handle( + _iconAssetMeta, + iconAsset.isAcceptableOrUnknown(data['icon_asset']!, _iconAssetMeta), + ); + } + if (data.containsKey('read')) { + context.handle( + _readMeta, + read.isAcceptableOrUnknown(data['read']!, _readMeta), + ); + } + if (data.containsKey('scope_id')) { + context.handle( + _scopeIdMeta, + scopeId.isAcceptableOrUnknown(data['scope_id']!, _scopeIdMeta), + ); + } + if (data.containsKey('target_id')) { + context.handle( + _targetIdMeta, + targetId.isAcceptableOrUnknown(data['target_id']!, _targetIdMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + AppNotification map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AppNotification( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + type: $AppNotificationsTable.$convertertype.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}type'], + )!, + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + body: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}body'], + )!, + iconAsset: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}icon_asset'], + ), + createdAt: $AppNotificationsTable.$convertercreatedAt.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}created_at'], + )!, + ), + read: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}read'], + )!, + scopeId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}scope_id'], + ), + targetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}target_id'], + ), + ); + } + + @override + $AppNotificationsTable createAlias(String alias) { + return $AppNotificationsTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 + $convertertype = const EnumNameConverter( + AppNotificationType.values, + ); + static TypeConverter $convertercreatedAt = + ShopInBitTickets.dateConverter; +} + +class AppNotification extends DataClass implements Insertable { + final int id; + final AppNotificationType type; + final String title; + final String body; + final String? iconAsset; + final DateTime createdAt; + final bool read; + final String? scopeId; + final String? targetId; + const AppNotification({ + required this.id, + required this.type, + required this.title, + required this.body, + this.iconAsset, + required this.createdAt, + required this.read, + this.scopeId, + this.targetId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + { + map['type'] = Variable( + $AppNotificationsTable.$convertertype.toSql(type), + ); + } + map['title'] = Variable(title); + map['body'] = Variable(body); + if (!nullToAbsent || iconAsset != null) { + map['icon_asset'] = Variable(iconAsset); + } + { + map['created_at'] = Variable( + $AppNotificationsTable.$convertercreatedAt.toSql(createdAt), + ); + } + map['read'] = Variable(read); + if (!nullToAbsent || scopeId != null) { + map['scope_id'] = Variable(scopeId); + } + if (!nullToAbsent || targetId != null) { + map['target_id'] = Variable(targetId); + } + return map; + } + + AppNotificationsCompanion toCompanion(bool nullToAbsent) { + return AppNotificationsCompanion( + id: Value(id), + type: Value(type), + title: Value(title), + body: Value(body), + iconAsset: iconAsset == null && nullToAbsent + ? const Value.absent() + : Value(iconAsset), + createdAt: Value(createdAt), + read: Value(read), + scopeId: scopeId == null && nullToAbsent + ? const Value.absent() + : Value(scopeId), + targetId: targetId == null && nullToAbsent + ? const Value.absent() + : Value(targetId), + ); + } + + factory AppNotification.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AppNotification( + id: serializer.fromJson(json['id']), + type: $AppNotificationsTable.$convertertype.fromJson( + serializer.fromJson(json['type']), + ), + title: serializer.fromJson(json['title']), + body: serializer.fromJson(json['body']), + iconAsset: serializer.fromJson(json['iconAsset']), + createdAt: serializer.fromJson(json['createdAt']), + read: serializer.fromJson(json['read']), + scopeId: serializer.fromJson(json['scopeId']), + targetId: serializer.fromJson(json['targetId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'type': serializer.toJson( + $AppNotificationsTable.$convertertype.toJson(type), + ), + 'title': serializer.toJson(title), + 'body': serializer.toJson(body), + 'iconAsset': serializer.toJson(iconAsset), + 'createdAt': serializer.toJson(createdAt), + 'read': serializer.toJson(read), + 'scopeId': serializer.toJson(scopeId), + 'targetId': serializer.toJson(targetId), + }; + } + + AppNotification copyWith({ + int? id, + AppNotificationType? type, + String? title, + String? body, + Value iconAsset = const Value.absent(), + DateTime? createdAt, + bool? read, + Value scopeId = const Value.absent(), + Value targetId = const Value.absent(), + }) => AppNotification( + id: id ?? this.id, + type: type ?? this.type, + title: title ?? this.title, + body: body ?? this.body, + iconAsset: iconAsset.present ? iconAsset.value : this.iconAsset, + createdAt: createdAt ?? this.createdAt, + read: read ?? this.read, + scopeId: scopeId.present ? scopeId.value : this.scopeId, + targetId: targetId.present ? targetId.value : this.targetId, + ); + AppNotification copyWithCompanion(AppNotificationsCompanion data) { + return AppNotification( + id: data.id.present ? data.id.value : this.id, + type: data.type.present ? data.type.value : this.type, + title: data.title.present ? data.title.value : this.title, + body: data.body.present ? data.body.value : this.body, + iconAsset: data.iconAsset.present ? data.iconAsset.value : this.iconAsset, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + read: data.read.present ? data.read.value : this.read, + scopeId: data.scopeId.present ? data.scopeId.value : this.scopeId, + targetId: data.targetId.present ? data.targetId.value : this.targetId, + ); + } + + @override + String toString() { + return (StringBuffer('AppNotification(') + ..write('id: $id, ') + ..write('type: $type, ') + ..write('title: $title, ') + ..write('body: $body, ') + ..write('iconAsset: $iconAsset, ') + ..write('createdAt: $createdAt, ') + ..write('read: $read, ') + ..write('scopeId: $scopeId, ') + ..write('targetId: $targetId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + type, + title, + body, + iconAsset, + createdAt, + read, + scopeId, + targetId, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AppNotification && + other.id == this.id && + other.type == this.type && + other.title == this.title && + other.body == this.body && + other.iconAsset == this.iconAsset && + other.createdAt == this.createdAt && + other.read == this.read && + other.scopeId == this.scopeId && + other.targetId == this.targetId); +} + +class AppNotificationsCompanion extends UpdateCompanion { + final Value id; + final Value type; + final Value title; + final Value body; + final Value iconAsset; + final Value createdAt; + final Value read; + final Value scopeId; + final Value targetId; + const AppNotificationsCompanion({ + this.id = const Value.absent(), + this.type = const Value.absent(), + this.title = const Value.absent(), + this.body = const Value.absent(), + this.iconAsset = const Value.absent(), + this.createdAt = const Value.absent(), + this.read = const Value.absent(), + this.scopeId = const Value.absent(), + this.targetId = const Value.absent(), + }); + AppNotificationsCompanion.insert({ + this.id = const Value.absent(), + required AppNotificationType type, + required String title, + this.body = const Value.absent(), + this.iconAsset = const Value.absent(), + this.createdAt = const Value.absent(), + this.read = const Value.absent(), + this.scopeId = const Value.absent(), + this.targetId = const Value.absent(), + }) : type = Value(type), + title = Value(title); + static Insertable custom({ + Expression? id, + Expression? type, + Expression? title, + Expression? body, + Expression? iconAsset, + Expression? createdAt, + Expression? read, + Expression? scopeId, + Expression? targetId, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (type != null) 'type': type, + if (title != null) 'title': title, + if (body != null) 'body': body, + if (iconAsset != null) 'icon_asset': iconAsset, + if (createdAt != null) 'created_at': createdAt, + if (read != null) 'read': read, + if (scopeId != null) 'scope_id': scopeId, + if (targetId != null) 'target_id': targetId, + }); + } + + AppNotificationsCompanion copyWith({ + Value? id, + Value? type, + Value? title, + Value? body, + Value? iconAsset, + Value? createdAt, + Value? read, + Value? scopeId, + Value? targetId, + }) { + return AppNotificationsCompanion( + id: id ?? this.id, + type: type ?? this.type, + title: title ?? this.title, + body: body ?? this.body, + iconAsset: iconAsset ?? this.iconAsset, + createdAt: createdAt ?? this.createdAt, + read: read ?? this.read, + scopeId: scopeId ?? this.scopeId, + targetId: targetId ?? this.targetId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (type.present) { + map['type'] = Variable( + $AppNotificationsTable.$convertertype.toSql(type.value), + ); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (body.present) { + map['body'] = Variable(body.value); + } + if (iconAsset.present) { + map['icon_asset'] = Variable(iconAsset.value); + } + if (createdAt.present) { + map['created_at'] = Variable( + $AppNotificationsTable.$convertercreatedAt.toSql(createdAt.value), + ); + } + if (read.present) { + map['read'] = Variable(read.value); + } + if (scopeId.present) { + map['scope_id'] = Variable(scopeId.value); + } + if (targetId.present) { + map['target_id'] = Variable(targetId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AppNotificationsCompanion(') + ..write('id: $id, ') + ..write('type: $type, ') + ..write('title: $title, ') + ..write('body: $body, ') + ..write('iconAsset: $iconAsset, ') + ..write('createdAt: $createdAt, ') + ..write('read: $read, ') + ..write('scopeId: $scopeId, ') + ..write('targetId: $targetId') + ..write(')')) + .toString(); + } +} + +abstract class _$SharedDatabase extends GeneratedDatabase { + _$SharedDatabase(QueryExecutor e) : super(e); + $SharedDatabaseManager get managers => $SharedDatabaseManager(this); + late final $CakepayOrdersTable cakepayOrders = $CakepayOrdersTable(this); + late final $ShopInBitSettingsTable shopInBitSettings = + $ShopInBitSettingsTable(this); + late final $ShopInBitTicketsTable shopInBitTickets = $ShopInBitTicketsTable( + this, + ); + late final $AppNotificationsTable appNotifications = $AppNotificationsTable( + this, + ); + late final Index appNotificationsScope = Index( + 'app_notifications_scope', + 'CREATE INDEX app_notifications_scope ON app_notifications (type, scope_id, read)', + ); + late final Index appNotificationsTarget = Index( + 'app_notifications_target', + 'CREATE INDEX app_notifications_target ON app_notifications (type, target_id)', + ); + late final ShopInBitSettingsDao shopInBitSettingsDao = ShopInBitSettingsDao( + this as SharedDatabase, + ); + late final ShopInBitTicketsDao shopInBitTicketsDao = ShopInBitTicketsDao( + this as SharedDatabase, + ); + late final AppNotificationsDao appNotificationsDao = AppNotificationsDao( + this as SharedDatabase, + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + cakepayOrders, + shopInBitSettings, + shopInBitTickets, + appNotifications, + appNotificationsScope, + appNotificationsTarget, + ]; +} + +typedef $$CakepayOrdersTableCreateCompanionBuilder = + CakepayOrdersCompanion Function({ + required String orderId, + Value rowid, + }); +typedef $$CakepayOrdersTableUpdateCompanionBuilder = + CakepayOrdersCompanion Function({Value orderId, Value rowid}); + +class $$CakepayOrdersTableFilterComposer + extends Composer<_$SharedDatabase, $CakepayOrdersTable> { + $$CakepayOrdersTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get orderId => $composableBuilder( + column: $table.orderId, + builder: (column) => ColumnFilters(column), + ); +} + +class $$CakepayOrdersTableOrderingComposer + extends Composer<_$SharedDatabase, $CakepayOrdersTable> { + $$CakepayOrdersTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get orderId => $composableBuilder( + column: $table.orderId, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$CakepayOrdersTableAnnotationComposer + extends Composer<_$SharedDatabase, $CakepayOrdersTable> { + $$CakepayOrdersTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get orderId => + $composableBuilder(column: $table.orderId, builder: (column) => column); +} + +class $$CakepayOrdersTableTableManager + extends + RootTableManager< + _$SharedDatabase, + $CakepayOrdersTable, + CakepayOrder, + $$CakepayOrdersTableFilterComposer, + $$CakepayOrdersTableOrderingComposer, + $$CakepayOrdersTableAnnotationComposer, + $$CakepayOrdersTableCreateCompanionBuilder, + $$CakepayOrdersTableUpdateCompanionBuilder, + ( + CakepayOrder, + BaseReferences<_$SharedDatabase, $CakepayOrdersTable, CakepayOrder>, + ), + CakepayOrder, + PrefetchHooks Function() + > { + $$CakepayOrdersTableTableManager( + _$SharedDatabase db, + $CakepayOrdersTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$CakepayOrdersTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$CakepayOrdersTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$CakepayOrdersTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value orderId = const Value.absent(), + Value rowid = const Value.absent(), + }) => CakepayOrdersCompanion(orderId: orderId, rowid: rowid), + createCompanionCallback: + ({ + required String orderId, + Value rowid = const Value.absent(), + }) => + CakepayOrdersCompanion.insert(orderId: orderId, rowid: rowid), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$CakepayOrdersTableProcessedTableManager = + ProcessedTableManager< + _$SharedDatabase, + $CakepayOrdersTable, + CakepayOrder, + $$CakepayOrdersTableFilterComposer, + $$CakepayOrdersTableOrderingComposer, + $$CakepayOrdersTableAnnotationComposer, + $$CakepayOrdersTableCreateCompanionBuilder, + $$CakepayOrdersTableUpdateCompanionBuilder, + ( + CakepayOrder, + BaseReferences<_$SharedDatabase, $CakepayOrdersTable, CakepayOrder>, + ), + CakepayOrder, + PrefetchHooks Function() + >; +typedef $$ShopInBitSettingsTableCreateCompanionBuilder = + ShopInBitSettingsCompanion Function({ + required String customerKey, + Value privacyAccepted, + Value conciergeGuidelinesAccepted, + Value travelGuidelinesAccepted, + Value carGuidelinesAccepted, + Value setupComplete, + Value createdAt, + Value lastUsedAt, + }); +typedef $$ShopInBitSettingsTableUpdateCompanionBuilder = + ShopInBitSettingsCompanion Function({ + Value customerKey, + Value privacyAccepted, + Value conciergeGuidelinesAccepted, + Value travelGuidelinesAccepted, + Value carGuidelinesAccepted, + Value setupComplete, + Value createdAt, + Value lastUsedAt, + }); + +class $$ShopInBitSettingsTableFilterComposer + extends Composer<_$SharedDatabase, $ShopInBitSettingsTable> { + $$ShopInBitSettingsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get customerKey => $composableBuilder( + column: $table.customerKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get privacyAccepted => $composableBuilder( + column: $table.privacyAccepted, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get conciergeGuidelinesAccepted => $composableBuilder( + column: $table.conciergeGuidelinesAccepted, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get travelGuidelinesAccepted => $composableBuilder( + column: $table.travelGuidelinesAccepted, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get carGuidelinesAccepted => $composableBuilder( + column: $table.carGuidelinesAccepted, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get setupComplete => $composableBuilder( + column: $table.setupComplete, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastUsedAt => $composableBuilder( + column: $table.lastUsedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ShopInBitSettingsTableOrderingComposer + extends Composer<_$SharedDatabase, $ShopInBitSettingsTable> { + $$ShopInBitSettingsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get customerKey => $composableBuilder( + column: $table.customerKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get privacyAccepted => $composableBuilder( + column: $table.privacyAccepted, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get conciergeGuidelinesAccepted => $composableBuilder( + column: $table.conciergeGuidelinesAccepted, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get travelGuidelinesAccepted => $composableBuilder( + column: $table.travelGuidelinesAccepted, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get carGuidelinesAccepted => $composableBuilder( + column: $table.carGuidelinesAccepted, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get setupComplete => $composableBuilder( + column: $table.setupComplete, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastUsedAt => $composableBuilder( + column: $table.lastUsedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ShopInBitSettingsTableAnnotationComposer + extends Composer<_$SharedDatabase, $ShopInBitSettingsTable> { + $$ShopInBitSettingsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get customerKey => $composableBuilder( + column: $table.customerKey, + builder: (column) => column, + ); + + GeneratedColumn get privacyAccepted => $composableBuilder( + column: $table.privacyAccepted, + builder: (column) => column, + ); + + GeneratedColumn get conciergeGuidelinesAccepted => $composableBuilder( + column: $table.conciergeGuidelinesAccepted, + builder: (column) => column, + ); + + GeneratedColumn get travelGuidelinesAccepted => $composableBuilder( + column: $table.travelGuidelinesAccepted, + builder: (column) => column, + ); + + GeneratedColumn get carGuidelinesAccepted => $composableBuilder( + column: $table.carGuidelinesAccepted, + builder: (column) => column, + ); + + GeneratedColumn get setupComplete => $composableBuilder( + column: $table.setupComplete, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get lastUsedAt => $composableBuilder( + column: $table.lastUsedAt, + builder: (column) => column, + ); +} + +class $$ShopInBitSettingsTableTableManager + extends + RootTableManager< + _$SharedDatabase, + $ShopInBitSettingsTable, + ShopInBitSetting, + $$ShopInBitSettingsTableFilterComposer, + $$ShopInBitSettingsTableOrderingComposer, + $$ShopInBitSettingsTableAnnotationComposer, + $$ShopInBitSettingsTableCreateCompanionBuilder, + $$ShopInBitSettingsTableUpdateCompanionBuilder, + ( + ShopInBitSetting, + BaseReferences< + _$SharedDatabase, + $ShopInBitSettingsTable, + ShopInBitSetting + >, + ), + ShopInBitSetting, + PrefetchHooks Function() + > { + $$ShopInBitSettingsTableTableManager( + _$SharedDatabase db, + $ShopInBitSettingsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ShopInBitSettingsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ShopInBitSettingsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ShopInBitSettingsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value customerKey = const Value.absent(), + Value privacyAccepted = const Value.absent(), + Value conciergeGuidelinesAccepted = const Value.absent(), + Value travelGuidelinesAccepted = const Value.absent(), + Value carGuidelinesAccepted = const Value.absent(), + Value setupComplete = const Value.absent(), + Value createdAt = const Value.absent(), + Value lastUsedAt = const Value.absent(), + }) => ShopInBitSettingsCompanion( + customerKey: customerKey, + privacyAccepted: privacyAccepted, + conciergeGuidelinesAccepted: conciergeGuidelinesAccepted, + travelGuidelinesAccepted: travelGuidelinesAccepted, + carGuidelinesAccepted: carGuidelinesAccepted, + setupComplete: setupComplete, + createdAt: createdAt, + lastUsedAt: lastUsedAt, + ), + createCompanionCallback: + ({ + required String customerKey, + Value privacyAccepted = const Value.absent(), + Value conciergeGuidelinesAccepted = const Value.absent(), + Value travelGuidelinesAccepted = const Value.absent(), + Value carGuidelinesAccepted = const Value.absent(), + Value setupComplete = const Value.absent(), + Value createdAt = const Value.absent(), + Value lastUsedAt = const Value.absent(), + }) => ShopInBitSettingsCompanion.insert( + customerKey: customerKey, + privacyAccepted: privacyAccepted, + conciergeGuidelinesAccepted: conciergeGuidelinesAccepted, + travelGuidelinesAccepted: travelGuidelinesAccepted, + carGuidelinesAccepted: carGuidelinesAccepted, + setupComplete: setupComplete, + createdAt: createdAt, + lastUsedAt: lastUsedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ShopInBitSettingsTableProcessedTableManager = + ProcessedTableManager< + _$SharedDatabase, + $ShopInBitSettingsTable, + ShopInBitSetting, + $$ShopInBitSettingsTableFilterComposer, + $$ShopInBitSettingsTableOrderingComposer, + $$ShopInBitSettingsTableAnnotationComposer, + $$ShopInBitSettingsTableCreateCompanionBuilder, + $$ShopInBitSettingsTableUpdateCompanionBuilder, + ( + ShopInBitSetting, + BaseReferences< + _$SharedDatabase, + $ShopInBitSettingsTable, + ShopInBitSetting + >, + ), + ShopInBitSetting, + PrefetchHooks Function() + >; +typedef $$ShopInBitTicketsTableCreateCompanionBuilder = + ShopInBitTicketsCompanion Function({ + required int apiTicketId, + required String customerKey, + required String ticketNumber, + required ShopInBitCategory category, + required String requestDescription, + required String deliveryCountry, + required ShopInBitOrderStatus status, + required String statusRaw, + Value offerProductName, + Value offerPrice, + Value paymentInvoiceStatus, + Value trackingLink, + Value lastAgentMessageAt, + Value feeTicketNumber, + Value> messages, + Value createdAt, + Value updatedAt, + Value lastReadAt, + }); +typedef $$ShopInBitTicketsTableUpdateCompanionBuilder = + ShopInBitTicketsCompanion Function({ + Value apiTicketId, + Value customerKey, + Value ticketNumber, + Value category, + Value requestDescription, + Value deliveryCountry, + Value status, + Value statusRaw, + Value offerProductName, + Value offerPrice, + Value paymentInvoiceStatus, + Value trackingLink, + Value lastAgentMessageAt, + Value feeTicketNumber, + Value> messages, + Value createdAt, + Value updatedAt, + Value lastReadAt, + }); + +class $$ShopInBitTicketsTableFilterComposer + extends Composer<_$SharedDatabase, $ShopInBitTicketsTable> { + $$ShopInBitTicketsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get apiTicketId => $composableBuilder( + column: $table.apiTicketId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get customerKey => $composableBuilder( + column: $table.customerKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get ticketNumber => $composableBuilder( + column: $table.ticketNumber, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters + get category => $composableBuilder( + column: $table.category, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get requestDescription => $composableBuilder( + column: $table.requestDescription, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deliveryCountry => $composableBuilder( + column: $table.deliveryCountry, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters< + ShopInBitOrderStatus, + ShopInBitOrderStatus, + String + > + get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get statusRaw => $composableBuilder( + column: $table.statusRaw, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get offerProductName => $composableBuilder( + column: $table.offerProductName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get offerPrice => $composableBuilder( + column: $table.offerPrice, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get paymentInvoiceStatus => $composableBuilder( + column: $table.paymentInvoiceStatus, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get trackingLink => $composableBuilder( + column: $table.trackingLink, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters + get lastAgentMessageAt => $composableBuilder( + column: $table.lastAgentMessageAt, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get feeTicketNumber => $composableBuilder( + column: $table.feeTicketNumber, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters< + List, + List, + String + > + get messages => $composableBuilder( + column: $table.messages, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters get createdAt => + $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters get updatedAt => + $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters get lastReadAt => + $composableBuilder( + column: $table.lastReadAt, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); +} + +class $$ShopInBitTicketsTableOrderingComposer + extends Composer<_$SharedDatabase, $ShopInBitTicketsTable> { + $$ShopInBitTicketsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get apiTicketId => $composableBuilder( + column: $table.apiTicketId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get customerKey => $composableBuilder( + column: $table.customerKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get ticketNumber => $composableBuilder( + column: $table.ticketNumber, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get category => $composableBuilder( + column: $table.category, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get requestDescription => $composableBuilder( + column: $table.requestDescription, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get deliveryCountry => $composableBuilder( + column: $table.deliveryCountry, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get statusRaw => $composableBuilder( + column: $table.statusRaw, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get offerProductName => $composableBuilder( + column: $table.offerProductName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get offerPrice => $composableBuilder( + column: $table.offerPrice, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get paymentInvoiceStatus => $composableBuilder( + column: $table.paymentInvoiceStatus, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get trackingLink => $composableBuilder( + column: $table.trackingLink, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastAgentMessageAt => $composableBuilder( + column: $table.lastAgentMessageAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get feeTicketNumber => $composableBuilder( + column: $table.feeTicketNumber, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get messages => $composableBuilder( + column: $table.messages, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastReadAt => $composableBuilder( + column: $table.lastReadAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ShopInBitTicketsTableAnnotationComposer + extends Composer<_$SharedDatabase, $ShopInBitTicketsTable> { + $$ShopInBitTicketsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get apiTicketId => $composableBuilder( + column: $table.apiTicketId, + builder: (column) => column, + ); + + GeneratedColumn get customerKey => $composableBuilder( + column: $table.customerKey, + builder: (column) => column, + ); + + GeneratedColumn get ticketNumber => $composableBuilder( + column: $table.ticketNumber, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter get category => + $composableBuilder(column: $table.category, builder: (column) => column); + + GeneratedColumn get requestDescription => $composableBuilder( + column: $table.requestDescription, + builder: (column) => column, + ); + + GeneratedColumn get deliveryCountry => $composableBuilder( + column: $table.deliveryCountry, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get statusRaw => + $composableBuilder(column: $table.statusRaw, builder: (column) => column); + + GeneratedColumn get offerProductName => $composableBuilder( + column: $table.offerProductName, + builder: (column) => column, + ); + + GeneratedColumn get offerPrice => $composableBuilder( + column: $table.offerPrice, + builder: (column) => column, + ); + + GeneratedColumn get paymentInvoiceStatus => $composableBuilder( + column: $table.paymentInvoiceStatus, + builder: (column) => column, + ); + + GeneratedColumn get trackingLink => $composableBuilder( + column: $table.trackingLink, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter get lastAgentMessageAt => + $composableBuilder( + column: $table.lastAgentMessageAt, + builder: (column) => column, + ); + + GeneratedColumn get feeTicketNumber => $composableBuilder( + column: $table.feeTicketNumber, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter, String> get messages => + $composableBuilder(column: $table.messages, builder: (column) => column); + + GeneratedColumnWithTypeConverter get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumnWithTypeConverter get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumnWithTypeConverter get lastReadAt => + $composableBuilder( + column: $table.lastReadAt, + builder: (column) => column, + ); +} + +class $$ShopInBitTicketsTableTableManager + extends + RootTableManager< + _$SharedDatabase, + $ShopInBitTicketsTable, + ShopInBitTicket, + $$ShopInBitTicketsTableFilterComposer, + $$ShopInBitTicketsTableOrderingComposer, + $$ShopInBitTicketsTableAnnotationComposer, + $$ShopInBitTicketsTableCreateCompanionBuilder, + $$ShopInBitTicketsTableUpdateCompanionBuilder, + ( + ShopInBitTicket, + BaseReferences< + _$SharedDatabase, + $ShopInBitTicketsTable, + ShopInBitTicket + >, + ), + ShopInBitTicket, + PrefetchHooks Function() + > { + $$ShopInBitTicketsTableTableManager( + _$SharedDatabase db, + $ShopInBitTicketsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ShopInBitTicketsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ShopInBitTicketsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ShopInBitTicketsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value apiTicketId = const Value.absent(), + Value customerKey = const Value.absent(), + Value ticketNumber = const Value.absent(), + Value category = const Value.absent(), + Value requestDescription = const Value.absent(), + Value deliveryCountry = const Value.absent(), + Value status = const Value.absent(), + Value statusRaw = const Value.absent(), + Value offerProductName = const Value.absent(), + Value offerPrice = const Value.absent(), + Value paymentInvoiceStatus = const Value.absent(), + Value trackingLink = const Value.absent(), + Value lastAgentMessageAt = const Value.absent(), + Value feeTicketNumber = const Value.absent(), + Value> messages = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value lastReadAt = const Value.absent(), + }) => ShopInBitTicketsCompanion( + apiTicketId: apiTicketId, + customerKey: customerKey, + ticketNumber: ticketNumber, + category: category, + requestDescription: requestDescription, + deliveryCountry: deliveryCountry, + status: status, + statusRaw: statusRaw, + offerProductName: offerProductName, + offerPrice: offerPrice, + paymentInvoiceStatus: paymentInvoiceStatus, + trackingLink: trackingLink, + lastAgentMessageAt: lastAgentMessageAt, + feeTicketNumber: feeTicketNumber, + messages: messages, + createdAt: createdAt, + updatedAt: updatedAt, + lastReadAt: lastReadAt, + ), + createCompanionCallback: + ({ + required int apiTicketId, + required String customerKey, + required String ticketNumber, + required ShopInBitCategory category, + required String requestDescription, + required String deliveryCountry, + required ShopInBitOrderStatus status, + required String statusRaw, + Value offerProductName = const Value.absent(), + Value offerPrice = const Value.absent(), + Value paymentInvoiceStatus = const Value.absent(), + Value trackingLink = const Value.absent(), + Value lastAgentMessageAt = const Value.absent(), + Value feeTicketNumber = const Value.absent(), + Value> messages = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value lastReadAt = const Value.absent(), + }) => ShopInBitTicketsCompanion.insert( + apiTicketId: apiTicketId, + customerKey: customerKey, + ticketNumber: ticketNumber, + category: category, + requestDescription: requestDescription, + deliveryCountry: deliveryCountry, + status: status, + statusRaw: statusRaw, + offerProductName: offerProductName, + offerPrice: offerPrice, + paymentInvoiceStatus: paymentInvoiceStatus, + trackingLink: trackingLink, + lastAgentMessageAt: lastAgentMessageAt, + feeTicketNumber: feeTicketNumber, + messages: messages, + createdAt: createdAt, + updatedAt: updatedAt, + lastReadAt: lastReadAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ShopInBitTicketsTableProcessedTableManager = + ProcessedTableManager< + _$SharedDatabase, + $ShopInBitTicketsTable, + ShopInBitTicket, + $$ShopInBitTicketsTableFilterComposer, + $$ShopInBitTicketsTableOrderingComposer, + $$ShopInBitTicketsTableAnnotationComposer, + $$ShopInBitTicketsTableCreateCompanionBuilder, + $$ShopInBitTicketsTableUpdateCompanionBuilder, + ( + ShopInBitTicket, + BaseReferences< + _$SharedDatabase, + $ShopInBitTicketsTable, + ShopInBitTicket + >, + ), + ShopInBitTicket, + PrefetchHooks Function() + >; +typedef $$AppNotificationsTableCreateCompanionBuilder = + AppNotificationsCompanion Function({ + Value id, + required AppNotificationType type, + required String title, + Value body, + Value iconAsset, + Value createdAt, + Value read, + Value scopeId, + Value targetId, + }); +typedef $$AppNotificationsTableUpdateCompanionBuilder = + AppNotificationsCompanion Function({ + Value id, + Value type, + Value title, + Value body, + Value iconAsset, + Value createdAt, + Value read, + Value scopeId, + Value targetId, + }); + +class $$AppNotificationsTableFilterComposer + extends Composer<_$SharedDatabase, $AppNotificationsTable> { + $$AppNotificationsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters< + AppNotificationType, + AppNotificationType, + String + > + get type => $composableBuilder( + column: $table.type, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get body => $composableBuilder( + column: $table.body, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get iconAsset => $composableBuilder( + column: $table.iconAsset, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters get createdAt => + $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get read => $composableBuilder( + column: $table.read, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get scopeId => $composableBuilder( + column: $table.scopeId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get targetId => $composableBuilder( + column: $table.targetId, + builder: (column) => ColumnFilters(column), + ); +} + +class $$AppNotificationsTableOrderingComposer + extends Composer<_$SharedDatabase, $AppNotificationsTable> { + $$AppNotificationsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get type => $composableBuilder( + column: $table.type, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get body => $composableBuilder( + column: $table.body, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get iconAsset => $composableBuilder( + column: $table.iconAsset, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get read => $composableBuilder( + column: $table.read, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get scopeId => $composableBuilder( + column: $table.scopeId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get targetId => $composableBuilder( + column: $table.targetId, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$AppNotificationsTableAnnotationComposer + extends Composer<_$SharedDatabase, $AppNotificationsTable> { + $$AppNotificationsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumnWithTypeConverter get type => + $composableBuilder(column: $table.type, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get body => + $composableBuilder(column: $table.body, builder: (column) => column); + + GeneratedColumn get iconAsset => + $composableBuilder(column: $table.iconAsset, builder: (column) => column); + + GeneratedColumnWithTypeConverter get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get read => + $composableBuilder(column: $table.read, builder: (column) => column); + + GeneratedColumn get scopeId => + $composableBuilder(column: $table.scopeId, builder: (column) => column); + + GeneratedColumn get targetId => + $composableBuilder(column: $table.targetId, builder: (column) => column); +} + +class $$AppNotificationsTableTableManager + extends + RootTableManager< + _$SharedDatabase, + $AppNotificationsTable, + AppNotification, + $$AppNotificationsTableFilterComposer, + $$AppNotificationsTableOrderingComposer, + $$AppNotificationsTableAnnotationComposer, + $$AppNotificationsTableCreateCompanionBuilder, + $$AppNotificationsTableUpdateCompanionBuilder, + ( + AppNotification, + BaseReferences< + _$SharedDatabase, + $AppNotificationsTable, + AppNotification + >, + ), + AppNotification, + PrefetchHooks Function() + > { + $$AppNotificationsTableTableManager( + _$SharedDatabase db, + $AppNotificationsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$AppNotificationsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$AppNotificationsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$AppNotificationsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value type = const Value.absent(), + Value title = const Value.absent(), + Value body = const Value.absent(), + Value iconAsset = const Value.absent(), + Value createdAt = const Value.absent(), + Value read = const Value.absent(), + Value scopeId = const Value.absent(), + Value targetId = const Value.absent(), + }) => AppNotificationsCompanion( + id: id, + type: type, + title: title, + body: body, + iconAsset: iconAsset, + createdAt: createdAt, + read: read, + scopeId: scopeId, + targetId: targetId, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required AppNotificationType type, + required String title, + Value body = const Value.absent(), + Value iconAsset = const Value.absent(), + Value createdAt = const Value.absent(), + Value read = const Value.absent(), + Value scopeId = const Value.absent(), + Value targetId = const Value.absent(), + }) => AppNotificationsCompanion.insert( + id: id, + type: type, + title: title, + body: body, + iconAsset: iconAsset, + createdAt: createdAt, + read: read, + scopeId: scopeId, + targetId: targetId, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$AppNotificationsTableProcessedTableManager = + ProcessedTableManager< + _$SharedDatabase, + $AppNotificationsTable, + AppNotification, + $$AppNotificationsTableFilterComposer, + $$AppNotificationsTableOrderingComposer, + $$AppNotificationsTableAnnotationComposer, + $$AppNotificationsTableCreateCompanionBuilder, + $$AppNotificationsTableUpdateCompanionBuilder, + ( + AppNotification, + BaseReferences< + _$SharedDatabase, + $AppNotificationsTable, + AppNotification + >, + ), + AppNotification, + PrefetchHooks Function() + >; + +class $SharedDatabaseManager { + final _$SharedDatabase _db; + $SharedDatabaseManager(this._db); + $$CakepayOrdersTableTableManager get cakepayOrders => + $$CakepayOrdersTableTableManager(_db, _db.cakepayOrders); + $$ShopInBitSettingsTableTableManager get shopInBitSettings => + $$ShopInBitSettingsTableTableManager(_db, _db.shopInBitSettings); + $$ShopInBitTicketsTableTableManager get shopInBitTickets => + $$ShopInBitTicketsTableTableManager(_db, _db.shopInBitTickets); + $$AppNotificationsTableTableManager get appNotifications => + $$AppNotificationsTableTableManager(_db, _db.appNotifications); +} + +mixin _$ShopInBitSettingsDaoMixin on DatabaseAccessor { + $ShopInBitSettingsTable get shopInBitSettings => + attachedDatabase.shopInBitSettings; + ShopInBitSettingsDaoManager get managers => ShopInBitSettingsDaoManager(this); +} + +class ShopInBitSettingsDaoManager { + final _$ShopInBitSettingsDaoMixin _db; + ShopInBitSettingsDaoManager(this._db); + $$ShopInBitSettingsTableTableManager get shopInBitSettings => + $$ShopInBitSettingsTableTableManager( + _db.attachedDatabase, + _db.shopInBitSettings, + ); +} + +mixin _$ShopInBitTicketsDaoMixin on DatabaseAccessor { + $ShopInBitTicketsTable get shopInBitTickets => + attachedDatabase.shopInBitTickets; + ShopInBitTicketsDaoManager get managers => ShopInBitTicketsDaoManager(this); +} + +class ShopInBitTicketsDaoManager { + final _$ShopInBitTicketsDaoMixin _db; + ShopInBitTicketsDaoManager(this._db); + $$ShopInBitTicketsTableTableManager get shopInBitTickets => + $$ShopInBitTicketsTableTableManager( + _db.attachedDatabase, + _db.shopInBitTickets, + ); +} + +mixin _$AppNotificationsDaoMixin on DatabaseAccessor { + $AppNotificationsTable get appNotifications => + attachedDatabase.appNotifications; + AppNotificationsDaoManager get managers => AppNotificationsDaoManager(this); +} + +class AppNotificationsDaoManager { + final _$AppNotificationsDaoMixin _db; + AppNotificationsDaoManager(this._db); + $$AppNotificationsTableTableManager get appNotifications => + $$AppNotificationsTableTableManager( + _db.attachedDatabase, + _db.appNotifications, + ); +} diff --git a/lib/db/drift/shared_db/tables/cakepay_orders.dart b/lib/db/drift/shared_db/tables/cakepay_orders.dart new file mode 100644 index 0000000000..8dc7f82e62 --- /dev/null +++ b/lib/db/drift/shared_db/tables/cakepay_orders.dart @@ -0,0 +1,8 @@ +import 'package:drift/drift.dart'; + +class CakepayOrders extends Table { + TextColumn get orderId => text()(); + + @override + Set get primaryKey => {orderId}; +} diff --git a/lib/db/drift/shared_db/tables/notifications.dart b/lib/db/drift/shared_db/tables/notifications.dart new file mode 100644 index 0000000000..fc522dbcf2 --- /dev/null +++ b/lib/db/drift/shared_db/tables/notifications.dart @@ -0,0 +1,26 @@ +import "package:drift/drift.dart"; + +import "shopin_bit_tickets.dart"; + +enum AppNotificationType { shopinbit } + +@TableIndex(name: "app_notifications_scope", columns: {#type, #scopeId, #read}) +@TableIndex(name: "app_notifications_target", columns: {#type, #targetId}) +class AppNotifications extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get type => textEnum()(); + + TextColumn get title => text()(); + TextColumn get body => text().withDefault(const Constant(""))(); + TextColumn get iconAsset => text().nullable()(); + + TextColumn get createdAt => text() + .map(ShopInBitTickets.dateConverter) + .clientDefault( + () => ShopInBitTickets.dateConverter.toSql(DateTime.now()), + )(); + BoolColumn get read => boolean().withDefault(const Constant(false))(); + + TextColumn get scopeId => text().nullable()(); + TextColumn get targetId => text().nullable()(); +} diff --git a/lib/db/drift/shared_db/tables/shopin_bit_settings.dart b/lib/db/drift/shared_db/tables/shopin_bit_settings.dart new file mode 100644 index 0000000000..4438f90199 --- /dev/null +++ b/lib/db/drift/shared_db/tables/shopin_bit_settings.dart @@ -0,0 +1,30 @@ +import "package:drift/drift.dart"; + +/// One row per ShopinBit customer key the user has ever generated or +/// recovered. Whichever row has the most recent `lastUsedAt` is the +/// "current" key — see `ShopInBitSettingsDao.getCurrentSettings`. +class ShopInBitSettings extends Table { + TextColumn get customerKey => text()(); + + BoolColumn get privacyAccepted => + boolean().withDefault(const Constant(false))(); + + BoolColumn get conciergeGuidelinesAccepted => + boolean().withDefault(const Constant(false))(); + BoolColumn get travelGuidelinesAccepted => + boolean().withDefault(const Constant(false))(); + BoolColumn get carGuidelinesAccepted => + boolean().withDefault(const Constant(false))(); + + BoolColumn get setupComplete => + boolean().withDefault(const Constant(false))(); + + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + DateTimeColumn get lastUsedAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set> get primaryKey => {customerKey}; + + @override + bool get withoutRowId => true; +} diff --git a/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart b/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart new file mode 100644 index 0000000000..7292bc7543 --- /dev/null +++ b/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart @@ -0,0 +1,99 @@ +import "dart:convert"; + +import "package:drift/drift.dart"; + +import "../../../../models/shopinbit/shopinbit_enums.dart"; +import "../../../../services/shopinbit/src/models/message.dart"; +import "../../../../utilities/logger.dart"; + +class ShopInBitTickets extends Table { + static const dateConverter = Iso8601UtcConverter(); + + IntColumn get apiTicketId => integer()(); + TextColumn get customerKey => text()(); + TextColumn get ticketNumber => text()(); + + TextColumn get category => textEnum()(); + TextColumn get requestDescription => text()(); + TextColumn get deliveryCountry => text()(); + + TextColumn get status => textEnum()(); + TextColumn get statusRaw => text()(); + + TextColumn get offerProductName => text().nullable()(); + TextColumn get offerPrice => text().nullable()(); + + TextColumn get paymentInvoiceStatus => text().nullable()(); + TextColumn get trackingLink => text().nullable()(); + TextColumn get lastAgentMessageAt => + text().nullable().map(ShopInBitTickets.dateConverter)(); + + TextColumn get feeTicketNumber => text().nullable()(); + + TextColumn get messages => + text().map(const MessagesConverter()).withDefault(const Constant("[]"))(); + + TextColumn get createdAt => text() + .map(ShopInBitTickets.dateConverter) + .clientDefault( + () => ShopInBitTickets.dateConverter.toSql(DateTime.now()), + )(); + TextColumn get updatedAt => text() + .map(ShopInBitTickets.dateConverter) + .clientDefault( + () => ShopInBitTickets.dateConverter.toSql(DateTime.now()), + )(); + + TextColumn get lastReadAt => + text().nullable().map(ShopInBitTickets.dateConverter)(); + + @override + Set> get primaryKey => {apiTicketId}; + + @override + bool get withoutRowId => true; +} + +class Iso8601UtcConverter extends TypeConverter { + const Iso8601UtcConverter(); + + @override + DateTime fromSql(String fromDb) => DateTime.parse(fromDb).toUtc(); + + @override + String toSql(DateTime value) => DateTime.fromMillisecondsSinceEpoch( + value.toUtc().millisecondsSinceEpoch, + isUtc: true, + ).toIso8601String(); +} + +/// Drift TypeConverter so `messages` round-trips between a JSON column and +/// `List` on the generated data class. +class MessagesConverter extends TypeConverter, String> { + const MessagesConverter(); + + @override + List fromSql(String fromDb) { + final List raw = jsonDecode(fromDb) as List; + // Skip any message that fails to parse rather than dropping the whole + // conversation; mirrors the tolerant parse on the network side. + final messages = []; + for (final e in raw) { + try { + messages.add(TicketMessage.fromJson(e as Map)); + } catch (err, s) { + Logging.instance.w( + "MessagesConverter skipping malformed message", + error: err, + stackTrace: s, + ); + } + } + return List.unmodifiable(messages); + } + + @override + String toSql(List value) { + return jsonEncode(value.map((m) => m.toMap()).toList()); + } +} diff --git a/lib/db/hive/db.dart b/lib/db/hive/db.dart index 3eac4b805a..03d0fd3038 100644 --- a/lib/db/hive/db.dart +++ b/lib/db/hive/db.dart @@ -11,11 +11,12 @@ import 'dart:isolate'; import 'package:compat/compat.dart' as lib_monero_compat; -import 'package:hive_ce/src/hive_impl.dart'; import 'package:hive_ce/hive.dart' show Box; +import 'package:hive_ce/src/hive_impl.dart'; import 'package:mutex/mutex.dart'; import '../../app_config.dart'; +import '../../models/epicbox_server_model.dart'; import '../../models/exchange/response_objects/trade.dart'; import '../../models/node_model.dart'; import '../../models/notification_model.dart'; @@ -52,6 +53,8 @@ class DB { static const String boxNameDBInfo = "dbInfo"; static const String boxNamePrefs = "prefs"; static const String boxNameOneTimeDialogsShown = "oneTimeDialogsShown"; + static const String boxNameEpicBoxModels = "epicBoxModels"; + static const String boxNamePrimaryEpicBox = "primaryEpicBox"; String _boxNameTxCache({required CryptoCurrency currency}) => "${currency.identifier}_txCache"; @@ -75,6 +78,8 @@ class DB { Box? _boxPrefs; Box? _boxTradeLookup; Box? _boxDBInfo; + late final Box _boxEpicBoxModels; + late final Box _boxPrimaryEpicBoxes; // Box? _boxDesktopData; final Map> _walletBoxes = {}; @@ -115,6 +120,24 @@ class DB { } await hive.openBox(boxNameWalletsToDeleteOnStart); + if (hive.isBoxOpen(boxNameEpicBoxModels)) { + _boxEpicBoxModels = hive.box(boxNameEpicBoxModels); + } else { + _boxEpicBoxModels = await hive.openBox( + boxNameEpicBoxModels, + ); + } + + if (hive.isBoxOpen(boxNamePrimaryEpicBox)) { + _boxPrimaryEpicBoxes = hive.box( + boxNamePrimaryEpicBox, + ); + } else { + _boxPrimaryEpicBoxes = await hive.openBox( + boxNamePrimaryEpicBox, + ); + } + if (hive.isBoxOpen(boxNamePrefs)) { _boxPrefs = hive.box(boxNamePrefs); } else { diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart index 94f27e1f8b..8958114736 100644 --- a/lib/db/isar/main_db.dart +++ b/lib/db/isar/main_db.dart @@ -8,6 +8,8 @@ * */ +import 'dart:io'; + import 'package:decimal/decimal.dart'; import 'package:isar_community/isar.dart'; import 'package:tuple/tuple.dart'; @@ -59,6 +61,7 @@ class MainDB { AddressSchema, AddressLabelSchema, EthContractSchema, + SolContractSchema, TransactionBlockExplorerSchema, StackThemeSchema, ContactEntrySchema, @@ -69,13 +72,15 @@ class MainDB { WalletInfoMetaSchema, TokenWalletInfoSchema, FrostWalletInfoSchema, + WalletSolanaTokenInfoSchema, ], directory: (await StackFileSystem.applicationIsarDirectory()).path, // inspector: kDebugMode, inspector: false, name: "wallet_data", - maxSizeMiB: 512, + maxSizeMiB: Platform.isWindows ? 1024 : 512, ); + return true; } @@ -328,6 +333,14 @@ class MainDB { if (storedUtxo != null) { // update + // Preserve user-set flags, but allow a fresh auto-freeze (e.g. firo + // masternode collateral detected after registration) unless the + // user deliberately unfroze this utxo before. Never auto-unfreeze: + // a flaky network check must not unlock coins. + final applyAutoBlock = + utxo.isBlocked && + !storedUtxo.isBlocked && + !storedUtxo.userUnfroze; set.remove(utxo); set.add( storedUtxo.copyWith( @@ -336,6 +349,12 @@ class MainDB { blockTime: utxo.blockTime, blockHeight: utxo.blockHeight, blockHash: utxo.blockHash, + // passing null keeps the stored value + isBlocked: applyAutoBlock ? true : null, + blockedReason: applyAutoBlock ? utxo.blockedReason : null, + name: applyAutoBlock && storedUtxo.name.isEmpty + ? utxo.name + : null, ), ); } else { @@ -443,18 +462,16 @@ class MainDB { // Future deleteWalletBlockchainData(String walletId) 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(); - // final lelantusCoinCount = - // await isar.lelantusCoins.where().walletIdEqualTo(walletId).count(); - await isar.writeTxn(() async { - const paginateLimit = 50; + 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) { @@ -621,4 +638,26 @@ class MainDB { isar.writeTxn(() async { await isar.ethContracts.putAll(contracts); }); + + // ========== Solana ========================================================= + + // Solana tokens. + + QueryBuilder getSolContracts() => + isar.solContracts.where(); + + Future getSolContract(String tokenMint) => + isar.solContracts.where().addressEqualTo(tokenMint).findFirst(); + + SolContract? getSolContractSync(String tokenMint) => + isar.solContracts.where().addressEqualTo(tokenMint).findFirstSync(); + + Future putSolContract(SolContract token) => isar.writeTxn(() async { + return await isar.solContracts.put(token); + }); + + Future putSolContracts(List tokens) => + isar.writeTxn(() async { + await isar.solContracts.putAll(tokens); + }); } diff --git a/lib/dto/ordinals/inscription_data.dart b/lib/dto/ordinals/inscription_data.dart index 2f12bd670a..1045bbc8b0 100644 --- a/lib/dto/ordinals/inscription_data.dart +++ b/lib/dto/ordinals/inscription_data.dart @@ -51,6 +51,44 @@ class InscriptionData { ); } + /// Parse the response from an ord server's /inscription/{id} endpoint. + /// [contentUrl] should be pre-built as `$baseUrl/content/$inscriptionId`. + factory InscriptionData.fromOrdJson( + Map json, + String contentUrl, + ) { + final inscriptionId = json['inscription_id'] as String; + final satpoint = json['satpoint'] as String? ?? ''; + // satpoint format: "txid:vout:offset" + final satpointParts = satpoint.split(':'); + if (satpointParts.length < 2 || satpointParts[0].isEmpty) { + throw FormatException( + 'Invalid satpoint for inscription $inscriptionId: "$satpoint"', + ); + } + final output = '${satpointParts[0]}:${satpointParts[1]}'; + final offset = satpointParts.length >= 3 + ? int.tryParse(satpointParts[2]) ?? 0 + : 0; + + return InscriptionData( + inscriptionId: inscriptionId, + inscriptionNumber: json['inscription_number'] as int, + address: json['address'] as String? ?? '', + preview: contentUrl, + content: contentUrl, + contentLength: json['content_length'] as int? ?? 0, + contentType: json['content_type'] as String? ?? '', + contentBody: '', + timestamp: json['timestamp'] as int? ?? 0, + genesisTransaction: inscriptionId.split('i').first, + location: satpoint, + output: output, + outputValue: json['output_value'] as int? ?? 0, + offset: offset, + ); + } + @override String toString() { return 'InscriptionData {' diff --git a/lib/electrumx_rpc/cached_electrumx_client.dart b/lib/electrumx_rpc/cached_electrumx_client.dart index 7c23af4010..e1b2235015 100644 --- a/lib/electrumx_rpc/cached_electrumx_client.dart +++ b/lib/electrumx_rpc/cached_electrumx_client.dart @@ -26,15 +26,13 @@ class CachedElectrumXClient { required ElectrumXClient electrumXClient, }) => CachedElectrumXClient(electrumXClient: electrumXClient); - String base64ToHex(String source) => - base64Decode( - LineSplitter.split(source).join(), - ).map((e) => e.toRadixString(16).padLeft(2, '0')).join(); + String base64ToHex(String source) => base64Decode( + LineSplitter.split(source).join(), + ).map((e) => e.toRadixString(16).padLeft(2, '0')).join(); - String base64ToReverseHex(String source) => - base64Decode( - LineSplitter.split(source).join(), - ).reversed.map((e) => e.toRadixString(16).padLeft(2, '0')).join(); + String base64ToReverseHex(String source) => base64Decode( + LineSplitter.split(source).join(), + ).reversed.map((e) => e.toRadixString(16).padLeft(2, '0')).join(); /// Call electrumx getTransaction on a per coin basis, storing the result in local db if not already there. /// @@ -77,6 +75,55 @@ class CachedElectrumXClient { } } + Future>> getBatchTransactions({ + required List txHashes, + required CryptoCurrency cryptoCurrency, + }) async { + try { + final box = await DB.instance.getTxCacheBox(currency: cryptoCurrency); + + final List> result = []; + final List needsFetching = []; + + for (final txHash in txHashes) { + final cachedTx = box.get(txHash) as Map?; + if (cachedTx == null) { + needsFetching.add(txHash); + } else { + result.add(Map.from(cachedTx)); + } + } + + if (needsFetching.isNotEmpty) { + final txns = await electrumXClient.getBatchTransactions( + txHashes: needsFetching, + ); + + for (final tx in txns) { + tx.remove("hex"); + tx.remove("lelantusData"); + tx.remove("sparkData"); + + if (tx["confirmations"] != null && + tx["confirmations"] as int > minCacheConfirms) { + await box.put(tx["txid"] as String, tx); + } + + result.add(tx); + } + } + + return result; + } catch (e, s) { + Logging.instance.e( + "Failed to process CachedElectrumX.getTransaction(): ", + error: e, + stackTrace: s, + ); + rethrow; + } + } + /// Clear all cached transactions for the specified coin Future clearSharedTransactionCache({ required CryptoCurrency cryptoCurrency, diff --git a/lib/electrumx_rpc/electrumx_client.dart b/lib/electrumx_rpc/electrumx_client.dart index 2ef2791cbe..94b650b103 100644 --- a/lib/electrumx_rpc/electrumx_client.dart +++ b/lib/electrumx_rpc/electrumx_client.dart @@ -117,6 +117,8 @@ class ElectrumXClient { final Mutex _torConnectingLock = Mutex(); bool _requireMutex = false; + final _adapterMutex = Mutex(); + ElectrumXClient({ required String host, required int port, @@ -219,109 +221,115 @@ class ElectrumXClient { } Future checkElectrumAdapter() async { - ({InternetAddress host, int port})? proxyInfo; - - if (AppConfig.hasFeature(AppFeature.tor)) { - // If we're supposed to use Tor... - if (_prefs.useTor) { - // But Tor isn't running... - if (_torService.status != TorConnectionStatus.connected) { - // And the killswitch isn't set... - if (!_prefs.torKillSwitch) { - // Then we'll just proceed and connect to ElectrumX through - // clearnet at the bottom of this function. - Logging.instance.w( - "Tor preference set but Tor is not enabled, killswitch not set," - " connecting to Electrum adapter through clearnet", - ); + await _adapterMutex.protect(() async { + ({InternetAddress host, int port})? proxyInfo; + + if (AppConfig.hasFeature(AppFeature.tor)) { + // If we're supposed to use Tor... + if (_prefs.useTor) { + // But Tor isn't running... + if (_torService.status != TorConnectionStatus.connected) { + // And the killswitch isn't set... + if (!_prefs.torKillSwitch) { + // Then we'll just proceed and connect to ElectrumX through + // clearnet at the bottom of this function. + Logging.instance.w( + "Tor preference set but Tor is not enabled, killswitch not set," + " connecting to Electrum adapter through clearnet", + ); + } else { + // ... But if the killswitch is set, then we throw an exception. + throw Exception( + "Tor preference and killswitch set but Tor is not enabled, " + "not connecting to Electrum adapter", + ); + // TODO [prio=low]: Try to start Tor. + } } else { - // ... But if the killswitch is set, then we throw an exception. - throw Exception( - "Tor preference and killswitch set but Tor is not enabled, " - "not connecting to Electrum adapter", + // Get the proxy info from the TorService. + proxyInfo = _torService.getProxyInfo(); + } + + if (netType == TorPlainNetworkOption.clear) { + _electrumAdapterChannel = null; + await ClientManager.sharedInstance.remove( + cryptoCurrency: cryptoCurrency, ); - // TODO [prio=low]: Try to start Tor. } } else { - // Get the proxy info from the TorService. - proxyInfo = _torService.getProxyInfo(); - } - - if (netType == TorPlainNetworkOption.clear) { - _electrumAdapterChannel = null; - await ClientManager.sharedInstance.remove( - cryptoCurrency: cryptoCurrency, - ); - } - } else { - if (netType == TorPlainNetworkOption.tor) { - _electrumAdapterChannel = null; - await ClientManager.sharedInstance.remove( - cryptoCurrency: cryptoCurrency, - ); + if (netType == TorPlainNetworkOption.tor) { + _electrumAdapterChannel = null; + await ClientManager.sharedInstance.remove( + cryptoCurrency: cryptoCurrency, + ); + } } } - } - // If the current ElectrumAdapterClient is closed, create a new one. - if (getElectrumAdapter() != null && getElectrumAdapter()!.peer.isClosed) { - _electrumAdapterChannel = null; - await ClientManager.sharedInstance.remove(cryptoCurrency: cryptoCurrency); - } - - final String useHost; - final int usePort; - final bool useUseSSL; + // If the current ElectrumAdapterClient is closed, create a new one. + if (getElectrumAdapter() != null && getElectrumAdapter()!.peer.isClosed) { + _electrumAdapterChannel = null; + await ClientManager.sharedInstance.remove( + cryptoCurrency: cryptoCurrency, + ); + } - if (currentFailoverIndex == -1) { - useHost = host; - usePort = port; - useUseSSL = useSSL; - } else { - _electrumAdapterChannel = null; - await ClientManager.sharedInstance.remove(cryptoCurrency: cryptoCurrency); - useHost = _failovers[currentFailoverIndex].address; - usePort = _failovers[currentFailoverIndex].port; - useUseSSL = _failovers[currentFailoverIndex].useSSL; - } + final String useHost; + final int usePort; + final bool useUseSSL; - _electrumAdapterChannel ??= await electrum_adapter.connect( - useHost, - port: usePort, - connectionTimeout: connectionTimeoutForSpecialCaseJsonRPCClients, - aliveTimerDuration: connectionTimeoutForSpecialCaseJsonRPCClients, - acceptUnverified: true, - useSSL: useUseSSL, - proxyInfo: proxyInfo, - ); - - if (getElectrumAdapter() == null) { - final ElectrumClient newClient; - if (cryptoCurrency is Firo) { - newClient = FiroElectrumClient( - _electrumAdapterChannel!, - useHost, - usePort, - useUseSSL, - proxyInfo, - ); + if (currentFailoverIndex == -1) { + useHost = host; + usePort = port; + useUseSSL = useSSL; } else { - newClient = ElectrumClient( - _electrumAdapterChannel!, - useHost, - usePort, - useUseSSL, - proxyInfo, + _electrumAdapterChannel = null; + await ClientManager.sharedInstance.remove( + cryptoCurrency: cryptoCurrency, ); + useHost = _failovers[currentFailoverIndex].address; + usePort = _failovers[currentFailoverIndex].port; + useUseSSL = _failovers[currentFailoverIndex].useSSL; } - await ClientManager.sharedInstance.addClient( - newClient, - cryptoCurrency: cryptoCurrency, - netType: netType, + _electrumAdapterChannel ??= await electrum_adapter.connect( + useHost, + port: usePort, + connectionTimeout: connectionTimeoutForSpecialCaseJsonRPCClients, + aliveTimerDuration: connectionTimeoutForSpecialCaseJsonRPCClients, + acceptUnverified: false, + useSSL: useUseSSL, + proxyInfo: proxyInfo, ); - } - return; + if (getElectrumAdapter() == null) { + final ElectrumClient newClient; + if (cryptoCurrency is Firo) { + newClient = FiroElectrumClient( + _electrumAdapterChannel!, + useHost, + usePort, + useUseSSL, + proxyInfo, + ); + } else { + newClient = ElectrumClient( + _electrumAdapterChannel!, + useHost, + usePort, + useUseSSL, + proxyInfo, + ); + } + + await newClient.request('server.version'); + + await ClientManager.sharedInstance.addClient( + newClient, + cryptoCurrency: cryptoCurrency, + netType: netType, + ); + } + }); } /// Send raw rpc command @@ -828,6 +836,28 @@ class ElectrumXClient { return Map.from(response as Map); } + Future>> getBatchTransactions({ + required List txHashes, + String? requestID, + }) async { + Logging.instance.d( + "attempting to fetch BATCHED blockchain.transaction.get...", + ); + + final response = await batchRequest( + command: 'blockchain.transaction.get', + args: txHashes.map((e) => [e, true]).toList(), + ); + final List> result = []; + for (int i = 0; i < response.length; i++) { + result.add(Map.from(response[i] as Map)); + } + + Logging.instance.d("Fetching blockchain.transaction.get BATCHED finished"); + + return result; + } + /// Returns the whole Lelantus anonymity set for denomination in the groupId. /// /// ex: diff --git a/lib/main.dart b/lib/main.dart index 7bfba70f00..89d7decb85 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,6 +23,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 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'; @@ -32,6 +33,7 @@ import 'db/hive/db.dart'; import 'db/isar/main_db.dart'; import 'db/special_migrations.dart'; import 'db/sqlite/firo_cache.dart'; +import 'models/epicbox_server_model.dart'; import 'models/exchange/change_now/exchange_transaction.dart'; import 'models/exchange/change_now/exchange_transaction_status.dart'; import 'models/exchange/response_objects/trade.dart'; @@ -39,6 +41,7 @@ import 'models/models.dart'; import 'models/node_model.dart'; import 'models/notification_model.dart'; import 'models/trade_wallet_lookup.dart'; +import 'pages/already_running_view.dart'; import 'pages/campfire_migrate_view.dart'; import 'pages/home_view/home_view.dart'; import 'pages/intro_view.dart'; @@ -76,6 +79,7 @@ import 'wallets/isar/providers/all_wallets_info_provider.dart'; import 'wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import 'widgets/crypto_notifications.dart'; import 'wl_gen/interfaces/cs_monero_interface.dart'; +import 'wl_gen/interfaces/cs_wownero_interface.dart'; import 'wl_gen/interfaces/lib_xelis_interface.dart'; final openedFromSWBFileStringStateProvider = StateProvider( @@ -153,6 +157,9 @@ void main(List args) async { // node model adapter DB.instance.hive.registerAdapter(NodeModelAdapter()); + // epicbox server model adapter + DB.instance.hive.registerAdapter(EpicBoxServerModelAdapter()); + if (!DB.instance.hive.isAdapterRegistered( lib_monero_compat.WalletInfoAdapter().typeId, )) { @@ -161,17 +168,68 @@ void main(List args) async { DB.instance.hive.registerAdapter(lib_monero_compat.WalletTypeAdapter()); - if (AppConfig.coins.whereType().isNotEmpty || - AppConfig.coins.whereType().isNotEmpty) { + if (AppConfig.coins.whereType().isNotEmpty) { csMonero.setUseCsMoneroLoggerInternal(kDebugMode); } + if (AppConfig.coins.whereType().isNotEmpty) { + csWownero.setUseCsWowneroLoggerInternal(kDebugMode); + } DB.instance.hive.init( (await StackFileSystem.applicationHiveDirectory()).path, ); - await DB.instance.hive.openBox(DB.boxNameDBInfo); - await DB.instance.hive.openBox(DB.boxNamePrefs); + try { + await DB.instance.hive.openBox(DB.boxNameDBInfo); + await DB.instance.hive.openBox(DB.boxNamePrefs); + } on FileSystemException catch (e) { + if (e.osError?.errorCode == 11 || e.message.contains('lock failed')) { + // Another instance already holds the Hive database lock. + // Try to bootstrap just enough of the theme system (Isar is independent + // of Hive) so the error screen looks like a real Stack Wallet screen. + Widget errorApp; + try { + await StackFileSystem.initThemesDir(); + await MainDB.instance.initMainDB(); + ThemeService.instance.init(MainDB.instance); + errorApp = const ProviderScope(child: AlreadyRunningApp()); + } catch (_) { + // Isar is also unavailable (e.g., another error). Fall back to a + // minimal but still Inter-font styled screen. + errorApp = MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData(fontFamily: GoogleFonts.inter().fontFamily), + home: Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppConfig.appName, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'is already running.\n' + 'Close the other window and try again.', + textAlign: TextAlign.center, + style: GoogleFonts.inter(fontSize: 16), + ), + ], + ), + ), + ), + ); + } + runApp(errorApp); + return; + } + rethrow; + } await Prefs.instance.init(); await Logging.instance.initialize( @@ -202,7 +260,7 @@ void main(List args) async { .logsStream(CryptoCurrencyNetwork.main) .then( (stream) => - stream.listen((line) => print("[MWEBD: MAINNET]: $line")), + stream.listen((line) => debugPrint("[MWEBD: MAINNET]: $line")), ), ); unawaited( @@ -210,7 +268,7 @@ void main(List args) async { .logsStream(CryptoCurrencyNetwork.test) .then( (stream) => - stream.listen((line) => print("[MWEBD: TESTNET]: $line")), + stream.listen((line) => debugPrint("[MWEBD: TESTNET]: $line")), ), ); } @@ -325,6 +383,10 @@ class _MaterialAppWithThemeState extends ConsumerState with WidgetsBindingObserver { static const platform = MethodChannel("STACK_WALLET_RESTORE"); + final _mobileAppPrivacy = Platform.isAndroid || Platform.isIOS + ? MobileAppPrivacy() + : null; + // late final Wallets _wallets; // late final Prefs _prefs; late final NotificationsService _notificationsService; @@ -382,6 +444,7 @@ class _MaterialAppWithThemeState extends ConsumerState unawaited(ref.read(baseCurrenciesProvider).update()); await _nodeService.updateDefaults(); + await _nodeService.updateDefaultEpicBoxes(); await _notificationsService.init( nodeService: _nodeService, tradesService: _tradesService, @@ -456,6 +519,11 @@ class _MaterialAppWithThemeState extends ConsumerState }); } + if (Platform.isAndroid && + ref.read(prefsChangeNotifierProvider).disableScreenShots) { + unawaited(_mobileAppPrivacy?.setFlagSecure(true)); + } + String themeId; if (ref.read(prefsChangeNotifierProvider).enableSystemBrightness) { final brightness = WidgetsBinding.instance.window.platformBrightness; @@ -551,7 +619,18 @@ class _MaterialAppWithThemeState extends ConsumerState @override void didChangeAppLifecycleState(AppLifecycleState state) async { debugPrint("didChangeAppLifecycleState: ${state.name}"); - if (state == AppLifecycleState.resumed) {} + + if (state == AppLifecycleState.resumed) { + await _mobileAppPrivacy?.disableOverlay(); + } else { + if (ref.read(prefsChangeNotifierProvider).privacyScreen) { + await _mobileAppPrivacy?.enableOverlay( + color: ref.read(themeProvider).popupBG, // only android, ios uses blur + blurInsteadOfColor: true, // ignored on android + ); + } + } + switch (state) { case AppLifecycleState.inactive: break; @@ -607,7 +686,10 @@ class _MaterialAppWithThemeState extends ConsumerState @override Future didRequestAppExit() async { debugPrint("didRequestAppExit called"); - if (Platform.isMacOS) { + if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { + // Monero will cause app to stop responding if in the middle of doing + // things like a scan on the c++ side of things. + // On macOS, mwebd fails to shut down, hanging the app on close. // // Exiting is a hack fix for this issue. @@ -688,6 +770,13 @@ class _MaterialAppWithThemeState extends ConsumerState // addToDebugMessagesDB: false); // }); + if (Platform.isAndroid) { + ref.listen( + prefsChangeNotifierProvider.select((s) => s.disableScreenShots), + (_, next) => _mobileAppPrivacy?.setFlagSecure(next), + ); + } + final colorScheme = ref.watch(colorProvider.state).state; return MaterialApp( diff --git a/lib/models/add_wallet_list_entity/sub_classes/sol_token_entity.dart b/lib/models/add_wallet_list_entity/sub_classes/sol_token_entity.dart new file mode 100644 index 0000000000..1f4c01ff06 --- /dev/null +++ b/lib/models/add_wallet_list_entity/sub_classes/sol_token_entity.dart @@ -0,0 +1,31 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../isar/models/solana/sol_contract.dart'; +import '../add_wallet_list_entity.dart'; + +class SolTokenEntity extends AddWalletListEntity { + SolTokenEntity(this.token); + + final SolContract token; + + @override + CryptoCurrency get cryptoCurrency => Solana(CryptoCurrencyNetwork.main); + + @override + String get name => token.name; + + @override + String get ticker => token.symbol; + + @override + List get props => + [cryptoCurrency.identifier, name, ticker, token.address]; +} diff --git a/lib/models/balance.dart b/lib/models/balance.dart index 9680412549..4fc728ade0 100644 --- a/lib/models/balance.dart +++ b/lib/models/balance.dart @@ -19,19 +19,18 @@ class Balance { final Amount blockedTotal; final Amount pendingSpendable; - Balance({ + const Balance({ required this.total, required this.spendable, required this.blockedTotal, required this.pendingSpendable, }); - factory Balance.zeroFor({required CryptoCurrency currency}) { - final amount = Amount( - rawValue: BigInt.zero, - fractionDigits: currency.fractionDigits, - ); + factory Balance.zeroFor({required CryptoCurrency currency}) => + .zeroWith(fractionDigits: currency.fractionDigits); + factory Balance.zeroWith({required int fractionDigits}) { + final amount = Amount.zeroWith(fractionDigits: fractionDigits); return Balance( total: amount, spendable: amount, @@ -41,11 +40,11 @@ class Balance { } String toJsonIgnoreCoin() => jsonEncode({ - "total": total.toJsonString(), - "spendable": spendable.toJsonString(), - "blockedTotal": blockedTotal.toJsonString(), - "pendingSpendable": pendingSpendable.toJsonString(), - }); + "total": total.toJsonString(), + "spendable": spendable.toJsonString(), + "blockedTotal": blockedTotal.toJsonString(), + "pendingSpendable": pendingSpendable.toJsonString(), + }); // need to fall back to parsing from int due to cached balances being previously // stored as int values instead of Amounts @@ -82,11 +81,11 @@ class Balance { } Map toMap() => { - "total": total, - "spendable": spendable, - "blockedTotal": blockedTotal, - "pendingSpendable": pendingSpendable, - }; + "total": total, + "spendable": spendable, + "blockedTotal": blockedTotal, + "pendingSpendable": pendingSpendable, + }; @override String toString() { diff --git a/lib/models/epic_slatepack_models.dart b/lib/models/epic_slatepack_models.dart new file mode 100644 index 0000000000..6df4cc70cf --- /dev/null +++ b/lib/models/epic_slatepack_models.dart @@ -0,0 +1,116 @@ +class EpicSlatepackResult { + final bool success; + final String? error; + final String? slatepack; + final String? slateJson; + final bool? wasEncrypted; + final String? recipientAddress; + + EpicSlatepackResult({ + required this.success, + this.error, + this.slatepack, + this.slateJson, + this.wasEncrypted, + this.recipientAddress, + }); + + @override + String toString() { + return "EpicSlatepackResult(" + "success: $success, " + "error: $error, " + "slatepack: $slatepack, " + "slateJson: $slateJson, " + "wasEncrypted: $wasEncrypted, " + "recipientAddress: $recipientAddress" + ")"; + } +} + +class EpicSlatepackDecodeResult { + final bool success; + final String? error; + final String? slateJson; + final bool? wasEncrypted; + final String? senderAddress; + final String? recipientAddress; + + EpicSlatepackDecodeResult({ + required this.success, + this.error, + this.slateJson, + this.wasEncrypted, + this.senderAddress, + this.recipientAddress, + }); + + @override + String toString() { + return "EpicSlatepackDecodeResult(" + "success: $success, " + "error: $error, " + "slateJson: $slateJson, " + "wasEncrypted: $wasEncrypted, " + "senderAddress: $senderAddress, " + "recipientAddress: $recipientAddress" + ")"; + } +} + +class EpicReceiveResult { + final bool success; + final String? error; + final String? slateId; + final String? commitId; + final String? responseSlatepack; + final bool? wasEncrypted; + final String? recipientAddress; + + EpicReceiveResult({ + required this.success, + this.error, + this.slateId, + this.commitId, + this.responseSlatepack, + this.wasEncrypted, + this.recipientAddress, + }); + + @override + String toString() { + return "EpicReceiveResult(" + "success: $success, " + "error: $error, " + "slateId: $slateId, " + "commitId: $commitId, " + "responseSlatepack: $responseSlatepack, " + "wasEncrypted: $wasEncrypted, " + "recipientAddress: $recipientAddress" + ")"; + } +} + +class EpicFinalizeResult { + final bool success; + final String? error; + final String? slateId; + final String? commitId; + + EpicFinalizeResult({ + required this.success, + this.error, + this.slateId, + this.commitId, + }); + + @override + String toString() { + return "EpicFinalizeResult(" + "success: $success, " + "error: $error, " + "slateId: $slateId, " + "commitId: $commitId" + ")"; + } +} diff --git a/lib/models/exchange/aggregate_currency.dart b/lib/models/exchange/aggregate_currency.dart index b2fa09f300..cfcb707134 100644 --- a/lib/models/exchange/aggregate_currency.dart +++ b/lib/models/exchange/aggregate_currency.dart @@ -46,7 +46,9 @@ class AggregateCurrency { return _map.values.first.name.split(" (Mainnet").first; } - String get image => _map.values.first.image; + String get image => _map.values + .map((e) => e.image) + .firstWhere((e) => e.isNotEmpty, orElse: () => ""); SupportedRateType get rateType => _map.values.first.rateType; diff --git a/lib/models/exchange/response_objects/trade.dart b/lib/models/exchange/response_objects/trade.dart index 1669b94c5e..a00d531e38 100644 --- a/lib/models/exchange/response_objects/trade.dart +++ b/lib/models/exchange/response_objects/trade.dart @@ -86,6 +86,9 @@ class Trade { @HiveField(21) final String exchangeName; + @HiveField(22) + final String? other; + const Trade({ required this.uuid, required this.tradeId, @@ -109,6 +112,7 @@ class Trade { required this.refundExtraId, required this.status, required this.exchangeName, + this.other, }); Trade copyWith({ @@ -133,6 +137,7 @@ class Trade { String? refundExtraId, String? status, String? exchangeName, + String? other, }) { return Trade( uuid: uuid, @@ -157,6 +162,7 @@ class Trade { refundExtraId: refundExtraId ?? this.refundExtraId, status: status ?? this.status, exchangeName: exchangeName ?? this.exchangeName, + other: other ?? this.other, ); } @@ -184,6 +190,7 @@ class Trade { "refundExtraId": refundExtraId, "status": status, "exchangeName": exchangeName, + if (other != null) "other": other!, }; } @@ -211,6 +218,7 @@ class Trade { refundExtraId: map["refundExtraId"] as String, status: map["status"] as String, exchangeName: map["exchangeName"] as String, + other: map["other"] as String?, ); } diff --git a/lib/models/exchange/response_objects/trade.g.dart b/lib/models/exchange/response_objects/trade.g.dart index c0c54c4875..4bee556a1f 100644 --- a/lib/models/exchange/response_objects/trade.g.dart +++ b/lib/models/exchange/response_objects/trade.g.dart @@ -39,13 +39,14 @@ class TradeAdapter extends TypeAdapter { refundExtraId: fields[19] as String, status: fields[20] as String, exchangeName: fields[21] as String, + other: fields[22] as String?, ); } @override void write(BinaryWriter writer, Trade obj) { writer - ..writeByte(22) + ..writeByte(23) ..writeByte(0) ..write(obj.uuid) ..writeByte(1) @@ -89,7 +90,9 @@ class TradeAdapter extends TypeAdapter { ..writeByte(20) ..write(obj.status) ..writeByte(21) - ..write(obj.exchangeName); + ..write(obj.exchangeName) + ..writeByte(22) + ..write(obj.other); } @override diff --git a/lib/models/isar/exchange_cache/currency.dart b/lib/models/isar/exchange_cache/currency.dart index f450535cb7..414deff9e3 100644 --- a/lib/models/isar/exchange_cache/currency.dart +++ b/lib/models/isar/exchange_cache/currency.dart @@ -12,9 +12,13 @@ import 'package:isar_community/isar.dart'; import '../../../app_config.dart'; import '../../../services/exchange/change_now/change_now_exchange.dart'; +import '../../../services/exchange/cyphergoat/cyphergoat_exchange.dart'; import '../../../services/exchange/exchange.dart'; +import '../../../services/exchange/exolix/exolix_exchange.dart'; +import '../../../services/exchange/lets_exchange/lets_exchange_exchange.dart'; import '../../../services/exchange/nanswap/nanswap_exchange.dart'; import '../../../services/exchange/trocador/trocador_exchange.dart'; +import '../../../services/exchange/wizard_swap/wizard_swap_exchange.dart'; import 'pair.dart'; part 'currency.g.dart'; @@ -82,6 +86,8 @@ class Currency { // already lower case ticker basically const (ChangeNowExchange) => network, + const (ExolixExchange) => network.toLowerCase(), + // not used at the time being // case const (SimpleSwapExchange): @@ -94,6 +100,14 @@ class Currency { const (NanswapExchange) => network.isNotEmpty ? network.toLowerCase() : ticker.toLowerCase(), + // wizard swap's api sucks + const (WizardSwapExchange) => ticker.toLowerCase(), + + const (LetsExchangeExchange) => network.toLowerCase(), + + const (CypherGoatExchange) => + network.isNotEmpty ? network.toLowerCase() : ticker.toLowerCase(), + _ => throw Exception("Unknown exchange: $exchangeName"), }; } diff --git a/lib/models/isar/models/blockchain_data/transaction.dart b/lib/models/isar/models/blockchain_data/transaction.dart index 3e43ffb219..a4c808bfbc 100644 --- a/lib/models/isar/models/blockchain_data/transaction.dart +++ b/lib/models/isar/models/blockchain_data/transaction.dart @@ -261,4 +261,5 @@ enum TransactionSubType { sparkSpend, // firo specific ordinal, mweb, + splToken, // Solana token. } diff --git a/lib/models/isar/models/blockchain_data/transaction.g.dart b/lib/models/isar/models/blockchain_data/transaction.g.dart index aa41d834b7..c87673a99a 100644 --- a/lib/models/isar/models/blockchain_data/transaction.g.dart +++ b/lib/models/isar/models/blockchain_data/transaction.g.dart @@ -356,6 +356,7 @@ const _TransactionsubTypeEnumValueMap = { 'sparkSpend': 7, 'ordinal': 8, 'mweb': 9, + 'splToken': 10, }; const _TransactionsubTypeValueEnumMap = { 0: TransactionSubType.none, @@ -368,6 +369,7 @@ const _TransactionsubTypeValueEnumMap = { 7: TransactionSubType.sparkSpend, 8: TransactionSubType.ordinal, 9: TransactionSubType.mweb, + 10: TransactionSubType.splToken, }; const _TransactiontypeEnumValueMap = { 'outgoing': 0, diff --git a/lib/models/isar/models/blockchain_data/utxo.dart b/lib/models/isar/models/blockchain_data/utxo.dart index f417b4cdf4..988a713aeb 100644 --- a/lib/models/isar/models/blockchain_data/utxo.dart +++ b/lib/models/isar/models/blockchain_data/utxo.dart @@ -94,6 +94,14 @@ class UTXO { (isCoinbase ? minimumCoinbaseConfirms : minimumConfirms); } + /// A lingering [blockedReason] on an unblocked utxo means the wallet + /// auto-froze it previously and the user deliberately unfroze it. Used to + /// prevent auto re-freezing in [MainDB.updateUTXOs]. Relies on the + /// freeze/unfreeze toggles only flipping [isBlocked] and never clearing + /// [blockedReason]. + @ignore + bool get userUnfroze => !isBlocked && blockedReason != null; + // fuzzy bool _isMonero() { return keyImage != null; diff --git a/lib/models/isar/models/blockchain_data/v2/transaction_v2.dart b/lib/models/isar/models/blockchain_data/v2/transaction_v2.dart index a507ba8fe7..721d9a11c9 100644 --- a/lib/models/isar/models/blockchain_data/v2/transaction_v2.dart +++ b/lib/models/isar/models/blockchain_data/v2/transaction_v2.dart @@ -87,6 +87,9 @@ class TransactionV2 { ); } + @ignore + String? get memo => _getFromOtherData(key: TxV2OdKeys.memo) as String?; + @ignore int? get size => _getFromOtherData(key: TxV2OdKeys.size) as int?; @@ -274,41 +277,8 @@ class TransactionV2 { if (isConfirmed(currentChainHeight, minConfirms, minCoinbaseConfirms)) { return "Received"; } else { - if (numberOfMessages == 1) { - return "Receiving (waiting for sender)"; - } else if ((numberOfMessages ?? 0) > 1) { - return "Receiving (waiting for confirmations)"; // TODO test if the sender still has to open again after the receiver has 2 messages present, ie. sender->receiver->sender->node (yes) vs. sender->receiver->node (no) - } else { - return "Receiving ${prettyConfirms()}"; - } - } - } else if (type == TransactionType.outgoing) { - if (isConfirmed(currentChainHeight, minConfirms, minCoinbaseConfirms)) { - return "Sent (confirmed)"; - } else { - if (numberOfMessages == 1) { - return "Sending (waiting for receiver)"; - } else if ((numberOfMessages ?? 0) > 1) { - return "Sending (waiting for confirmations)"; - } else { - return "Sending ${prettyConfirms()}"; - } - } - } - } - - if (isMimblewimblecoinTransaction) { - if (slateId == null) { - return "Restored Funds"; - } - - if (isCancelled) { - return "Cancelled"; - } else if (type == TransactionType.incoming) { - if (isConfirmed(currentChainHeight, minConfirms, minCoinbaseConfirms)) { - return "Received"; - } else { - if (numberOfMessages == 1) { + if ((onChainNote == null && numberOfMessages == 1) | + (onChainNote != null && numberOfMessages == 2)) { return "Receiving (waiting for sender)"; } else if ((numberOfMessages ?? 0) > 1) { return "Receiving (waiting for confirmations)"; // TODO test if the sender still has to open again after the receiver has 2 messages present, ie. sender->receiver->sender->node (yes) vs. sender->receiver->node (no) @@ -320,7 +290,8 @@ class TransactionV2 { if (isConfirmed(currentChainHeight, minConfirms, minCoinbaseConfirms)) { return "Sent (confirmed)"; } else { - if (numberOfMessages == 1) { + if ((onChainNote == null && numberOfMessages == 1) | + (onChainNote != null && numberOfMessages == 2)) { return "Sending (waiting for receiver)"; } else if ((numberOfMessages ?? 0) > 1) { return "Sending (waiting for confirmations)"; @@ -342,7 +313,8 @@ class TransactionV2 { if (isConfirmed(currentChainHeight, minConfirms, minCoinbaseConfirms)) { return "Received"; } else { - if (numberOfMessages == 1) { + if ((onChainNote == null && numberOfMessages == 1) | + (onChainNote != null && numberOfMessages == 2)) { return "Receiving (waiting for sender)"; } else if ((numberOfMessages ?? 0) > 1) { return "Receiving (waiting for confirmations)"; // TODO test if the sender still has to open again after the receiver has 2 messages present, ie. sender->receiver->sender->node (yes) vs. sender->receiver->node (no) @@ -354,7 +326,8 @@ class TransactionV2 { if (isConfirmed(currentChainHeight, minConfirms, minCoinbaseConfirms)) { return "Sent (confirmed)"; } else { - if (numberOfMessages == 1) { + if ((onChainNote == null && numberOfMessages == 1) | + (onChainNote != null && numberOfMessages == 2)) { return "Sending (waiting for receiver)"; } else if ((numberOfMessages ?? 0) > 1) { return "Sending (waiting for confirmations)"; @@ -447,4 +420,5 @@ abstract final class TxV2OdKeys { static const isInstantLock = "isInstantLock"; static const salviumTypeInt = "salviumTypeInt"; static const salviumTypeString = "salviumTypeString"; + static const memo = "onChainMemo"; } diff --git a/lib/models/isar/models/blockchain_data/v2/transaction_v2.g.dart b/lib/models/isar/models/blockchain_data/v2/transaction_v2.g.dart index 1335d783a2..c604f36f7c 100644 --- a/lib/models/isar/models/blockchain_data/v2/transaction_v2.g.dart +++ b/lib/models/isar/models/blockchain_data/v2/transaction_v2.g.dart @@ -378,6 +378,7 @@ const _TransactionV2subTypeEnumValueMap = { 'sparkSpend': 7, 'ordinal': 8, 'mweb': 9, + 'splToken': 10, }; const _TransactionV2subTypeValueEnumMap = { 0: TransactionSubType.none, @@ -390,6 +391,7 @@ const _TransactionV2subTypeValueEnumMap = { 7: TransactionSubType.sparkSpend, 8: TransactionSubType.ordinal, 9: TransactionSubType.mweb, + 10: TransactionSubType.splToken, }; const _TransactionV2typeEnumValueMap = { 'outgoing': 0, diff --git a/lib/models/isar/models/contract.dart b/lib/models/isar/models/contract.dart index 3260df084a..a11383af1e 100644 --- a/lib/models/isar/models/contract.dart +++ b/lib/models/isar/models/contract.dart @@ -9,5 +9,15 @@ */ abstract class Contract { - // for possible future use + /// Token/contract address (mint address for Solana, contract address for Ethereum). + String get address; + + /// Token name. + String get name; + + /// Token symbol. + String get symbol; + + /// Token decimals. + int get decimals; } diff --git a/lib/models/isar/models/ethereum/eth_contract.dart b/lib/models/isar/models/ethereum/eth_contract.dart index adaec31226..ad98f29887 100644 --- a/lib/models/isar/models/ethereum/eth_contract.dart +++ b/lib/models/isar/models/ethereum/eth_contract.dart @@ -9,6 +9,7 @@ */ import 'package:isar_community/isar.dart'; + import '../contract.dart'; part 'eth_contract.g.dart'; @@ -26,13 +27,17 @@ class EthContract extends Contract { Id id = Isar.autoIncrement; + @override @Index(unique: true, replace: true) late final String address; + @override late final String name; + @override late final String symbol; + @override late final int decimals; late final String? abi; @@ -50,21 +55,16 @@ class EthContract extends Contract { List? walletIds, String? abi, String? otherData, - }) => - EthContract( - address: address ?? this.address, - name: name ?? this.name, - symbol: symbol ?? this.symbol, - decimals: decimals ?? this.decimals, - type: type ?? this.type, - abi: abi ?? this.abi, - )..id = id ?? this.id; + }) => EthContract( + address: address ?? this.address, + name: name ?? this.name, + symbol: symbol ?? this.symbol, + decimals: decimals ?? this.decimals, + type: type ?? this.type, + abi: abi ?? this.abi, + )..id = id ?? this.id; } // Used in Isar db and stored there as int indexes so adding/removing values // in this definition should be done extremely carefully in production -enum EthContractType { - unknown, - erc20, - erc721; -} +enum EthContractType { unknown, erc20, erc721 } diff --git a/lib/models/isar/models/isar_models.dart b/lib/models/isar/models/isar_models.dart index ce7652a466..cf27091bf1 100644 --- a/lib/models/isar/models/isar_models.dart +++ b/lib/models/isar/models/isar_models.dart @@ -16,4 +16,6 @@ export 'blockchain_data/transaction.dart'; export 'blockchain_data/utxo.dart'; export 'ethereum/eth_contract.dart'; export 'log.dart'; +export 'solana/sol_contract.dart'; export 'transaction_note.dart'; +export '../../../wallets/isar/models/wallet_solana_token_info.dart'; diff --git a/lib/models/isar/models/solana/sol_contract.dart b/lib/models/isar/models/solana/sol_contract.dart new file mode 100644 index 0000000000..ba2493e2a5 --- /dev/null +++ b/lib/models/isar/models/solana/sol_contract.dart @@ -0,0 +1,62 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:isar_community/isar.dart'; + +import '../contract.dart'; + +part 'sol_contract.g.dart'; + +@collection +class SolContract extends Contract { + SolContract({ + required this.address, + required this.name, + required this.symbol, + required this.decimals, + this.logoUri, + this.metadataAddress, + }); + + Id id = Isar.autoIncrement; + + @override + @Index(unique: true, replace: true) + late final String address; // Mint address. + + @override + late final String name; + + @override + late final String symbol; + + @override + late final int decimals; + + late final String? logoUri; + + late final String? metadataAddress; + + SolContract copyWith({ + Id? id, + String? address, + String? name, + String? symbol, + int? decimals, + String? logoUri, + String? metadataAddress, + }) => SolContract( + address: address ?? this.address, + name: name ?? this.name, + symbol: symbol ?? this.symbol, + decimals: decimals ?? this.decimals, + logoUri: logoUri ?? this.logoUri, + metadataAddress: metadataAddress ?? this.metadataAddress, + )..id = id ?? this.id; +} diff --git a/lib/models/isar/models/solana/sol_contract.g.dart b/lib/models/isar/models/solana/sol_contract.g.dart new file mode 100644 index 0000000000..b75906e254 --- /dev/null +++ b/lib/models/isar/models/solana/sol_contract.g.dart @@ -0,0 +1,1498 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sol_contract.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetSolContractCollection on Isar { + IsarCollection get solContracts => this.collection(); +} + +const SolContractSchema = CollectionSchema( + name: r'SolContract', + id: 1474803837279318906, + properties: { + r'address': PropertySchema(id: 0, name: r'address', type: IsarType.string), + r'decimals': PropertySchema(id: 1, name: r'decimals', type: IsarType.long), + r'logoUri': PropertySchema(id: 2, name: r'logoUri', type: IsarType.string), + r'metadataAddress': PropertySchema( + id: 3, + name: r'metadataAddress', + type: IsarType.string, + ), + r'name': PropertySchema(id: 4, name: r'name', type: IsarType.string), + r'symbol': PropertySchema(id: 5, name: r'symbol', type: IsarType.string), + }, + + estimateSize: _solContractEstimateSize, + serialize: _solContractSerialize, + deserialize: _solContractDeserialize, + deserializeProp: _solContractDeserializeProp, + idName: r'id', + indexes: { + r'address': IndexSchema( + id: -259407546592846288, + name: r'address', + unique: true, + replace: true, + properties: [ + IndexPropertySchema( + name: r'address', + type: IndexType.hash, + caseSensitive: true, + ), + ], + ), + }, + links: {}, + embeddedSchemas: {}, + + getId: _solContractGetId, + getLinks: _solContractGetLinks, + attach: _solContractAttach, + version: '3.3.0-dev.2', +); + +int _solContractEstimateSize( + SolContract object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + bytesCount += 3 + object.address.length * 3; + { + final value = object.logoUri; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + { + final value = object.metadataAddress; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + bytesCount += 3 + object.name.length * 3; + bytesCount += 3 + object.symbol.length * 3; + return bytesCount; +} + +void _solContractSerialize( + SolContract object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeString(offsets[0], object.address); + writer.writeLong(offsets[1], object.decimals); + writer.writeString(offsets[2], object.logoUri); + writer.writeString(offsets[3], object.metadataAddress); + writer.writeString(offsets[4], object.name); + writer.writeString(offsets[5], object.symbol); +} + +SolContract _solContractDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = SolContract( + address: reader.readString(offsets[0]), + decimals: reader.readLong(offsets[1]), + logoUri: reader.readStringOrNull(offsets[2]), + metadataAddress: reader.readStringOrNull(offsets[3]), + name: reader.readString(offsets[4]), + symbol: reader.readString(offsets[5]), + ); + object.id = id; + return object; +} + +P _solContractDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readString(offset)) as P; + case 1: + return (reader.readLong(offset)) as P; + case 2: + return (reader.readStringOrNull(offset)) as P; + case 3: + return (reader.readStringOrNull(offset)) as P; + case 4: + return (reader.readString(offset)) as P; + case 5: + return (reader.readString(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _solContractGetId(SolContract object) { + return object.id; +} + +List> _solContractGetLinks(SolContract object) { + return []; +} + +void _solContractAttach( + IsarCollection col, + Id id, + SolContract object, +) { + object.id = id; +} + +extension SolContractByIndex on IsarCollection { + Future getByAddress(String address) { + return getByIndex(r'address', [address]); + } + + SolContract? getByAddressSync(String address) { + return getByIndexSync(r'address', [address]); + } + + Future deleteByAddress(String address) { + return deleteByIndex(r'address', [address]); + } + + bool deleteByAddressSync(String address) { + return deleteByIndexSync(r'address', [address]); + } + + Future> getAllByAddress(List addressValues) { + final values = addressValues.map((e) => [e]).toList(); + return getAllByIndex(r'address', values); + } + + List getAllByAddressSync(List addressValues) { + final values = addressValues.map((e) => [e]).toList(); + return getAllByIndexSync(r'address', values); + } + + Future deleteAllByAddress(List addressValues) { + final values = addressValues.map((e) => [e]).toList(); + return deleteAllByIndex(r'address', values); + } + + int deleteAllByAddressSync(List addressValues) { + final values = addressValues.map((e) => [e]).toList(); + return deleteAllByIndexSync(r'address', values); + } + + Future putByAddress(SolContract object) { + return putByIndex(r'address', object); + } + + Id putByAddressSync(SolContract object, {bool saveLinks = true}) { + return putByIndexSync(r'address', object, saveLinks: saveLinks); + } + + Future> putAllByAddress(List objects) { + return putAllByIndex(r'address', objects); + } + + List putAllByAddressSync( + List objects, { + bool saveLinks = true, + }) { + return putAllByIndexSync(r'address', objects, saveLinks: saveLinks); + } +} + +extension SolContractQueryWhereSort + on QueryBuilder { + QueryBuilder anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension SolContractQueryWhere + on QueryBuilder { + QueryBuilder idEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between(lower: id, upper: id)); + }); + } + + QueryBuilder idNotEqualTo( + Id id, + ) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder idGreaterThan( + Id id, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder idLessThan( + Id id, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder addressEqualTo( + String address, + ) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IndexWhereClause.equalTo(indexName: r'address', value: [address]), + ); + }); + } + + QueryBuilder addressNotEqualTo( + String address, + ) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'address', + lower: [], + upper: [address], + includeUpper: false, + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'address', + lower: [address], + includeLower: false, + upper: [], + ), + ); + } else { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'address', + lower: [address], + includeLower: false, + upper: [], + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'address', + lower: [], + upper: [address], + includeUpper: false, + ), + ); + } + }); + } +} + +extension SolContractQueryFilter + on QueryBuilder { + QueryBuilder addressEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + addressGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'address', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + addressStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressContains( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressMatches( + String pattern, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'address', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + addressIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'address', value: ''), + ); + }); + } + + QueryBuilder + addressIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'address', value: ''), + ); + }); + } + + QueryBuilder decimalsEqualTo( + int value, + ) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'decimals', value: value), + ); + }); + } + + QueryBuilder + decimalsGreaterThan(int value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'decimals', + value: value, + ), + ); + }); + } + + QueryBuilder + decimalsLessThan(int value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'decimals', + value: value, + ), + ); + }); + } + + QueryBuilder decimalsBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'decimals', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder idEqualTo( + Id value, + ) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'id', value: value), + ); + }); + } + + QueryBuilder idGreaterThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder idLessThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder idBetween( + Id lower, + Id upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder + logoUriIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNull(property: r'logoUri'), + ); + }); + } + + QueryBuilder + logoUriIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNotNull(property: r'logoUri'), + ); + }); + } + + QueryBuilder logoUriEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + logoUriGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'logoUri', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + logoUriStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriContains( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriMatches( + String pattern, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'logoUri', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + logoUriIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'logoUri', value: ''), + ); + }); + } + + QueryBuilder + logoUriIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'logoUri', value: ''), + ); + }); + } + + QueryBuilder + metadataAddressIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNull(property: r'metadataAddress'), + ); + }); + } + + QueryBuilder + metadataAddressIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNotNull(property: r'metadataAddress'), + ); + }); + } + + QueryBuilder + metadataAddressEqualTo(String? value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'metadataAddress', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'metadataAddress', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'metadataAddress', value: ''), + ); + }); + } + + QueryBuilder + metadataAddressIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'metadataAddress', value: ''), + ); + }); + } + + QueryBuilder nameEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'name', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameContains( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameMatches( + String pattern, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'name', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'name', value: ''), + ); + }); + } + + QueryBuilder + nameIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'name', value: ''), + ); + }); + } + + QueryBuilder symbolEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + symbolGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'symbol', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + symbolStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolContains( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolMatches( + String pattern, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'symbol', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + symbolIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'symbol', value: ''), + ); + }); + } + + QueryBuilder + symbolIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'symbol', value: ''), + ); + }); + } +} + +extension SolContractQueryObject + on QueryBuilder {} + +extension SolContractQueryLinks + on QueryBuilder {} + +extension SolContractQuerySortBy + on QueryBuilder { + QueryBuilder sortByAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'address', Sort.asc); + }); + } + + QueryBuilder sortByAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'address', Sort.desc); + }); + } + + QueryBuilder sortByDecimals() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'decimals', Sort.asc); + }); + } + + QueryBuilder sortByDecimalsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'decimals', Sort.desc); + }); + } + + QueryBuilder sortByLogoUri() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'logoUri', Sort.asc); + }); + } + + QueryBuilder sortByLogoUriDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'logoUri', Sort.desc); + }); + } + + QueryBuilder sortByMetadataAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'metadataAddress', Sort.asc); + }); + } + + QueryBuilder + sortByMetadataAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'metadataAddress', Sort.desc); + }); + } + + QueryBuilder sortByName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.asc); + }); + } + + QueryBuilder sortByNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.desc); + }); + } + + QueryBuilder sortBySymbol() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'symbol', Sort.asc); + }); + } + + QueryBuilder sortBySymbolDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'symbol', Sort.desc); + }); + } +} + +extension SolContractQuerySortThenBy + on QueryBuilder { + QueryBuilder thenByAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'address', Sort.asc); + }); + } + + QueryBuilder thenByAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'address', Sort.desc); + }); + } + + QueryBuilder thenByDecimals() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'decimals', Sort.asc); + }); + } + + QueryBuilder thenByDecimalsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'decimals', Sort.desc); + }); + } + + QueryBuilder thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } + + QueryBuilder thenByLogoUri() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'logoUri', Sort.asc); + }); + } + + QueryBuilder thenByLogoUriDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'logoUri', Sort.desc); + }); + } + + QueryBuilder thenByMetadataAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'metadataAddress', Sort.asc); + }); + } + + QueryBuilder + thenByMetadataAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'metadataAddress', Sort.desc); + }); + } + + QueryBuilder thenByName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.asc); + }); + } + + QueryBuilder thenByNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.desc); + }); + } + + QueryBuilder thenBySymbol() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'symbol', Sort.asc); + }); + } + + QueryBuilder thenBySymbolDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'symbol', Sort.desc); + }); + } +} + +extension SolContractQueryWhereDistinct + on QueryBuilder { + QueryBuilder distinctByAddress({ + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'address', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByDecimals() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'decimals'); + }); + } + + QueryBuilder distinctByLogoUri({ + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'logoUri', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByMetadataAddress({ + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy( + r'metadataAddress', + caseSensitive: caseSensitive, + ); + }); + } + + QueryBuilder distinctByName({ + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'name', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctBySymbol({ + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'symbol', caseSensitive: caseSensitive); + }); + } +} + +extension SolContractQueryProperty + on QueryBuilder { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder addressProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'address'); + }); + } + + QueryBuilder decimalsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'decimals'); + }); + } + + QueryBuilder logoUriProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'logoUri'); + }); + } + + QueryBuilder + metadataAddressProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'metadataAddress'); + }); + } + + QueryBuilder nameProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'name'); + }); + } + + QueryBuilder symbolProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'symbol'); + }); + } +} diff --git a/lib/models/keys/view_only_wallet_data.dart b/lib/models/keys/view_only_wallet_data.dart index 92c23b082d..384985ac16 100644 --- a/lib/models/keys/view_only_wallet_data.dart +++ b/lib/models/keys/view_only_wallet_data.dart @@ -7,7 +7,8 @@ import 'key_data_interface.dart'; enum ViewOnlyWalletType { cryptonote, addressOnly, - xPub; + xPub, + spark; } sealed class ViewOnlyWalletData with KeyDataInterface { @@ -46,6 +47,12 @@ sealed class ViewOnlyWalletData with KeyDataInterface { jsonEncodedString, walletId: walletId, ); + + case ViewOnlyWalletType.spark: + return SparkViewOnlyWalletData.fromJsonEncodedString( + jsonEncodedString, + walletId: walletId, + ); } } @@ -162,3 +169,34 @@ class ExtendedKeysViewOnlyWalletData extends ViewOnlyWalletData { ], }); } + +class SparkViewOnlyWalletData extends ViewOnlyWalletData { + @override + final type = ViewOnlyWalletType.spark; + + final String viewKey; + + SparkViewOnlyWalletData({ + required super.walletId, + required this.viewKey, + }); + + static SparkViewOnlyWalletData fromJsonEncodedString( + String jsonEncodedString, { + required String walletId, + }) { + final map = jsonDecode(jsonEncodedString) as Map; + final json = Map.from(map); + + return SparkViewOnlyWalletData( + walletId: walletId, + viewKey: json["viewKey"] as String, + ); + } + + @override + String toJsonEncodedString() => jsonEncode({ + "type": type.index, + "viewKey": viewKey, + }); +} diff --git a/lib/models/node_model.dart b/lib/models/node_model.dart index 5d43d84dba..5386cae0f9 100644 --- a/lib/models/node_model.dart +++ b/lib/models/node_model.dart @@ -47,6 +47,8 @@ class NodeModel { final bool forceNoTor; // @HiveField(14) final bool isPrimary; + // @HiveField(15) + final String? nodeApiSecret; NodeModel({ required this.host, @@ -64,6 +66,7 @@ class NodeModel { this.forceNoTor = false, this.loginName, this.trusted, + this.nodeApiSecret, }); NodeModel copyWith({ @@ -81,6 +84,7 @@ class NodeModel { bool? forceNoTor, bool? clearnetEnabled, bool? isPrimary, + String? nodeApiSecret, }) { return NodeModel( host: host ?? this.host, @@ -98,6 +102,7 @@ class NodeModel { clearnetEnabled: clearnetEnabled ?? this.clearnetEnabled, forceNoTor: forceNoTor ?? this.forceNoTor, isPrimary: isPrimary ?? this.isPrimary, + nodeApiSecret: nodeApiSecret ?? this.nodeApiSecret, ); } @@ -123,6 +128,7 @@ class NodeModel { map['clearEnabled'] = clearnetEnabled; map['forceNoTor'] = forceNoTor; map['isPrimary'] = isPrimary; + map['nodeApiSecret'] = nodeApiSecret; return map; } diff --git a/lib/models/paymint/fee_object_model.dart b/lib/models/paymint/fee_object_model.dart index 0c00f807f0..888f8edf42 100644 --- a/lib/models/paymint/fee_object_model.dart +++ b/lib/models/paymint/fee_object_model.dart @@ -44,4 +44,16 @@ class EthFeeObject extends FeeObject { required super.medium, required super.slow, }); + + @override + String toString() => + "{\n" + " fast: $fast,\n" + " medium: $medium,\n" + " slow: $slow,\n" + " suggestBaseFee: $suggestBaseFee,\n" + " numberOfBlocksFast: $numberOfBlocksFast,\n" + " numberOfBlocksAverage: $numberOfBlocksAverage,\n" + " numberOfBlocksSlow: $numberOfBlocksSlow,\n" + "}"; } diff --git a/lib/models/paynym/paynym_account.dart b/lib/models/paynym/paynym_account.dart index 4d44d43fc5..9950912235 100644 --- a/lib/models/paynym/paynym_account.dart +++ b/lib/models/paynym/paynym_account.dart @@ -37,24 +37,24 @@ class PaynymAccount { ); PaynymAccount.fromMap(Map map) - : nymID = map["nymID"] as String, - nymName = map["nymName"] as String, - segwit = map["segwit"] as bool, - codes = (map["codes"] as List) - .map((e) => PaynymCode.fromMap(Map.from(e as Map))) - .toList(), - followers = (map["followers"] as List) - .map( - (e) => PaynymAccountLite.fromMap( - Map.from(e as Map)), - ) - .toList(), - following = (map["following"] as List) - .map( - (e) => PaynymAccountLite.fromMap( - Map.from(e as Map)), - ) - .toList(); + : nymID = map["nymID"] as String, + nymName = map["nymName"] as String, + segwit = map["segwit"] as bool, + codes = (map["codes"] as List) + .map((e) => PaynymCode.fromMap(Map.from(e as Map))) + .toList(), + followers = (map["followers"] as List) + .map( + (e) => + PaynymAccountLite.fromMap(Map.from(e as Map)), + ) + .toList(), + following = (map["following"] as List) + .map( + (e) => + PaynymAccountLite.fromMap(Map.from(e as Map)), + ) + .toList(); PaynymAccount copyWith({ String? nymID, @@ -75,13 +75,13 @@ class PaynymAccount { } Map toMap() => { - "nymID": nymID, - "nymName": nymName, - "segwit": segwit, - "codes": codes.map((e) => e.toMap()), - "followers": followers.map((e) => e.toMap()), - "following": followers.map((e) => e.toMap()), - }; + "nymID": nymID, + "nymName": nymName, + "segwit": segwit, + "codes": codes.map((e) => e.toMap()), + "followers": followers.map((e) => e.toMap()), + "following": following.map((e) => e.toMap()), + }; @override String toString() { diff --git a/lib/models/paynym/paynym_account_lite.dart b/lib/models/paynym/paynym_account_lite.dart index 694efb7788..4702be49ed 100644 --- a/lib/models/paynym/paynym_account_lite.dart +++ b/lib/models/paynym/paynym_account_lite.dart @@ -1,6 +1,6 @@ -/* +/* * This file is part of Stack Wallet. - * + * * Copyright (c) 2023 Cypher Stack * All Rights Reserved. * The code is distributed under GPLv3 license, see LICENSE file for details. @@ -8,31 +8,50 @@ * */ +import 'package:bip47/bip47.dart'; +import 'package:bitcoindart/bitcoindart.dart' as bitcoindart; + class PaynymAccountLite { final String nymId; final String nymName; final String code; final bool segwit; + final bool taproot; PaynymAccountLite( this.nymId, this.nymName, this.code, - this.segwit, - ); + this.segwit, { + this.taproot = false, + }); PaynymAccountLite.fromMap(Map map) - : nymId = map["nymId"] as String, - nymName = map["nymName"] as String, - code = map["code"] as String, - segwit = map["segwit"] as bool; + : nymId = map["nymId"] as String, + nymName = map["nymName"] as String, + code = map["code"] as String, + segwit = map["segwit"] as bool, + taproot = map["taproot"] as bool? ?? inferTaproot(map["code"] as String); + + static bool inferTaproot(String paymentCodeString) { + try { + final pCode = PaymentCode.fromPaymentCode( + paymentCodeString, + networkType: bitcoindart.bitcoin, + ); + return pCode.isTaprootEnabled(); + } catch (_) { + return false; + } + } Map toMap() => { - "nymId": nymId, - "nymName": nymName, - "code": code, - "segwit": segwit, - }; + "nymId": nymId, + "nymName": nymName, + "code": code, + "segwit": segwit, + "taproot": taproot, + }; @override String toString() { diff --git a/lib/models/paynym/paynym_claim.dart b/lib/models/paynym/paynym_claim.dart index 0f1e66373a..e2733710b4 100644 --- a/lib/models/paynym/paynym_claim.dart +++ b/lib/models/paynym/paynym_claim.dart @@ -15,13 +15,10 @@ class PaynymClaim { PaynymClaim(this.claimed, this.token); PaynymClaim.fromMap(Map map) - : claimed = map["claimed"] as String, - token = map["token"] as String; + : claimed = map["claimed"].toString(), + token = map["token"] as String; - Map toMap() => { - "claimed": claimed, - "token": token, - }; + Map toMap() => {"claimed": claimed, "token": token}; @override String toString() { diff --git a/lib/models/shopinbit/shopinbit_enums.dart b/lib/models/shopinbit/shopinbit_enums.dart new file mode 100644 index 0000000000..eca8d63b3b --- /dev/null +++ b/lib/models/shopinbit/shopinbit_enums.dart @@ -0,0 +1,82 @@ +import 'dart:ui'; + +import "../../services/shopinbit/src/models/ticket.dart"; +import '../../themes/stack_colors.dart'; + +// Stable string identifiers — these names are persisted in the DB via +// `textEnum()`. Renaming any value silently corrupts existing rows; +// add new values to the end instead. + +enum ShopInBitCategory { + concierge, + travel, + car; + + /// Value used for `service_type` in `POST /requests`. Matches the API + /// spec strings exactly; equivalent to [name] for the current set. + String get apiValue => name; + + String get label => switch (this) { + .concierge => "Concierge", + .travel => "Travel", + .car => "Car", + }; +} + +enum ShopInBitOrderStatus { + pending, + reviewing, + offerAvailable, + accepted, + paymentPending, + paid, + shipping, + delivered, + closed, + cancelled, + refunded; + + String get label => switch (this) { + .pending => "Pending", + .reviewing => "Under review", + .offerAvailable => "Offer available", + .accepted => "Accepted", + .paymentPending => "Awaiting payment", + .paid => "Paid", + .shipping => "Shipping", + .delivered => "Delivered", + .closed => "Closed", + .cancelled => "Cancelled", + .refunded => "Refunded", + }; + + /// Maps a raw API ticket state to a customer-facing status. Returns null + /// for unrecognized states so the caller can decide whether to skip the + /// row entirely or keep the previous value. + static ShopInBitOrderStatus? fromTicketState(TicketState state) => + switch (state) { + .newTicket => ShopInBitOrderStatus.pending, + .checking || + .inProgress || + .replyNeeded => ShopInBitOrderStatus.reviewing, + .offerAvailable => ShopInBitOrderStatus.offerAvailable, + .clearing => ShopInBitOrderStatus.accepted, + .pendingClose => ShopInBitOrderStatus.paymentPending, + .shipped => ShopInBitOrderStatus.shipping, + .fulfilled => ShopInBitOrderStatus.delivered, + .closed || .merged => ShopInBitOrderStatus.closed, + .closedCancelled => ShopInBitOrderStatus.cancelled, + .refunded => ShopInBitOrderStatus.refunded, + .unknown => null, + }; +} + +extension ShopinbitStatusStyleExt on ShopInBitOrderStatus { + Color getColor(StackColors colors) => switch (this) { + .delivered => colors.accentColorGreen, + .offerAvailable => colors.accentColorBlue, + .pending || .reviewing => colors.accentColorYellow, + .closed || .cancelled || .refunded => colors.textSubtitle1, + _ => colors.accentColorDark, + }; +} diff --git a/lib/models/shopinbit/shopinbit_request_draft.dart b/lib/models/shopinbit/shopinbit_request_draft.dart new file mode 100644 index 0000000000..c4c3cdd1ba --- /dev/null +++ b/lib/models/shopinbit/shopinbit_request_draft.dart @@ -0,0 +1,36 @@ +import 'shopinbit_enums.dart'; + +class ShopinbitRequestDraft { + final ShopInBitCategory category; + final String requestDescription; + final String deliveryCountryName; + final String deliveryCountryCode; + final String? deliveryState; + final String? voucherCode; + + bool get requiresState => switch (deliveryCountryCode) { + "US" || "CA" => category != .travel, + _ => false, + }; + + ShopinbitRequestDraft({ + required this.category, + required this.requestDescription, + required this.deliveryCountryName, + required this.deliveryCountryCode, + required this.deliveryState, + required this.voucherCode, + }); + + Map toMap() => { + "category": category.apiValue, + "requestDescription": requestDescription, + "deliveryCountryName": deliveryCountryName, + "deliveryCountryCode": deliveryCountryCode, + "deliveryState": deliveryState, + "voucherCode": voucherCode, + }; + + @override + String toString() => toMap().toString(); +} diff --git a/lib/models/type_adaptors/node_model.g.dart b/lib/models/type_adaptors/node_model.g.dart index 218731a69c..1ab826d553 100644 --- a/lib/models/type_adaptors/node_model.g.dart +++ b/lib/models/type_adaptors/node_model.g.dart @@ -32,13 +32,14 @@ class NodeModelAdapter extends TypeAdapter { clearnetEnabled: fields[12] as bool? ?? true, forceNoTor: fields[13] as bool? ?? false, isPrimary: fields[14] as bool? ?? false, + nodeApiSecret: fields[15] as String?, ); } @override void write(BinaryWriter writer, NodeModel obj) { writer - ..writeByte(15) + ..writeByte(16) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -68,7 +69,9 @@ class NodeModelAdapter extends TypeAdapter { ..writeByte(13) ..write(obj.forceNoTor) ..writeByte(14) - ..write(obj.isPrimary); + ..write(obj.isPrimary) + ..writeByte(15) + ..write(obj.nodeApiSecret); } @override diff --git a/lib/networking/http.dart b/lib/networking/http.dart index 80ea57c370..821bdc4af9 100644 --- a/lib/networking/http.dart +++ b/lib/networking/http.dart @@ -14,33 +14,43 @@ class Response { final int code; final List bodyBytes; + // Lower-cased response header names mapped to their (comma-joined) values. + // Empty by default so existing callers/tests don't need to supply them. + final Map headers; + String get body => utf8.decode(bodyBytes, allowMalformed: true); - Response(this.bodyBytes, this.code); + Response(this.bodyBytes, this.code, {this.headers = const {}}); +} + +Map _headerMap(HttpClientResponse response) { + final map = {}; + response.headers.forEach((name, values) { + map[name.toLowerCase()] = values.join(', '); + }); + return map; } class HTTP { + const HTTP(); + Future get({ required Uri url, Map? headers, - required ({ - InternetAddress host, - int port, - })? proxyInfo, + required ({InternetAddress host, int port})? proxyInfo, + Duration? connectionTimeout, }) async { final httpClient = HttpClient(); + if (connectionTimeout != null) { + httpClient.connectionTimeout = connectionTimeout; + } try { if (proxyInfo != null) { SocksTCPClient.assignToHttpClient(httpClient, [ - ProxySettings( - proxyInfo.host, - proxyInfo.port, - ), + ProxySettings(proxyInfo.host, proxyInfo.port), ]); } - final HttpClientRequest request = await httpClient.getUrl( - url, - ); + final HttpClientRequest request = await httpClient.getUrl(url); if (headers != null) { headers.forEach((key, value) => request.headers.add(key, value)); @@ -51,6 +61,7 @@ class HTTP { return Response( await _bodyBytes(response), response.statusCode, + headers: _headerMap(response), ); } catch (e, s) { Logging.instance.w("HTTP.get() rethrew: ", error: e, stackTrace: s); @@ -65,24 +76,16 @@ class HTTP { Map? headers, Object? body, Encoding? encoding, - required ({ - InternetAddress host, - int port, - })? proxyInfo, + required ({InternetAddress host, int port})? proxyInfo, }) async { final httpClient = HttpClient(); try { if (proxyInfo != null) { SocksTCPClient.assignToHttpClient(httpClient, [ - ProxySettings( - proxyInfo.host, - proxyInfo.port, - ), + ProxySettings(proxyInfo.host, proxyInfo.port), ]); } - final HttpClientRequest request = await httpClient.postUrl( - url, - ); + final HttpClientRequest request = await httpClient.postUrl(url); if (headers != null) { headers.forEach((key, value) => request.headers.add(key, value)); @@ -94,6 +97,7 @@ class HTTP { return Response( await _bodyBytes(response), response.statusCode, + headers: _headerMap(response), ); } catch (e, s) { Logging.instance.w("HTTP.post() rethrew: ", error: e, stackTrace: s); @@ -103,6 +107,145 @@ class HTTP { } } + /// POST a raw byte body (e.g. an encoded multipart/form-data payload). + Future postBytes({ + required Uri url, + Map? headers, + required List bodyBytes, + required ({InternetAddress host, int port})? proxyInfo, + }) async { + final httpClient = HttpClient(); + try { + if (proxyInfo != null) { + SocksTCPClient.assignToHttpClient(httpClient, [ + ProxySettings(proxyInfo.host, proxyInfo.port), + ]); + } + final HttpClientRequest request = await httpClient.postUrl(url); + + if (headers != null) { + headers.forEach((key, value) => request.headers.add(key, value)); + } + + request.contentLength = bodyBytes.length; + request.add(bodyBytes); + + final response = await request.close(); + return Response( + await _bodyBytes(response), + response.statusCode, + headers: _headerMap(response), + ); + } catch (e, s) { + Logging.instance.w("HTTP.postBytes() rethrew: ", error: e, stackTrace: s); + rethrow; + } finally { + httpClient.close(force: true); + } + } + + Future put({ + required Uri url, + Map? headers, + Object? body, + required ({InternetAddress host, int port})? proxyInfo, + }) async { + final httpClient = HttpClient(); + try { + if (proxyInfo != null) { + SocksTCPClient.assignToHttpClient(httpClient, [ + ProxySettings(proxyInfo.host, proxyInfo.port), + ]); + } + final HttpClientRequest request = await httpClient.putUrl(url); + + if (headers != null) { + headers.forEach((key, value) => request.headers.add(key, value)); + } + + if (body != null) request.write(body); + + final response = await request.close(); + return Response( + await _bodyBytes(response), + response.statusCode, + headers: _headerMap(response), + ); + } catch (e, s) { + Logging.instance.w("HTTP.put() rethrew: ", error: e, stackTrace: s); + rethrow; + } finally { + httpClient.close(force: true); + } + } + + Future patch({ + required Uri url, + Map? headers, + Object? body, + required ({InternetAddress host, int port})? proxyInfo, + }) async { + final httpClient = HttpClient(); + try { + if (proxyInfo != null) { + SocksTCPClient.assignToHttpClient(httpClient, [ + ProxySettings(proxyInfo.host, proxyInfo.port), + ]); + } + final HttpClientRequest request = await httpClient.patchUrl(url); + + if (headers != null) { + headers.forEach((key, value) => request.headers.add(key, value)); + } + + request.write(body); + + final response = await request.close(); + return Response( + await _bodyBytes(response), + response.statusCode, + headers: _headerMap(response), + ); + } catch (e, s) { + Logging.instance.w("HTTP.patch() rethrew: ", error: e, stackTrace: s); + rethrow; + } finally { + httpClient.close(force: true); + } + } + + Future delete({ + required Uri url, + Map? headers, + required ({InternetAddress host, int port})? proxyInfo, + }) async { + final httpClient = HttpClient(); + try { + if (proxyInfo != null) { + SocksTCPClient.assignToHttpClient(httpClient, [ + ProxySettings(proxyInfo.host, proxyInfo.port), + ]); + } + final HttpClientRequest request = await httpClient.deleteUrl(url); + + if (headers != null) { + headers.forEach((key, value) => request.headers.add(key, value)); + } + + final response = await request.close(); + return Response( + await _bodyBytes(response), + response.statusCode, + headers: _headerMap(response), + ); + } catch (e, s) { + Logging.instance.w("HTTP.delete() rethrew: ", error: e, stackTrace: s); + rethrow; + } finally { + httpClient.close(force: true); + } + } + Future _bodyBytes(HttpClientResponse response) { final completer = Completer(); final List bytes = []; @@ -110,14 +253,18 @@ class HTTP { (data) { bytes.addAll(data); }, - onDone: () => completer.complete( - Uint8List.fromList(bytes), - ), - onError: (Object err, StackTrace s) => Logging.instance.e( - "Http wrapper layer listen", - error: err, - stackTrace: s, - ), + onDone: () => completer.complete(Uint8List.fromList(bytes)), + onError: (Object err, StackTrace s) { + Logging.instance.e( + "Http wrapper layer listen", + error: err, + stackTrace: s, + ); + if (!completer.isCompleted) { + completer.completeError(err, s); + } + }, + cancelOnError: true, ); return completer.future; } diff --git a/lib/notifications/notification_card.dart b/lib/notifications/notification_card.dart index 2176082252..ed2bce897d 100644 --- a/lib/notifications/notification_card.dart +++ b/lib/notifications/notification_card.dart @@ -21,11 +21,8 @@ import '../themes/coin_icon_provider.dart'; import '../themes/stack_colors.dart'; import '../themes/theme_providers.dart'; import '../utilities/format.dart'; -import '../utilities/text_styles.dart'; import '../utilities/util.dart'; -import '../widgets/conditional_parent.dart'; -import '../widgets/rounded_container.dart'; -import '../widgets/rounded_white_container.dart'; +import 'notification_card_layout.dart'; class NotificationCard extends ConsumerWidget { const NotificationCard({ @@ -40,9 +37,6 @@ class NotificationCard extends ConsumerWidget { return Format.extractDateFrom(date.millisecondsSinceEpoch ~/ 1000); } - static const double mobileIconSize = 24; - static const double desktopIconSize = 30; - String coinIconPath(IThemeAssets assets, WidgetRef ref) { try { final coin = @@ -56,137 +50,36 @@ class NotificationCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isDesktop = Util.isDesktop; + final double iconSize = isDesktop + ? NotificationCardLayout.desktopIconSize + : NotificationCardLayout.mobileIconSize; + final iconFile = File(coinIconPath(ref.watch(themeAssetsProvider), ref)); - return Stack( - children: [ - RoundedWhiteContainer( - padding: isDesktop - ? const EdgeInsets.symmetric( - horizontal: 20, - vertical: 10, - ) - : const EdgeInsets.all(12), - child: Row( - children: [ - notification.changeNowId == null - ? SvgPicture.file( - File( - coinIconPath( - ref.watch( - themeAssetsProvider, - ), - ref, - ), - ), - width: isDesktop ? desktopIconSize : mobileIconSize, - height: isDesktop ? desktopIconSize : mobileIconSize, - ) - : Container( - width: isDesktop ? desktopIconSize : mobileIconSize, - height: isDesktop ? desktopIconSize : mobileIconSize, - decoration: BoxDecoration( - color: Colors.transparent, - borderRadius: BorderRadius.circular(24), - ), - child: SvgPicture.file( - File( - coinIconPath( - ref.watch( - themeAssetsProvider, - ), - ref, - ), - ), - color: Theme.of(context) - .extension()! - .accentColorDark, - width: isDesktop ? desktopIconSize : mobileIconSize, - height: isDesktop ? desktopIconSize : mobileIconSize, - ), - ), - const SizedBox( - width: 12, - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ConditionalParent( - condition: isDesktop && !notification.read, - builder: (child) => Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - child, - Text( - "New", - style: - STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - color: Theme.of(context) - .extension()! - .accentColorGreen, - ), - ), - ], - ), - child: Text( - notification.title, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ) - : STextStyles.titleBold12(context), - ), - ), - const SizedBox( - height: 2, - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - notification.description, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - color: Theme.of(context) - .extension()! - .textSubtitle1, - ) - : STextStyles.label(context), - ), - Text( - extractPrettyDateString(notification.date), - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - color: Theme.of(context) - .extension()! - .textSubtitle1, - ) - : STextStyles.label(context), - ), - ], - ), - ], - ), - ), - ], - ), - ), - if (notification.read) - Positioned.fill( - child: RoundedContainer( + final Widget icon = notification.changeNowId == null + ? SvgPicture.file(iconFile, width: iconSize, height: iconSize) + : Container( + width: iconSize, + height: iconSize, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(24), + ), + child: SvgPicture.file( + iconFile, color: Theme.of(context) .extension()! - .background - .withOpacity(0.5), + .accentColorDark, + width: iconSize, + height: iconSize, ), - ), - ], + ); + + return NotificationCardLayout( + icon: icon, + title: notification.title, + body: notification.description, + dateString: extractPrettyDateString(notification.date), + read: notification.read, ); } } diff --git a/lib/notifications/notification_card_layout.dart b/lib/notifications/notification_card_layout.dart new file mode 100644 index 0000000000..44340ad245 --- /dev/null +++ b/lib/notifications/notification_card_layout.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +import '../themes/stack_colors.dart'; +import '../utilities/text_styles.dart'; +import '../utilities/util.dart'; +import '../widgets/rounded_container.dart'; +import '../widgets/rounded_white_container.dart'; + +class NotificationCardLayout extends StatelessWidget { + const NotificationCardLayout({ + super.key, + required this.icon, + required this.title, + required this.body, + required this.dateString, + required this.read, + }); + + final Widget icon; + final String title; + final String body; + final String dateString; + final bool read; + + static const double mobileIconSize = 24; + static const double desktopIconSize = 30; + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final colors = Theme.of(context).extension()!; + + final TextStyle titleStyle = isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith(color: colors.textDark) + : STextStyles.titleBold12(context); + final TextStyle subStyle = isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith(color: colors.textSubtitle1) + : STextStyles.label(context); + + return Stack( + children: [ + RoundedWhiteContainer( + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 20, vertical: 10) + : const EdgeInsets.all(12), + child: Row( + children: [ + icon, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: Text(title, style: titleStyle)), + if (isDesktop && !read) + Text( + "New", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith(color: colors.accentColorGreen), + ), + ], + ), + const SizedBox(height: 2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: Text(body, style: subStyle)), + const SizedBox(width: 8), + Text(dateString, style: subStyle), + ], + ), + ], + ), + ), + ], + ), + ), + if (read) + Positioned.fill( + child: RoundedContainer(color: colors.background.withOpacity(0.5)), + ), + ], + ); + } +} diff --git a/lib/notifications/notification_feed_entry.dart b/lib/notifications/notification_feed_entry.dart new file mode 100644 index 0000000000..2d430f0b90 --- /dev/null +++ b/lib/notifications/notification_feed_entry.dart @@ -0,0 +1,30 @@ +import '../db/drift/shared_db/shared_database.dart'; +import '../models/notification_model.dart'; + +sealed class NotificationFeedEntry { + DateTime get date; +} + +class HiveFeedEntry extends NotificationFeedEntry { + HiveFeedEntry(this.model); + final NotificationModel model; + @override + DateTime get date => model.date; +} + +class AppFeedEntry extends NotificationFeedEntry { + AppFeedEntry(this.notification); + final AppNotification notification; + @override + DateTime get date => notification.createdAt; +} + +List mergeNotificationFeed( + List hive, + List app, +) { + return [ + ...hive.map(HiveFeedEntry.new), + ...app.map(AppFeedEntry.new), + ]..sort((a, b) => b.date.compareTo(a.date)); +} diff --git a/lib/notifications/notification_feed_entry_card.dart b/lib/notifications/notification_feed_entry_card.dart new file mode 100644 index 0000000000..03973d71c4 --- /dev/null +++ b/lib/notifications/notification_feed_entry_card.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/ui/unread_notifications_provider.dart'; +import 'notification_card.dart'; +import 'notification_feed_entry.dart'; +import 'shopinbit_notification_card.dart'; + +class NotificationFeedEntryCard extends ConsumerWidget { + const NotificationFeedEntryCard({super.key, required this.entry}); + + final NotificationFeedEntry entry; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final entry = this.entry; + if (entry is HiveFeedEntry && entry.model.read == false) { + ref + .read(unreadNotificationsStateProvider.state) + .state + .add(entry.model.id); + } + + return switch (entry) { + HiveFeedEntry e => NotificationCard(notification: e.model), + AppFeedEntry e => ShopInBitNotificationCard(notification: e.notification), + }; + } +} diff --git a/lib/notifications/shopinbit_notification_card.dart b/lib/notifications/shopinbit_notification_card.dart new file mode 100644 index 0000000000..1ae8eafd8e --- /dev/null +++ b/lib/notifications/shopinbit_notification_card.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../db/drift/shared_db/shared_database.dart'; +import '../themes/stack_colors.dart'; +import '../utilities/assets.dart'; +import '../utilities/format.dart'; +import '../utilities/util.dart'; +import 'notification_card_layout.dart'; + +class ShopInBitNotificationCard extends StatelessWidget { + const ShopInBitNotificationCard({super.key, required this.notification}); + + final AppNotification notification; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).extension()!; + final double iconSize = Util.isDesktop + ? NotificationCardLayout.desktopIconSize + : NotificationCardLayout.mobileIconSize; + + return NotificationCardLayout( + icon: SvgPicture.asset( + notification.iconAsset ?? Assets.svg.sib, + width: iconSize, + height: iconSize, + color: colors.accentColorDark, + ), + title: notification.title, + body: notification.body, + dateString: Format.extractDateFrom( + notification.createdAt.millisecondsSinceEpoch ~/ 1000, + ), + read: notification.read, + ); + } +} diff --git a/lib/notifications/show_flush_bar.dart b/lib/notifications/show_flush_bar.dart index b955a41ec2..8bbd88cc20 100644 --- a/lib/notifications/show_flush_bar.dart +++ b/lib/notifications/show_flush_bar.dart @@ -8,8 +8,9 @@ * */ +import 'dart:async'; + import 'package:another_flushbar/flushbar.dart'; -import 'package:another_flushbar/flushbar_route.dart' as flushRoute; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; @@ -26,6 +27,9 @@ Future showFloatingFlushBar({ required BuildContext context, Duration? duration = const Duration(milliseconds: 1500), FlushbarPosition flushbarPosition = FlushbarPosition.TOP, + @Deprecated( + 'onTap is non-functional -- toasts are fully passive with IgnorePointer', + ) VoidCallback? onTap, }) { Color bg; @@ -45,34 +49,126 @@ Future showFloatingFlushBar({ break; } final bar = Flushbar( - onTap: (_) { - onTap?.call(); - }, + onTap: null, + isDismissible: false, icon: iconAsset != null - ? SvgPicture.asset( - iconAsset, - height: 16, - width: 16, - color: fg, - ) + ? SvgPicture.asset(iconAsset, height: 16, width: 16, color: fg) : null, message: message, messageColor: fg, flushbarPosition: flushbarPosition, backgroundColor: bg, - duration: duration, + duration: null, flushbarStyle: FlushbarStyle.FLOATING, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), + borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), margin: const EdgeInsets.all(20), maxWidth: 550, ); - final _route = flushRoute.showFlushbar( - context: context, - flushbar: bar, + final completer = Completer(); + final overlay = Overlay.of(context, rootOverlay: true); + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (context) => _OverlayFlushbar( + animationDuration: const Duration(seconds: 1), + displayDuration: duration, + forwardCurve: Curves.easeOutCirc, + reverseCurve: Curves.easeOutCirc, + initialAlignment: const Alignment(-1.0, -2.0), + endAlignment: const Alignment(-1.0, -1.0), + onDismiss: () { + entry.remove(); + if (!completer.isCompleted) { + completer.complete(); + } + }, + child: SafeArea( + child: Container(margin: const EdgeInsets.all(20), child: bar), + ), + ), ); + overlay.insert(entry); + return completer.future; +} + +class _OverlayFlushbar extends StatefulWidget { + const _OverlayFlushbar({ + required this.child, + required this.animationDuration, + required this.forwardCurve, + required this.reverseCurve, + required this.initialAlignment, + required this.endAlignment, + required this.onDismiss, + this.displayDuration, + }); + + final Widget child; + final Duration animationDuration; + final Duration? displayDuration; + final Curve forwardCurve; + final Curve reverseCurve; + final Alignment initialAlignment; + final Alignment endAlignment; + final VoidCallback onDismiss; + + @override + State<_OverlayFlushbar> createState() => _OverlayFlushbarState(); +} - return Navigator.of(context, rootNavigator: true).push(_route); +class _OverlayFlushbarState extends State<_OverlayFlushbar> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _animation; + Timer? _timer; + bool _dismissed = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: widget.animationDuration, + vsync: this, + ); + _animation = + AlignmentTween( + begin: widget.initialAlignment, + end: widget.endAlignment, + ).animate( + CurvedAnimation( + parent: _controller, + curve: widget.forwardCurve, + reverseCurve: widget.reverseCurve, + ), + ); + _controller.forward(); + if (widget.displayDuration != null) { + _timer = Timer(widget.displayDuration!, _dismiss); + } + } + + void _dismiss() { + if (_dismissed) return; + _dismissed = true; + _controller.reverse().then((_) { + if (mounted) { + widget.onDismiss(); + } + }); + } + + @override + void dispose() { + _timer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlignTransition( + alignment: _animation, + child: IgnorePointer(child: widget.child), + ); + } } diff --git a/lib/pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dart b/lib/pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dart new file mode 100644 index 0000000000..c5f13088b8 --- /dev/null +++ b/lib/pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dart @@ -0,0 +1,525 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/isar/models/solana/sol_contract.dart'; +import '../../../services/solana/solana_token_api.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../widgets/background.dart'; +import '../../../widgets/conditional_parent.dart'; +import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../widgets/desktop/desktop_dialog.dart'; +import '../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/stack_dialog.dart'; + +class AddCustomSolanaTokenView extends ConsumerStatefulWidget { + const AddCustomSolanaTokenView({super.key, this.walletId}); + + static const routeName = "/addCustomSolanaToken"; + + final String? walletId; + + @override + ConsumerState createState() => + _AddCustomSolanaTokenViewState(); +} + +class _AddCustomSolanaTokenViewState + extends ConsumerState { + final isDesktop = Util.isDesktop; + + final mintController = TextEditingController(); + final nameController = TextEditingController(); + final symbolController = TextEditingController(); + final decimalsController = TextEditingController(); + + bool enableSubFields = false; + bool addTokenButtonEnabled = false; + + SolContract? currentToken; + + Future _searchTokenMetadata() async { + debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Search button pressed'); + + // Validate mint address format first. + final tokenApi = SolanaTokenAPI(); + final isValid = tokenApi.isValidSolanaMintAddress( + mintController.text.trim(), + ); + + debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Mint address valid: $isValid'); + + // Check if token is already in the wallet. + if (widget.walletId != null) { + final walletInfo = ref.read(pWalletInfo(widget.walletId!)); + final allTokenMints = { + ...walletInfo.solanaTokenMintAddresses, + ...walletInfo.solanaCustomTokenMintAddresses, + }; + + if (allTokenMints.contains(mintController.text.trim())) { + debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Token already in wallet'); + setState(() { + addTokenButtonEnabled = false; + }); + // Show error dialog for duplicate token. + if (mounted) { + unawaited( + showDialog( + context: context, + builder: (dialogContext) => ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopDialog( + maxWidth: 500, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.all(32), + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => StackDialogBase(child: child), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Token Already Added", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 8), + Text( + "This token is already in your wallet.", + style: STextStyles.smallMed14(context), + ), + const SizedBox(height: 20), + Row( + children: [ + const Spacer(), + Expanded( + child: PrimaryButton( + label: "OK", + onPressed: () => + Navigator.of(dialogContext).pop(), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + return; + } + } + + if (!isValid) { + setState(() { + addTokenButtonEnabled = false; + }); + // Show error dialog for invalid address. + if (mounted) { + unawaited( + showDialog( + context: context, + builder: (dialogContext) => ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopDialog( + maxWidth: 500, + maxHeight: double.infinity, + child: Padding(padding: const EdgeInsets.all(32), child: child), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => StackDialogBase(child: child), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Invalid Mint Address", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 8), + Text( + "Please enter a valid Solana token mint address " + "(base58 encoded, ~44 characters).", + style: STextStyles.smallMed14(context), + ), + const SizedBox(height: 20), + Row( + children: [ + const Spacer(), + Expanded( + child: PrimaryButton( + label: "OK", + onPressed: () => Navigator.of(dialogContext).pop(), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + return; + } + + // Fetch token metadata. + debugPrint( + '[ADD_CUSTOM_SOLANA_TOKEN] Fetching metadata for' + ' mint: ${mintController.text.trim()}', + ); + final response = await tokenApi.fetchTokenMetadataByMint( + mintController.text.trim(), + ); + + if (!mounted) return; + + debugPrint( + '[ADD_CUSTOM_SOLANA_TOKEN] Metadata response: ${response.value}', + ); + + if (response.value != null && response.value!.isNotEmpty) { + final metadata = response.value!; + currentToken = SolContract( + address: mintController.text.trim(), + name: metadata['name'] as String? ?? 'Unknown Token', + symbol: metadata['symbol'] as String? ?? '???', + decimals: int.tryParse(metadata['decimals']?.toString() ?? "") ?? 6, + logoUri: metadata['logoUri'] as String?, + ); + + nameController.text = currentToken!.name; + symbolController.text = currentToken!.symbol; + decimalsController.text = currentToken!.decimals.toString(); + + // Disable editing when we have metadata. + setState(() { + enableSubFields = false; + addTokenButtonEnabled = currentToken != null; + }); + debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Metadata found, fields populated'); + } else { + // Token not found, allow user to manually enter details. + debugPrint( + '[ADD_CUSTOM_SOLANA_TOKEN] Metadata not found, enabling manual entry', + ); + nameController.text = ""; + symbolController.text = ""; + decimalsController.text = ""; + + // Enable fields for manual entry and allow user to create token with + // custom values. + setState(() { + enableSubFields = true; + currentToken = SolContract( + address: mintController.text.trim(), + name: '', + symbol: '', + decimals: 6, + logoUri: null, + ); + // Allow adding token once mint is validated. + addTokenButtonEnabled = true; + }); + + // Show dialog for manual entry & alert the user they need to enter + // details manually. + if (mounted) { + unawaited( + showDialog( + context: context, + builder: (dialogContext) => ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopDialog( + maxWidth: 500, + maxHeight: double.infinity, + child: Padding(padding: const EdgeInsets.all(32), child: child), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => StackDialogBase(child: child), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Metadata Not Found", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 8), + Text( + "Could not fetch token metadata. Please enter the token" + " details manually below.", + style: STextStyles.smallMed14(context), + ), + const SizedBox(height: 20), + Row( + children: [ + const Spacer(), + Expanded( + child: PrimaryButton( + label: "OK", + onPressed: () => Navigator.of(dialogContext).pop(), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only( + top: 10, + left: 16, + right: 16, + bottom: 16, + ), + child: child, + ), + ), + ), + ), + child: ConditionalParent( + condition: isDesktop, + builder: (child) => Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Add custom SOL token", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + top: 16, + ), + child: child, + ), + ), + ], + ), + child: Column( + children: [ + if (!isDesktop) + Text( + "Add custom SOL token", + style: STextStyles.pageTitleH1(context), + ), + if (!isDesktop) const SizedBox(height: 16), + TextField( + autocorrect: !isDesktop, + enableSuggestions: !isDesktop, + controller: mintController, + style: STextStyles.field(context), + decoration: InputDecoration( + hintText: "SOL token mint address", + hintStyle: STextStyles.fieldLabel(context), + ), + ), + SizedBox(height: isDesktop ? 16 : 8), + PrimaryButton(label: "Search", onPressed: _searchTokenMetadata), + SizedBox(height: isDesktop ? 16 : 8), + TextField( + enabled: enableSubFields, + autocorrect: !isDesktop, + enableSuggestions: !isDesktop, + controller: nameController, + style: STextStyles.field(context), + decoration: InputDecoration( + hintText: "Token name", + hintStyle: STextStyles.fieldLabel(context), + ), + ), + SizedBox(height: isDesktop ? 16 : 8), + if (isDesktop) + Row( + children: [ + Expanded( + child: TextField( + enabled: enableSubFields, + autocorrect: !isDesktop, + enableSuggestions: !isDesktop, + controller: symbolController, + style: STextStyles.field(context), + decoration: InputDecoration( + hintText: "Ticker", + hintStyle: STextStyles.fieldLabel(context), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextField( + enabled: enableSubFields, + autocorrect: !isDesktop, + enableSuggestions: !isDesktop, + controller: decimalsController, + style: STextStyles.field(context), + inputFormatters: [ + TextInputFormatter.withFunction( + (oldValue, newValue) => + RegExp(r'^([0-9]*)$').hasMatch(newValue.text) + ? newValue + : oldValue, + ), + ], + keyboardType: const TextInputType.numberWithOptions( + signed: false, + decimal: false, + ), + decoration: InputDecoration( + hintText: "Decimals", + hintStyle: STextStyles.fieldLabel(context), + ), + ), + ), + ], + ), + if (!isDesktop) + TextField( + enabled: enableSubFields, + autocorrect: !isDesktop, + enableSuggestions: !isDesktop, + controller: symbolController, + style: STextStyles.field(context), + decoration: InputDecoration( + hintText: "Ticker", + hintStyle: STextStyles.fieldLabel(context), + ), + ), + if (!isDesktop) const SizedBox(height: 8), + if (!isDesktop) + TextField( + enabled: enableSubFields, + autocorrect: !isDesktop, + enableSuggestions: !isDesktop, + controller: decimalsController, + style: STextStyles.field(context), + inputFormatters: [ + TextInputFormatter.withFunction( + (oldValue, newValue) => + RegExp(r'^([0-9]*)$').hasMatch(newValue.text) + ? newValue + : oldValue, + ), + ], + keyboardType: const TextInputType.numberWithOptions( + signed: false, + decimal: false, + ), + decoration: InputDecoration( + hintText: "Decimals", + hintStyle: STextStyles.fieldLabel(context), + ), + ), + const SizedBox(height: 16), + const Spacer(), + Row( + children: [ + if (isDesktop) + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + if (isDesktop) const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Add token", + enabled: addTokenButtonEnabled, + onPressed: () { + // Update currentToken with user-entered values. + if (currentToken != null) { + final finalToken = currentToken!.copyWith( + name: nameController.text.isNotEmpty + ? nameController.text + : currentToken!.name, + symbol: symbolController.text.isNotEmpty + ? symbolController.text + : currentToken!.symbol, + decimals: decimalsController.text.isNotEmpty + ? int.tryParse(decimalsController.text) ?? + currentToken!.decimals + : currentToken!.decimals, + ); + Navigator.of(context).pop(finalToken); + } + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + + @override + void dispose() { + mintController.dispose(); + nameController.dispose(); + symbolController.dispose(); + decimalsController.dispose(); + super.dispose(); + } +} diff --git a/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart b/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart index b1f07cec75..d81afe51e5 100644 --- a/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart +++ b/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart @@ -17,6 +17,7 @@ import 'package:isar_community/isar.dart'; import '../../../db/isar/main_db.dart'; import '../../../models/isar/models/ethereum/eth_contract.dart'; +import '../../../models/isar/models/solana/sol_contract.dart'; import '../../../notifications/show_flush_bar.dart'; import '../../../pages_desktop_specific/desktop_home_view.dart'; import '../../../providers/global/price_provider.dart'; @@ -25,10 +26,12 @@ import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; import '../../../utilities/constants.dart'; import '../../../utilities/default_eth_tokens.dart'; +import '../../../utilities/default_sol_tokens.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/wallet/impl/ethereum_wallet.dart'; +import '../../../wallets/wallet/impl/solana_wallet.dart'; import '../../../widgets/background.dart'; import '../../../widgets/conditional_parent.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -44,6 +47,7 @@ import '../../../widgets/stack_text_field.dart'; import '../../../widgets/textfield_icon_button.dart'; import '../../home_view/home_view.dart'; import 'add_custom_token_view.dart'; +import 'add_custom_solana_token_view.dart'; import 'sub_widgets/add_token_list.dart'; import 'sub_widgets/add_token_list_element.dart'; import 'sub_widgets/add_token_text.dart'; @@ -102,10 +106,14 @@ class _EditWalletTokensViewState extends ConsumerState { .map((e) => e.token.address) .toList(); - final ethWallet = - ref.read(pWallets).getWallet(widget.walletId) as EthereumWallet; + final wallet = ref.read(pWallets).getWallet(widget.walletId); - await ethWallet.updateTokenContracts(selectedTokens); + // Handle tokens. + if (wallet is EthereumWallet) { + await wallet.updateTokenContracts(selectedTokens); + } else if (wallet is SolanaWallet) { + await wallet.updateSolanaTokens(selectedTokens); + } if (mounted) { if (widget.contractsToMarkSelected == null) { Navigator.of(context).pop(42); @@ -123,7 +131,7 @@ class _EditWalletTokensViewState extends ConsumerState { unawaited( showFloatingFlushBar( type: FlushBarType.success, - message: "${ethWallet.info.name} tokens saved", + message: "${wallet.info.name} tokens saved", context: context, ), ); @@ -133,35 +141,95 @@ class _EditWalletTokensViewState extends ConsumerState { } Future _addToken() async { - EthContract? contract; + final wallet = ref.read(pWallets).getWallet(widget.walletId); + + if (wallet is SolanaWallet) { + // For Solana wallets, navigate to custom token addition screen. + await _addCustomSolanaToken(); + } else { + // Original Ethereum token handling. + EthContract? contract; + + if (isDesktop) { + contract = await showDialog( + context: context, + builder: (context) => const DesktopDialog( + maxWidth: 580, + maxHeight: 500, + child: AddCustomTokenView(), + ), + ); + } else { + final result = await Navigator.of( + context, + ).pushNamed(AddCustomTokenView.routeName); + contract = result as EthContract?; + } + + if (contract != null) { + await MainDB.instance.putEthContract(contract); + unawaited(ref.read(priceAnd24hChangeNotifierProvider).updatePrice()); + if (mounted) { + setState(() { + if (tokenEntities + .where((e) => e.token.address == contract!.address) + .isEmpty) { + tokenEntities.add( + AddTokenListElementData(contract!)..selected = true, + ); + tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name)); + } + }); + } + } + } + } + + /// Navigate to add custom Solana token view and handle the result. + Future _addCustomSolanaToken() async { + SolContract? token; if (isDesktop) { - contract = await showDialog( + token = await showDialog( context: context, - builder: - (context) => const DesktopDialog( - maxWidth: 580, - maxHeight: 500, - child: AddCustomTokenView(), - ), + builder: (context) => DesktopDialog( + maxWidth: 580, + maxHeight: 500, + child: AddCustomSolanaTokenView(walletId: widget.walletId), + ), ); } else { final result = await Navigator.of( context, - ).pushNamed(AddCustomTokenView.routeName); - contract = result as EthContract?; + ).pushNamed( + AddCustomSolanaTokenView.routeName, + arguments: widget.walletId, + ); + token = result as SolContract?; } - if (contract != null) { - await MainDB.instance.putEthContract(contract); + if (token != null) { + await MainDB.instance.putSolContract(token); + + // Also add the custom token mint address to the wallet's custom token list. + final wallet = ref.read(pWallets).getWallet(widget.walletId); + if (wallet is SolanaWallet) { + final currentCustomTokens = wallet.info.solanaCustomTokenMintAddresses; + currentCustomTokens.add(token.address); + await wallet.info.updateSolanaCustomTokenMintAddresses( + newMintAddresses: currentCustomTokens, + isar: MainDB.instance.isar, + ); + } + unawaited(ref.read(priceAnd24hChangeNotifierProvider).updatePrice()); if (mounted) { setState(() { if (tokenEntities - .where((e) => e.token.address == contract!.address) + .where((e) => e.token.address == token!.address) .isEmpty) { tokenEntities.add( - AddTokenListElementData(contract!)..selected = true, + AddTokenListElementData(token!)..selected = true, ); tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name)); } @@ -175,20 +243,43 @@ class _EditWalletTokensViewState extends ConsumerState { _searchFieldController = TextEditingController(); _searchFocusNode = FocusNode(); - final contracts = - MainDB.instance.getEthContracts().sortByName().findAllSync(); + final wallet = ref.read(pWallets).getWallet(widget.walletId); - if (contracts.isEmpty) { - contracts.addAll(DefaultTokens.list); - MainDB.instance - .putEthContracts(contracts) - .then( - (_) => ref.read(priceAnd24hChangeNotifierProvider).updatePrice(), - ); - } + if (wallet is SolanaWallet) { + final contracts = MainDB.instance + .getSolContracts() + .sortByName() + .findAllSync(); + + if (contracts.isEmpty) { + contracts.addAll(DefaultSolTokens.list); + MainDB.instance + .putSolContracts(contracts) + .then( + (_) => ref.read(priceAnd24hChangeNotifierProvider).updatePrice(), + ); + } - tokenEntities.addAll(contracts.map((e) => AddTokenListElementData(e))); + tokenEntities.addAll(contracts.map((e) => AddTokenListElementData(e))); + } else { + final contracts = MainDB.instance + .getEthContracts() + .sortByName() + .findAllSync(); + + if (contracts.isEmpty) { + contracts.addAll(DefaultTokens.list); + MainDB.instance + .putEthContracts(contracts) + .then( + (_) => ref.read(priceAnd24hChangeNotifierProvider).updatePrice(), + ); + } + tokenEntities.addAll(contracts.map((e) => AddTokenListElementData(e))); + } + + // Get token addresses. final walletContracts = ref.read(pWalletTokenAddresses(widget.walletId)); final shouldMarkAsSelectedContracts = [ @@ -218,135 +309,129 @@ class _EditWalletTokensViewState extends ConsumerState { if (isDesktop) { return ConditionalParent( condition: !widget.isDesktopPopup, - builder: - (child) => DesktopScaffold( - appBar: DesktopAppBar( - isCompactHeight: false, - useSpacers: false, - leading: const AppBarBackButton(), - overlayCenter: Text( - walletName, - style: STextStyles.desktopSubtitleH2(context), - ), - trailing: - widget.contractsToMarkSelected == null - ? Padding( - padding: const EdgeInsets.only(right: 24), - child: SizedBox( - height: 56, - child: TextButton( - style: Theme.of(context) - .extension()! - .getSmallSecondaryEnabledButtonStyle(context), - onPressed: _addToken, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 30, - ), - child: Text( - "Add custom token", - style: - STextStyles.desktopButtonSmallSecondaryEnabled( - context, - ), + builder: (child) => DesktopScaffold( + appBar: DesktopAppBar( + isCompactHeight: false, + useSpacers: false, + leading: const AppBarBackButton(), + overlayCenter: Text( + walletName, + style: STextStyles.desktopSubtitleH2(context), + ), + trailing: widget.contractsToMarkSelected == null + ? Padding( + padding: const EdgeInsets.only(right: 24), + child: SizedBox( + height: 56, + child: TextButton( + style: Theme.of(context) + .extension()! + .getSmallSecondaryEnabledButtonStyle(context), + onPressed: _addToken, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 30), + child: Text( + "Add custom token", + style: + STextStyles.desktopButtonSmallSecondaryEnabled( + context, ), - ), - ), ), - ) - : null, - ), - body: SizedBox( - width: 480, - child: Column( - children: [ - const AddTokenText(isDesktop: true), - const SizedBox(height: 16), - Expanded( - child: RoundedWhiteContainer( - radiusMultiplier: 2, - padding: const EdgeInsets.only( - left: 20, - top: 20, - right: 20, - bottom: 0, ), - child: child, ), ), - const SizedBox(height: 26), - SizedBox( - height: 70, - width: 480, - child: PrimaryButton( - label: - widget.contractsToMarkSelected != null - ? "Save" - : "Next", - onPressed: onNextPressed, - ), + ) + : null, + ), + body: SizedBox( + width: 480, + child: Column( + children: [ + const AddTokenText(isDesktop: true), + const SizedBox(height: 16), + Expanded( + child: RoundedWhiteContainer( + radiusMultiplier: 2, + padding: const EdgeInsets.only( + left: 20, + top: 20, + right: 20, + bottom: 0, ), - const SizedBox(height: 32), - ], + child: child, + ), ), - ), + const SizedBox(height: 26), + SizedBox( + height: 70, + width: 480, + child: PrimaryButton( + label: widget.contractsToMarkSelected != null + ? "Save" + : "Next", + onPressed: onNextPressed, + ), + ), + const SizedBox(height: 32), + ], ), + ), + ), child: ConditionalParent( condition: widget.isDesktopPopup, - builder: - (child) => DesktopDialog( - maxHeight: 670, - child: Column( - mainAxisSize: MainAxisSize.min, + builder: (child) => DesktopDialog( + maxHeight: 670, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Edit tokens", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, - ), - child: child, - ), - ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Row( - children: [ - Expanded( - child: SecondaryButton( - label: "Add custom token", - buttonHeight: ButtonHeight.l, - onPressed: _addToken, - ), - ), - const SizedBox(width: 16), - Expanded( - child: PrimaryButton( - label: "Done", - buttonHeight: ButtonHeight.l, - onPressed: onNextPressed, - ), - ), - ], + padding: const EdgeInsets.only(left: 32), + child: Text( + "Edit tokens", + style: STextStyles.desktopH3(context), ), ), - const SizedBox(height: 32), + const DesktopDialogCloseButton(), ], ), - ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + child: child, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Add custom token", + buttonHeight: ButtonHeight.l, + onPressed: _addToken, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Done", + buttonHeight: ButtonHeight.l, + onPressed: onNextPressed, + ), + ), + ], + ), + ), + const SizedBox(height: 32), + ], + ), + ), child: Column( children: [ ClipRRect( @@ -366,49 +451,53 @@ class _EditWalletTokensViewState extends ConsumerState { style: STextStyles.desktopTextMedium( context, ).copyWith(height: 2), - decoration: standardInputDecoration( - "Search", - _searchFocusNode, - context, - ).copyWith( - contentPadding: const EdgeInsets.symmetric(vertical: 10), - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - // vertical: 20, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 24, - height: 24, - color: - Theme.of(context) + decoration: + standardInputDecoration( + "Search", + _searchFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.symmetric( + vertical: 10, + ), + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + // vertical: 20, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 24, + height: 24, + color: Theme.of(context) .extension()! .textFieldDefaultSearchIconLeft, - ), - ), - suffixIcon: - _searchFieldController.text.isNotEmpty + ), + ), + suffixIcon: _searchFieldController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 10), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(width: 24, height: 24), - onTap: () async { - setState(() { - _searchFieldController.text = ""; - _searchTerm = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 10), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon( + width: 24, + height: 24, + ), + onTap: () async { + setState(() { + _searchFieldController.text = ""; + _searchTerm = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), const SizedBox(height: 12), @@ -427,8 +516,9 @@ class _EditWalletTokensViewState extends ConsumerState { } else { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -443,14 +533,14 @@ class _EditWalletTokensViewState extends ConsumerState { child: AppBarIconButton( size: 36, shadows: const [], - color: - Theme.of(context).extension()!.background, + color: Theme.of( + context, + ).extension()!.background, icon: SvgPicture.asset( Assets.svg.circlePlusFilled, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, width: 20, height: 20, ), @@ -480,49 +570,49 @@ class _EditWalletTokensViewState extends ConsumerState { enableSuggestions: !isDesktop, controller: _searchFieldController, focusNode: _searchFocusNode, - onChanged: - (value) => setState(() => _searchTerm = value), + onChanged: (value) => + setState(() => _searchTerm = value), style: STextStyles.field(context), - decoration: standardInputDecoration( - "Search", - _searchFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 16, - height: 16, - ), - ), - suffixIcon: - _searchFieldController.text.isNotEmpty + decoration: + standardInputDecoration( + "Search", + _searchFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 16, + height: 16, + ), + ), + suffixIcon: _searchFieldController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _searchFieldController.text = - ""; - _searchTerm = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _searchFieldController.text = + ""; + _searchTerm = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), const SizedBox(height: 10), @@ -535,10 +625,9 @@ class _EditWalletTokensViewState extends ConsumerState { ), const SizedBox(height: 16), PrimaryButton( - label: - widget.contractsToMarkSelected != null - ? "Save" - : "Next", + label: widget.contractsToMarkSelected != null + ? "Save" + : "Next", onPressed: onNextPressed, ), ], diff --git a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list.dart b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list.dart index 23bfb36c2d..b479a62aa7 100644 --- a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list.dart +++ b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list.dart @@ -46,6 +46,7 @@ class AddTokenList extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: AddTokenListElement( + key: Key(items[index].token.address), data: items[index], ), ), diff --git a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart index a477c57691..eecf914d73 100644 --- a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart +++ b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart @@ -14,6 +14,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:isar_community/isar.dart'; import '../../../../models/isar/exchange_cache/currency.dart'; +import '../../../../models/isar/models/contract.dart'; import '../../../../models/isar/models/ethereum/eth_contract.dart'; import '../../../../services/exchange/change_now/change_now_exchange.dart'; import '../../../../services/exchange/exchange_data_loading_service.dart'; @@ -29,7 +30,7 @@ import '../../../../widgets/rounded_white_container.dart'; class AddTokenListElementData { AddTokenListElementData(this.token); - final EthContract token; + final Contract token; bool selected = false; } @@ -102,13 +103,7 @@ class _AddTokenListElementState extends ConsumerState { placeholderBuilder: (_) => AppIcon(width: iconSize, height: iconSize), ) - : SvgPicture.asset( - widget.data.token.symbol == "BNB" - ? Assets.svg.bnbIcon - : Assets.svg.ethereum, - width: iconSize, - height: iconSize, - ), + : AppIcon(width: iconSize, height: iconSize), const SizedBox(width: 12), ConditionalParent( condition: isDesktop, diff --git a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart index 4d289a4662..13cb7a022e 100644 --- a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart +++ b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart @@ -20,13 +20,16 @@ import '../../../db/isar/main_db.dart'; import '../../../models/add_wallet_list_entity/add_wallet_list_entity.dart'; import '../../../models/add_wallet_list_entity/sub_classes/coin_entity.dart'; import '../../../models/add_wallet_list_entity/sub_classes/eth_token_entity.dart'; +import '../../../models/add_wallet_list_entity/sub_classes/sol_token_entity.dart'; import '../../../models/isar/models/ethereum/eth_contract.dart'; +import '../../../models/isar/models/solana/sol_contract.dart'; import '../../../pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; import '../../../providers/providers.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; import '../../../utilities/constants.dart'; import '../../../utilities/default_eth_tokens.dart'; +import '../../../utilities/default_sol_tokens.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; @@ -40,6 +43,7 @@ import '../../../widgets/icon_widgets/x_icon.dart'; import '../../../widgets/rounded_white_container.dart'; import '../../../widgets/stack_text_field.dart'; import '../../../widgets/textfield_icon_button.dart'; +import '../add_token_view/add_custom_solana_token_view.dart'; import '../add_token_view/add_custom_token_view.dart'; import '../add_token_view/sub_widgets/add_custom_token_selector.dart'; import 'sub_widgets/add_wallet_text.dart'; @@ -68,6 +72,7 @@ class _AddWalletViewState extends ConsumerState { final List coinEntities = []; final List coinTestnetEntities = []; final List tokenEntities = []; + final List solTokenEntities = []; final bool isDesktop = Util.isDesktop; @@ -84,6 +89,8 @@ class _AddWalletViewState extends ConsumerState { e.name.toLowerCase().contains(lowercaseTerm) || e.cryptoCurrency.identifier.toLowerCase().contains(lowercaseTerm) || (e is EthTokenEntity && + e.token.address.toLowerCase().contains(lowercaseTerm)) || + (e is SolTokenEntity && e.token.address.toLowerCase().contains(lowercaseTerm)), ); } @@ -125,6 +132,38 @@ class _AddWalletViewState extends ConsumerState { } } + Future _addSolToken() async { + SolContract? token; + if (isDesktop) { + token = await showDialog( + context: context, + builder: + (context) => const DesktopDialog( + maxWidth: 580, + maxHeight: 500, + child: AddCustomSolanaTokenView(), + ), + ); + } else { + token = await Navigator.of( + context, + ).pushNamed(AddCustomSolanaTokenView.routeName); + } + + if (token != null) { + await MainDB.instance.putSolContract(token); + if (mounted) { + setState(() { + if (solTokenEntities + .where((e) => e.token.address == token!.address) + .isEmpty) { + solTokenEntities.add(SolTokenEntity(token!)); + } + }); + } + } + } + @override void initState() { _searchFieldController = TextEditingController(); @@ -153,6 +192,25 @@ class _AddWalletViewState extends ConsumerState { tokenEntities.addAll(contracts.map((e) => EthTokenEntity(e))); } + if (AppConfig.coins.whereType().isNotEmpty) { + final contracts = MainDB.instance + .getSolContracts() + .sortByName() + .findAllSync(); + + if (contracts.isEmpty) { + contracts.addAll(DefaultSolTokens.list); + MainDB.instance + .putSolContracts(contracts) + .then( + (value) => + ref.read(priceAnd24hChangeNotifierProvider).updatePrice(), + ); + } + + solTokenEntities.addAll(contracts.map((e) => SolTokenEntity(e))); + } + WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { ref.refresh(addWalletSelectedEntityStateProvider); @@ -296,7 +354,7 @@ class _AddWalletViewState extends ConsumerState { ), if (tokenEntities.isNotEmpty) ExpandingSubListItem( - title: "Tokens", + title: "Ethereum tokens", entities: filter(_searchTerm, tokenEntities), initialState: ExpandableState.expanded, animationDurationMultiplier: 0.5, @@ -304,6 +362,16 @@ class _AddWalletViewState extends ConsumerState { addFunction: _addToken, ), ), + if (solTokenEntities.isNotEmpty) + ExpandingSubListItem( + title: "Solana tokens", + entities: filter(_searchTerm, solTokenEntities), + initialState: ExpandableState.expanded, + animationDurationMultiplier: 0.5, + trailing: AddCustomTokenSelector( + addFunction: _addSolToken, + ), + ), ], ), ), @@ -427,10 +495,16 @@ class _AddWalletViewState extends ConsumerState { ), if (tokenEntities.isNotEmpty) ExpandingSubListItem( - title: "Tokens", + title: "Ethereum tokens", entities: filter(_searchTerm, tokenEntities), initialState: ExpandableState.expanded, ), + if (solTokenEntities.isNotEmpty) + ExpandingSubListItem( + title: "Solana tokens", + entities: filter(_searchTerm, solTokenEntities), + initialState: ExpandableState.expanded, + ), ], ), ), diff --git a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart index 72adf390bb..9254847fcb 100644 --- a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart +++ b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart @@ -17,6 +17,7 @@ import 'package:isar_community/isar.dart'; import '../../../../models/add_wallet_list_entity/add_wallet_list_entity.dart'; import '../../../../models/add_wallet_list_entity/sub_classes/eth_token_entity.dart'; +import '../../../../models/add_wallet_list_entity/sub_classes/sol_token_entity.dart'; import '../../../../models/isar/exchange_cache/currency.dart'; import '../../../../providers/providers.dart'; import '../../../../services/exchange/change_now/change_now_exchange.dart'; @@ -27,6 +28,7 @@ import '../../../../utilities/assets.dart'; import '../../../../utilities/constants.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; +import '../../../../widgets/app_icon.dart'; class CoinSelectItem extends ConsumerStatefulWidget { const CoinSelectItem({super.key, required this.entity}); @@ -46,18 +48,17 @@ class _CoinSelectItemState extends ConsumerState { if (widget.entity is EthTokenEntity) { ExchangeDataLoadingService.instance.isar.then((isar) async { - final currency = - await isar.currencies - .where() - .exchangeNameEqualTo(ChangeNowExchange.exchangeName) - .filter() - .tokenContractEqualTo( - (widget.entity as EthTokenEntity).token.address, - caseSensitive: false, - ) - .and() - .imageIsNotEmpty() - .findFirst(); + final currency = await isar.currencies + .where() + .exchangeNameEqualTo(ChangeNowExchange.exchangeName) + .filter() + .tokenContractEqualTo( + (widget.entity as EthTokenEntity).token.address, + caseSensitive: false, + ) + .and() + .imageIsNotEmpty() + .findFirst(); if (mounted) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -69,6 +70,36 @@ class _CoinSelectItemState extends ConsumerState { }); } }); + } else if (widget.entity is SolTokenEntity) { + final solToken = (widget.entity as SolTokenEntity).token; + + ExchangeDataLoadingService.instance.isar.then((isar) async { + final currency = await isar.currencies + .where() + .exchangeNameEqualTo(ChangeNowExchange.exchangeName) + .filter() + .tokenContractEqualTo(solToken.address, caseSensitive: false) + .and() + .imageIsNotEmpty() + .findFirst(); + + if (mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + // Use exchange cache image if available, + // otherwise use logoUri if it's a PNG. + String? fallbackUri; + if (solToken.logoUri != null && + solToken.logoUri!.endsWith('.png')) { + fallbackUri = solToken.logoUri; + } + tokenImageUri = currency?.image ?? fallbackUri; + }); + } + }); + } + }); } } @@ -81,22 +112,21 @@ class _CoinSelectItemState extends ConsumerState { return Container( decoration: BoxDecoration( - color: - selectedEntity == widget.entity - ? Theme.of(context).extension()!.textFieldActiveBG - : Theme.of(context).extension()!.popupBG, + color: selectedEntity == widget.entity + ? Theme.of(context).extension()!.textFieldActiveBG + : Theme.of(context).extension()!.popupBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), ), child: MaterialButton( key: Key( - "coinSelectItemButtonKey_${widget.entity.name}${widget.entity.ticker}", + "coinSelectItemButtonKey_" + "${widget.entity.name}${widget.entity.ticker}", ), - padding: - isDesktop - ? const EdgeInsets.only(left: 24) - : const EdgeInsets.all(12), + padding: isDesktop + ? const EdgeInsets.only(left: 24) + : const EdgeInsets.all(12), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( @@ -108,23 +138,45 @@ class _CoinSelectItemState extends ConsumerState { child: Row( children: [ tokenImageUri != null - ? SvgPicture.network(tokenImageUri!, width: 26, height: 26) + ? tokenImageUri!.toLowerCase().endsWith(".svg") + ? SvgPicture.network( + tokenImageUri!, + width: 26, + height: 26, + placeholderBuilder: (_) => + const AppIcon(width: 26, height: 26), + ) + : Image.network( + tokenImageUri!, + width: 26, + height: 26, + errorBuilder: (_, _, _) => SvgPicture.file( + File( + ref.watch( + coinIconProvider( + widget.entity.cryptoCurrency, + ), + ), + ), + width: 26, + height: 26, + ), + ) : SvgPicture.file( - File( - ref.watch(coinIconProvider(widget.entity.cryptoCurrency)), + File( + ref.watch( + coinIconProvider(widget.entity.cryptoCurrency), + ), + ), + width: 26, + height: 26, ), - width: 26, - height: 26, - ), SizedBox(width: isDesktop ? 12 : 10), Text( "${widget.entity.name} (${widget.entity.ticker})", - style: - isDesktop - ? STextStyles.desktopTextMedium(context) - : STextStyles.subtitle600( - context, - ).copyWith(fontSize: 14), + style: isDesktop + ? STextStyles.desktopTextMedium(context) + : STextStyles.subtitle600(context).copyWith(fontSize: 14), ), if (isDesktop && selectedEntity == widget.entity) const Spacer(), if (isDesktop && selectedEntity == widget.entity) @@ -135,10 +187,9 @@ class _CoinSelectItemState extends ConsumerState { height: 24, child: SvgPicture.asset( Assets.svg.check, - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), diff --git a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/next_button.dart b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/next_button.dart index 4447cc7a9f..f88b491781 100644 --- a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/next_button.dart +++ b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/next_button.dart @@ -11,8 +11,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../models/add_wallet_list_entity/sub_classes/eth_token_entity.dart'; +import '../../../../models/add_wallet_list_entity/sub_classes/sol_token_entity.dart'; import '../../create_or_restore_wallet_view/create_or_restore_wallet_view.dart'; import '../../select_wallet_for_token_view.dart'; +import '../../select_wallet_for_sol_token_view.dart'; import '../../../../providers/providers.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/text_styles.dart'; @@ -42,6 +44,11 @@ class AddWalletNextButton extends ConsumerWidget { SelectWalletForTokenView.routeName, arguments: selectedCoin, ); + } else if (selectedCoin is SolTokenEntity) { + Navigator.of(context).pushNamed( + SelectWalletForSolTokenView.routeName, + arguments: selectedCoin, + ); } else { Navigator.of(context).pushNamed( CreateOrRestoreWalletView.routeName, diff --git a/lib/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart b/lib/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart index 33eddc54b8..0c756d0828 100644 --- a/lib/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart +++ b/lib/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart @@ -215,8 +215,9 @@ class _RestoreFrostMsWalletViewState } final qrResult = await ref.read(pBarcodeScanner).scan(context: context); + if (qrResult.rawContent == null) return; - configFieldController.text = qrResult.rawContent; + configFieldController.text = qrResult.rawContent!; setState(() { _configEmpty = configFieldController.text.isEmpty; diff --git a/lib/pages/add_wallet_views/new_wallet_options/new_wallet_options_view.dart b/lib/pages/add_wallet_views/new_wallet_options/new_wallet_options_view.dart index bfe41c3feb..e3a874a45a 100644 --- a/lib/pages/add_wallet_views/new_wallet_options/new_wallet_options_view.dart +++ b/lib/pages/add_wallet_views/new_wallet_options/new_wallet_options_view.dart @@ -22,24 +22,23 @@ import '../../../widgets/desktop/desktop_scaffold.dart'; import '../../../widgets/desktop/primary_button.dart'; import '../../../widgets/rounded_white_container.dart'; import '../../../widgets/stack_text_field.dart'; +import '../../../widgets/toggle.dart'; import '../create_or_restore_wallet_view/sub_widgets/coin_image.dart'; import '../new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart'; import '../restore_wallet_view/restore_options_view/sub_widgets/mobile_mnemonic_length_selector.dart'; import '../restore_wallet_view/sub_widgets/mnemonic_word_count_select_sheet.dart'; -final pNewWalletOptions = StateProvider< - ({ - String mnemonicPassphrase, - int mnemonicWordsCount, - bool convertToViewOnly, - })?>( - (ref) => null, -); +final pNewWalletOptions = + StateProvider< + ({ + String mnemonicPassphrase, + int mnemonicWordsCount, + bool convertToViewOnly, + bool convertToViewOnlySpark, + })? + >((ref) => null); -enum NewWalletOptions { - Default, - Advanced; -} +enum NewWalletOptions { Default, Advanced } class NewWalletOptionsView extends ConsumerStatefulWidget { const NewWalletOptionsView({ @@ -66,6 +65,7 @@ class _NewWalletOptionsViewState extends ConsumerState { NewWalletOptions _selectedOptions = NewWalletOptions.Default; bool _convertToViewOnly = false; + bool _firoFlag = true; @override void initState() { @@ -94,17 +94,15 @@ class _NewWalletOptionsViewState extends ConsumerState { leading: AppBarBackButton(), trailing: ExitToMyStackButton(), ), - body: SizedBox( - width: 480, - child: child, - ), + body: SizedBox(width: 480, child: child), ), child: ConditionalParent( condition: !Util.isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: const AppBarBackButton(), title: Text( @@ -135,20 +133,10 @@ class _NewWalletOptionsViewState extends ConsumerState { ), child: Column( children: [ - if (Util.isDesktop) - const Spacer( - flex: 10, - ), + if (Util.isDesktop) const Spacer(flex: 10), + if (!Util.isDesktop) const SizedBox(height: 16), if (!Util.isDesktop) - const SizedBox( - height: 16, - ), - if (!Util.isDesktop) - CoinImage( - coin: widget.coin, - height: 100, - width: 100, - ), + CoinImage(coin: widget.coin, height: 100, width: 100), if (Util.isDesktop) Text( "Wallet options", @@ -157,9 +145,7 @@ class _NewWalletOptionsViewState extends ConsumerState { ? STextStyles.desktopH2(context) : STextStyles.pageTitleH1(context), ), - SizedBox( - height: Util.isDesktop ? 32 : 16, - ), + SizedBox(height: Util.isDesktop ? 32 : 16), DropdownButtonHideUnderline( child: DropdownButton2( value: _selectedOptions, @@ -187,34 +173,29 @@ class _NewWalletOptionsViewState extends ConsumerState { Assets.svg.chevronDown, width: 12, height: 6, - color: Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, ), ), dropdownStyleData: DropdownStyleData( offset: const Offset(0, -10), elevation: 0, decoration: BoxDecoration( - color: Theme.of(context) - .extension()! - .textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), ), ), menuItemStyleData: const MenuItemStyleData( - padding: EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), ), ), ), - const SizedBox( - height: 24, - ), + const SizedBox(height: 24), if (_selectedOptions == NewWalletOptions.Advanced) Column( children: [ @@ -238,8 +219,9 @@ class _NewWalletOptionsViewState extends ConsumerState { onChanged: (value) { if (value is int) { ref - .read(mnemonicWordCountStateProvider.state) - .state = value; + .read(mnemonicWordCountStateProvider.state) + .state = + value; } }, isExpanded: true, @@ -257,9 +239,9 @@ class _NewWalletOptionsViewState extends ConsumerState { offset: const Offset(0, -10), elevation: 0, decoration: BoxDecoration( - color: Theme.of(context) - .extension()! - .textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -293,30 +275,28 @@ class _NewWalletOptionsViewState extends ConsumerState { }, ), if (widget.coin.hasMnemonicPassphraseSupport) - const SizedBox( - height: 24, - ), + const SizedBox(height: 24), if (widget.coin.hasMnemonicPassphraseSupport) RoundedWhiteContainer( child: Center( child: Text( "You may add a BIP39 passphrase. This is optional. " - "You will need BOTH your seed and your passphrase to recover the wallet.", + "You will need BOTH your seed and your passphrase to " + "recover the wallet.", style: Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context) - .copyWith( - color: Theme.of(context) - .extension()! - .textSubtitle1, + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, ) : STextStyles.itemSubtitle(context), ), ), ), if (widget.coin.hasMnemonicPassphraseSupport) - const SizedBox( - height: 8, - ), + const SizedBox(height: 8), if (widget.coin.hasMnemonicPassphraseSupport) ClipRRect( borderRadius: BorderRadius.circular( @@ -327,89 +307,122 @@ class _NewWalletOptionsViewState extends ConsumerState { focusNode: passwordFocusNode, controller: passwordController, style: Util.isDesktop - ? STextStyles.desktopTextMedium(context).copyWith( - height: 2, - ) + ? STextStyles.desktopTextMedium( + context, + ).copyWith(height: 2) : STextStyles.field(context), obscureText: hidePassword, enableSuggestions: false, autocorrect: false, - decoration: standardInputDecoration( - "BIP39 passphrase", - passwordFocusNode, - context, - ).copyWith( - suffixIcon: UnconstrainedBox( - child: ConditionalParent( - condition: Util.isDesktop, - builder: (child) => SizedBox( - height: 70, - child: child, - ), - child: Row( - children: [ - SizedBox( - width: Util.isDesktop ? 24 : 16, - ), - GestureDetector( - key: const Key( - "mnemonicPassphraseFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: SvgPicture.asset( - hidePassword - ? Assets.svg.eye - : Assets.svg.eyeSlash, - color: Theme.of(context) - .extension()! - .textDark3, - width: Util.isDesktop ? 24 : 16, - height: Util.isDesktop ? 24 : 16, - ), + decoration: + standardInputDecoration( + "BIP39 passphrase", + passwordFocusNode, + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => + SizedBox(height: 70, child: child), + child: Row( + children: [ + SizedBox(width: Util.isDesktop ? 24 : 16), + GestureDetector( + key: const Key( + "mnemonicPassphraseFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of( + context, + ).extension()!.textDark3, + width: Util.isDesktop ? 24 : 16, + height: Util.isDesktop ? 24 : 16, + ), + ), + const SizedBox(width: 12), + ], ), - const SizedBox( - width: 12, - ), - ], + ), ), ), - ), - ), ), ), if (widget.coin is ViewOnlyOptionCurrencyInterface) - const SizedBox( - height: 24, - ), + const SizedBox(height: 24), if (widget.coin is ViewOnlyOptionCurrencyInterface) CheckboxTextButton( - label: "Convert to view only wallet. " + label: + "Convert to view only wallet. " "You will only be shown the seed phrase once. " "Save it somewhere. " - "If you lose it you will lose access to any funds in this wallet.", + "If you lose it you will lose access to any funds in" + " this wallet.", onChanged: (value) { _convertToViewOnly = value; + if (mounted && widget.coin is Firo) { + setState(() {}); + } }, ), + if (_convertToViewOnly && + widget.coin is ViewOnlyOptionCurrencyInterface && + widget.coin is Firo) + const SizedBox(height: 24), + if (_convertToViewOnly && + widget.coin is ViewOnlyOptionCurrencyInterface && + widget.coin is Firo) + SizedBox( + height: 48, + child: Toggle( + key: UniqueKey(), + onColor: Theme.of( + context, + ).extension()!.popupBG, + offColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + onText: "Spark", + offText: "XPub", + isOn: !_firoFlag, + onValueChanged: (value) { + FocusManager.instance.primaryFocus?.unfocus(); + setState(() { + _firoFlag = !value; + }); + }, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + ), ], ), if (!Util.isDesktop) const Spacer(), - SizedBox( - height: Util.isDesktop ? 32 : 16, - ), + SizedBox(height: Util.isDesktop ? 32 : 16), PrimaryButton( label: "Continue", onPressed: () { if (_selectedOptions == NewWalletOptions.Advanced) { ref.read(pNewWalletOptions.notifier).state = ( - mnemonicWordsCount: - ref.read(mnemonicWordCountStateProvider.state).state, + mnemonicWordsCount: ref + .read(mnemonicWordCountStateProvider.state) + .state, mnemonicPassphrase: passwordController.text, convertToViewOnly: _convertToViewOnly, + convertToViewOnlySpark: + widget.coin is Firo && _convertToViewOnly && _firoFlag, ); } else { ref.read(pNewWalletOptions.notifier).state = null; @@ -417,21 +430,12 @@ class _NewWalletOptionsViewState extends ConsumerState { Navigator.of(context).pushNamed( NewWalletRecoveryPhraseWarningView.routeName, - arguments: Tuple2( - widget.walletName, - widget.coin, - ), + arguments: Tuple2(widget.walletName, widget.coin), ); }, ), - if (!Util.isDesktop) - const SizedBox( - height: 16, - ), - if (Util.isDesktop) - const Spacer( - flex: 15, - ), + if (!Util.isDesktop) const SizedBox(height: 16), + if (Util.isDesktop) const Spacer(flex: 15), ], ), ), diff --git a/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart b/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart index 7e3f57d320..52a78a975f 100644 --- a/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart +++ b/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart @@ -30,8 +30,7 @@ import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../wallets/isar/models/wallet_info.dart'; -import '../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; -import '../../../wallets/wallet/intermediate/lib_salvium_wallet.dart'; +import '../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../wallets/wallet/wallet.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -81,12 +80,11 @@ class _NewWalletRecoveryPhraseWarningViewState if (mounted) { await showDialog( context: context, - builder: - (_) => StackOkDialog( - title: "Create Wallet Error", - message: ex?.toString() ?? "Unknown error", - maxWidth: 600, - ), + builder: (_) => StackOkDialog( + title: "Create Wallet Error", + message: ex?.toString() ?? "Unknown error", + maxWidth: 600, + ), ); } return; @@ -195,17 +193,23 @@ class _NewWalletRecoveryPhraseWarningViewState } else if (wordCount > 0) { if (ref.read(pNewWalletOptions.state).state != null) { if (coin.hasMnemonicPassphraseSupport) { - mnemonicPassphrase = - ref.read(pNewWalletOptions.state).state!.mnemonicPassphrase; + mnemonicPassphrase = ref + .read(pNewWalletOptions.state) + .state! + .mnemonicPassphrase; } else { // this may not be epiccash and sol specific? - if (coin is Epiccash || coin is Solana) { + if (coin is Epiccash || + coin is Mimblewimblecoin || + coin is Solana) { mnemonicPassphrase = ""; } } - wordCount = - ref.read(pNewWalletOptions.state).state!.mnemonicWordsCount; + wordCount = ref + .read(pNewWalletOptions.state) + .state! + .mnemonicWordsCount; } else { mnemonicPassphrase = ""; } @@ -230,9 +234,7 @@ class _NewWalletRecoveryPhraseWarningViewState privateKey: privateKey, ); - if (wallet is LibMoneroWallet) { - await wallet.init(wordCount: wordCount); - } else if (wallet is LibSalviumWallet) { + if (wallet is CryptonoteWallet) { await wallet.init(wordCount: wordCount); } else { await wallet.init(); @@ -241,8 +243,8 @@ class _NewWalletRecoveryPhraseWarningViewState // set checkbox back to unchecked to annoy users to agree again :P ref.read(checkBoxStateProvider.state).state = false; - final fetchedMnemonic = - await (wallet as MnemonicInterface).getMnemonicAsWords(); + final fetchedMnemonic = await (wallet as MnemonicInterface) + .getMnemonicAsWords(); return (wallet, fetchedMnemonic); } catch (e, s) { @@ -269,46 +271,43 @@ class _NewWalletRecoveryPhraseWarningViewState return MasterScaffold( isDesktop: isDesktop, - appBar: - isDesktop - ? const DesktopAppBar( - isCompactHeight: false, - leading: AppBarBackButton(), - trailing: ExitToMyStackButton(), - ) - : AppBar( - leading: const AppBarBackButton(), - actions: [ - Padding( - padding: const EdgeInsets.only( - top: 10, - bottom: 10, - right: 10, - ), - child: AppBarIconButton( - semanticsLabel: - "Question Button. Opens A Dialog For Recovery Phrase Explanation.", - icon: SvgPicture.asset( - Assets.svg.circleQuestion, - width: 20, - height: 20, - color: - Theme.of( - context, - ).extension()!.accentColorDark, - ), - onPressed: () async { - await showDialog( - context: context, - builder: - (context) => - const RecoveryPhraseExplanationDialog(), - ); - }, + appBar: isDesktop + ? const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ) + : AppBar( + leading: const AppBarBackButton(), + actions: [ + Padding( + padding: const EdgeInsets.only( + top: 10, + bottom: 10, + right: 10, + ), + child: AppBarIconButton( + semanticsLabel: + "Question Button. Opens A Dialog For Recovery Phrase Explanation.", + icon: SvgPicture.asset( + Assets.svg.circleQuestion, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), + onPressed: () async { + await showDialog( + context: context, + builder: (context) => + const RecoveryPhraseExplanationDialog(), + ); + }, ), - ], - ), + ), + ], + ), body: SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints( @@ -319,10 +318,9 @@ class _NewWalletRecoveryPhraseWarningViewState padding: const EdgeInsets.all(16), child: Center( child: Column( - crossAxisAlignment: - isDesktop - ? CrossAxisAlignment.center - : CrossAxisAlignment.stretch, + crossAxisAlignment: isDesktop + ? CrossAxisAlignment.center + : CrossAxisAlignment.stretch, children: [ /*if (isDesktop) const Spacer( @@ -341,233 +339,206 @@ class _NewWalletRecoveryPhraseWarningViewState Text( "Recovery Phrase", textAlign: TextAlign.center, - style: - isDesktop - ? STextStyles.desktopH2(context) - : STextStyles.pageTitleH1(context), + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), ), SizedBox(height: isDesktop ? 32 : 16), RoundedWhiteContainer( padding: const EdgeInsets.all(32), width: isDesktop ? 480 : null, - child: - isDesktop - ? Text( - "On the next screen you will see " - "$seedCount " - "words that make up your recovery phrase.\n\nPlease " - "write it down. Keep it safe and never share it with " - "anyone. Your recovery phrase is the only way you can" - " access your funds if you forget your PIN, lose your" - " phone, etc.\n\n${AppConfig.appName} does not keep nor is " - "able to restore your recover phrase. Only you have " - "access to your wallet.", - style: - isDesktop - ? STextStyles.desktopTextMediumRegular( - context, - ) - : STextStyles.subtitle( - context, - ).copyWith(fontSize: 12), - ) - : Column( - children: [ - Text( - "Important", + child: isDesktop + ? Text( + "On the next screen you will see " + "$seedCount " + "words that make up your recovery phrase.\n\nPlease " + "write it down. Keep it safe and never share it with " + "anyone. Your recovery phrase is the only way you can" + " access your funds if you forget your PIN, lose your" + " phone, etc.\n\n${AppConfig.appName} does not keep nor is " + "able to restore your recover phrase. Only you have " + "access to your wallet.", + style: isDesktop + ? STextStyles.desktopTextMediumRegular( + context, + ) + : STextStyles.subtitle( + context, + ).copyWith(fontSize: 12), + ) + : Column( + children: [ + Text( + "Important", + style: STextStyles.desktopH3(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorBlue, + ), + ), + const SizedBox(height: 24), + RichText( + textAlign: TextAlign.center, + text: TextSpan( style: STextStyles.desktopH3( context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .accentColorBlue, - ), + ).copyWith(fontSize: 18), + children: [ + TextSpan( + text: + "On the next screen you will be given ", + style: STextStyles.desktopH3(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + fontSize: 18, + height: 1.3, + ), + ), + TextSpan( + text: "$seedCount words", + style: STextStyles.desktopH3(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorBlue, + fontSize: 18, + height: 1.3, + ), + ), + TextSpan( + text: ". They are your ", + style: STextStyles.desktopH3(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + fontSize: 18, + height: 1.3, + ), + ), + TextSpan( + text: "recovery phrase", + style: STextStyles.desktopH3(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorBlue, + fontSize: 18, + height: 1.3, + ), + ), + TextSpan( + text: ".", + style: STextStyles.desktopH3(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + fontSize: 18, + height: 1.3, + ), + ), + ], ), - const SizedBox(height: 24), - RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: STextStyles.desktopH3( - context, - ).copyWith(fontSize: 18), + ), + const SizedBox(height: 40), + Column( + children: [ + Row( children: [ - TextSpan( - text: - "On the next screen you will be given ", - style: STextStyles.desktopH3( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textDark, - fontSize: 18, - height: 1.3, - ), - ), - TextSpan( - text: "$seedCount words", - style: STextStyles.desktopH3( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .accentColorBlue, - fontSize: 18, - height: 1.3, + SizedBox( + width: 32, + height: 32, + child: RoundedContainer( + radiusMultiplier: 20, + padding: const EdgeInsets.all(9), + color: Theme.of(context) + .extension()! + .buttonBackSecondary, + child: SvgPicture.asset( + Assets.svg.pencil, + color: Theme.of(context) + .extension()! + .accentColorDark, + ), ), ), - TextSpan( - text: ". They are your ", - style: STextStyles.desktopH3( + const SizedBox(width: 20), + Text( + "Write them down.", + style: STextStyles.navBarTitle( context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textDark, - fontSize: 18, - height: 1.3, ), ), - TextSpan( - text: "recovery phrase", - style: STextStyles.desktopH3( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .accentColorBlue, - fontSize: 18, - height: 1.3, + ], + ), + const SizedBox(height: 30), + Row( + children: [ + SizedBox( + width: 32, + height: 32, + child: RoundedContainer( + radiusMultiplier: 20, + padding: const EdgeInsets.all(8), + color: Theme.of(context) + .extension()! + .buttonBackSecondary, + child: SvgPicture.asset( + Assets.svg.lock, + color: Theme.of(context) + .extension()! + .accentColorDark, + ), ), ), - TextSpan( - text: ".", - style: STextStyles.desktopH3( + const SizedBox(width: 20), + Text( + "Keep them safe.", + style: STextStyles.navBarTitle( context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textDark, - fontSize: 18, - height: 1.3, ), ), ], ), - ), - const SizedBox(height: 40), - Column( - children: [ - Row( - children: [ - SizedBox( - width: 32, - height: 32, - child: RoundedContainer( - radiusMultiplier: 20, - padding: const EdgeInsets.all(9), - color: - Theme.of(context) - .extension()! - .buttonBackSecondary, - child: SvgPicture.asset( - Assets.svg.pencil, - color: - Theme.of(context) - .extension< - StackColors - >()! - .accentColorDark, - ), - ), - ), - const SizedBox(width: 20), - Text( - "Write them down.", - style: STextStyles.navBarTitle( - context, - ), - ), - ], - ), - const SizedBox(height: 30), - Row( - children: [ - SizedBox( - width: 32, - height: 32, - child: RoundedContainer( - radiusMultiplier: 20, - padding: const EdgeInsets.all(8), - color: - Theme.of(context) - .extension()! - .buttonBackSecondary, - child: SvgPicture.asset( - Assets.svg.lock, - color: - Theme.of(context) - .extension< - StackColors - >()! - .accentColorDark, - ), + const SizedBox(height: 30), + Row( + children: [ + SizedBox( + width: 32, + height: 32, + child: RoundedContainer( + radiusMultiplier: 20, + padding: const EdgeInsets.all(8), + color: Theme.of(context) + .extension()! + .buttonBackSecondary, + child: SvgPicture.asset( + Assets.svg.eyeSlash, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), - const SizedBox(width: 20), - Text( - "Keep them safe.", + ), + const SizedBox(width: 20), + Expanded( + child: Text( + "Do not show them to anyone.", style: STextStyles.navBarTitle( context, ), ), - ], - ), - const SizedBox(height: 30), - Row( - children: [ - SizedBox( - width: 32, - height: 32, - child: RoundedContainer( - radiusMultiplier: 20, - padding: const EdgeInsets.all(8), - color: - Theme.of(context) - .extension()! - .buttonBackSecondary, - child: SvgPicture.asset( - Assets.svg.eyeSlash, - color: - Theme.of(context) - .extension< - StackColors - >()! - .accentColorDark, - ), - ), - ), - const SizedBox(width: 20), - Expanded( - child: Text( - "Do not show them to anyone.", - style: STextStyles.navBarTitle( - context, - ), - ), - ), - ], - ), - ], - ), - ], - ), + ), + ], + ), + ], + ), + ], + ), ), if (!isDesktop) const Spacer(), if (!isDesktop) const SizedBox(height: 16), @@ -584,10 +555,9 @@ class _NewWalletRecoveryPhraseWarningViewState children: [ GestureDetector( onTap: () { - final value = - ref - .read(checkBoxStateProvider.state) - .state; + final value = ref + .read(checkBoxStateProvider.state) + .state; ref.read(checkBoxStateProvider.state).state = !value; }, @@ -603,18 +573,19 @@ class _NewWalletRecoveryPhraseWarningViewState child: Checkbox( materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - value: - ref - .watch( - checkBoxStateProvider.state, - ) - .state, + value: ref + .watch( + checkBoxStateProvider.state, + ) + .state, onChanged: (newValue) { ref - .read( - checkBoxStateProvider.state, - ) - .state = newValue!; + .read( + checkBoxStateProvider + .state, + ) + .state = + newValue!; }, ), ), @@ -622,14 +593,13 @@ class _NewWalletRecoveryPhraseWarningViewState Flexible( child: Text( "I understand that ${AppConfig.appName} does not keep and cannot restore my recovery phrase, and If I lose my recovery phrase, I will not be able to access my funds.", - style: - isDesktop - ? STextStyles.desktopTextMedium( - context, - ) - : STextStyles.baseXS( - context, - ).copyWith(height: 1.3), + style: isDesktop + ? STextStyles.desktopTextMedium( + context, + ) + : STextStyles.baseXS( + context, + ).copyWith(height: 1.3), ), ), ], @@ -644,41 +614,39 @@ class _NewWalletRecoveryPhraseWarningViewState child: TextButton( onPressed: ref - .read(checkBoxStateProvider.state) - .state - ? _initNewWallet - : null, + .read(checkBoxStateProvider.state) + .state + ? _initNewWallet + : null, style: ref - .read(checkBoxStateProvider.state) - .state - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle( - context, - ) - : Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle( - context, - ), + .read(checkBoxStateProvider.state) + .state + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle( + context, + ) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle( + context, + ), child: Text( "View recovery phrase", - style: - isDesktop - ? ref - .read( - checkBoxStateProvider - .state, - ) - .state - ? STextStyles.desktopButtonEnabled( + style: isDesktop + ? ref + .read( + checkBoxStateProvider.state, + ) + .state + ? STextStyles.desktopButtonEnabled( context, ) - : STextStyles.desktopButtonDisabled( + : STextStyles.desktopButtonDisabled( context, ) - : STextStyles.button(context), + : STextStyles.button(context), ), ), ), diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart index 1686eba71b..e650564a18 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart @@ -42,6 +42,7 @@ import '../../../../widgets/textfield_icon_button.dart'; import '../../../../widgets/toggle.dart'; import '../../../../wl_gen/interfaces/cs_monero_interface.dart'; import '../../../../wl_gen/interfaces/cs_salvium_interface.dart'; +import '../../../../wl_gen/interfaces/cs_wownero_interface.dart'; import '../../create_or_restore_wallet_view/sub_widgets/coin_image.dart'; import '../restore_view_only_wallet_view.dart'; import '../restore_wallet_view.dart'; @@ -177,7 +178,7 @@ class _RestoreOptionsViewState extends ConsumerState { } if (mounted) { - final date = await showSWDatePicker(context); + final date = (await showSWDatePicker(context))?.first; if (date != null) { _restoreFromDate = date; _dateController.text = Format.formatDate(date); @@ -186,7 +187,7 @@ class _RestoreOptionsViewState extends ConsumerState { } Future chooseDesktopDate() async { - final date = await showSWDatePicker(context); + final date = (await showSWDatePicker(context))?.first; if (date != null) { _restoreFromDate = date; _dateController.text = Format.formatDate(date); @@ -213,10 +214,10 @@ class _RestoreOptionsViewState extends ConsumerState { int height = 0; if (date != null) { if (widget.coin is Monero) { - height = csMonero.getHeightByDate(date, csCoin: CsCoin.monero); + height = csMonero.getHeightByDate(date); } if (widget.coin is Wownero) { - height = csMonero.getHeightByDate(date, csCoin: CsCoin.wownero); + height = csWownero.getHeightByDate(date); } if (widget.coin is Salvium) { height = csSalvium.getHeightByDate( diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart index e0d3871c42..4dd084f8c2 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart @@ -26,17 +26,16 @@ import '../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; import '../../../wallets/isar/models/wallet_info.dart'; import '../../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; -import '../../../wallets/wallet/impl/monero_wallet.dart'; -import '../../../wallets/wallet/impl/wownero_wallet.dart'; import '../../../wallets/wallet/impl/xelis_wallet.dart'; +import '../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../wallets/wallet/wallet.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../widgets/desktop/desktop_app_bar.dart'; import '../../../widgets/desktop/desktop_scaffold.dart'; import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/options.dart'; import '../../../widgets/stack_text_field.dart'; -import '../../../widgets/toggle.dart'; import '../../home_view/home_view.dart'; import 'confirm_recovery_dialog.dart'; import 'sub_widgets/restore_failed_dialog.dart'; @@ -68,13 +67,13 @@ class _RestoreViewOnlyWalletViewState extends ConsumerState { late final TextEditingController addressController; late final TextEditingController viewKeyController; + late final TextEditingController sparkViewKeyController; - late String _currentDropDownValue; + late ViewOnlyWalletType _walletType; bool _enableRestoreButton = false; - bool _addressOnly = false; - bool _buttonLock = false; + late String _currentDropDownValue; Future _requestRestore() async { if (_buttonLock) return; @@ -107,12 +106,9 @@ class _RestoreViewOnlyWalletViewState WalletInfoKeys.isViewOnlyKey: true, }; - final ViewOnlyWalletType viewOnlyWalletType; + ViewOnlyWalletType viewOnlyWalletType = _walletType; if (widget.coin is Bip39HDCurrency) { - viewOnlyWalletType = - _addressOnly - ? ViewOnlyWalletType.addressOnly - : ViewOnlyWalletType.xPub; + // already set above } else if (widget.coin is CryptonoteCurrency) { viewOnlyWalletType = ViewOnlyWalletType.cryptonote; } else { @@ -120,8 +116,7 @@ class _RestoreViewOnlyWalletViewState "Unsupported view only wallet currency type found: ${widget.coin.runtimeType}", ); } - otherDataJson[WalletInfoKeys.viewOnlyTypeIndexKey] = - viewOnlyWalletType.index; + otherDataJson[WalletInfoKeys.viewOnlyTypeIndexKey] = _walletType.index; if (!Platform.isLinux && !Util.isDesktop) await WakelockPlus.enable(); @@ -131,6 +126,7 @@ class _RestoreViewOnlyWalletViewState name: widget.walletName, restoreHeight: widget.restoreBlockHeight, otherDataJsonString: jsonEncode(otherDataJson), + overrideAddressType: viewOnlyWalletType == .spark ? .spark : null, ); bool isRestoring = true; @@ -192,6 +188,16 @@ class _RestoreViewOnlyWalletViewState ], ); break; + + case ViewOnlyWalletType.spark: + if (sparkViewKeyController.text.isEmpty) { + throw Exception("Spark View Key is empty"); + } + viewOnlyData = SparkViewOnlyWalletData( + walletId: info.walletId, + viewKey: sparkViewKeyController.text, + ); + break; } var node = ref @@ -216,25 +222,21 @@ class _RestoreViewOnlyWalletViewState ); // TODO: extract interface with isRestore param - switch (wallet.runtimeType) { - case const (EpiccashWallet): - await (wallet as EpiccashWallet).init(isRestore: true); - break; - - case const (MimblewimblecoinWallet): - await (wallet as MimblewimblecoinWallet).init(isRestore: true); + switch (wallet) { + case EpiccashWallet(): + await wallet.init(isRestore: true); break; - case const (MoneroWallet): - await (wallet as MoneroWallet).init(isRestore: true); + case MimblewimblecoinWallet(): + await wallet.init(isRestore: true); break; - case const (WowneroWallet): - await (wallet as WowneroWallet).init(isRestore: true); + case CryptonoteWallet(): + await wallet.init(isRestore: true); break; - case const (XelisWallet): - await (wallet as XelisWallet).init(isRestore: true); + case XelisWallet(): + await wallet.init(isRestore: true); break; default: @@ -314,12 +316,15 @@ class _RestoreViewOnlyWalletViewState super.initState(); addressController = TextEditingController(); viewKeyController = TextEditingController(); + sparkViewKeyController = TextEditingController(); if (widget.coin is Bip39HDCurrency) { - _currentDropDownValue = - (widget.coin as Bip39HDCurrency) - .supportedHardenedDerivationPaths - .last; + _currentDropDownValue = (widget.coin as Bip39HDCurrency) + .supportedHardenedDerivationPaths + .last; + _walletType = ViewOnlyWalletType.xPub; + } else if (widget.coin is CryptonoteCurrency) { + _walletType = ViewOnlyWalletType.cryptonote; } } @@ -327,6 +332,7 @@ class _RestoreViewOnlyWalletViewState void dispose() { addressController.dispose(); viewKeyController.dispose(); + sparkViewKeyController.dispose(); super.dispose(); } @@ -338,28 +344,27 @@ class _RestoreViewOnlyWalletViewState return MasterScaffold( isDesktop: isDesktop, - appBar: - isDesktop - ? const DesktopAppBar( - isCompactHeight: false, - leading: AppBarBackButton(), - trailing: ExitToMyStackButton(), - ) - : AppBar( - leading: AppBarBackButton( - onPressed: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 50), - ); - } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - ), + appBar: isDesktop + ? const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ) + : AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 50), + ); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, ), + ), body: Container( color: Theme.of(context).extension()!.background, child: LayoutBuilder( @@ -384,32 +389,34 @@ class _RestoreViewOnlyWalletViewState SizedBox(height: isDesktop ? 0 : 4), Text( "Enter view only details", - style: - isDesktop - ? STextStyles.desktopH2(context) - : STextStyles.pageTitleH1(context), + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), ), if (isElectrumX) SizedBox(height: isDesktop ? 24 : 16), if (isElectrumX) SizedBox( height: isDesktop ? 56 : 48, - width: isDesktop ? 490 : null, - child: Toggle( + width: isDesktop ? 490 : double.infinity, + child: Options( key: UniqueKey(), - onText: "Extended pub key", - offText: "Single address", - onColor: - Theme.of( - context, - ).extension()!.popupBG, - offColor: - Theme.of(context) - .extension()! - .textFieldDefaultBG, - isOn: _addressOnly, + texts: [ + "Single address", + "Extended pub key", + if (widget.coin is Firo) + isDesktop ? "Spark View Key" : "View Key", + ], + onColor: Theme.of( + context, + ).extension()!.popupBG, + offColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + selectedIndex: _walletType.index - 1, onValueChanged: (value) { setState(() { - _addressOnly = value; + _walletType = + ViewOnlyWalletType.values[value + 1]; }); }, decoration: BoxDecoration( @@ -421,7 +428,8 @@ class _RestoreViewOnlyWalletViewState ), ), SizedBox(height: isDesktop ? 24 : 16), - if (!isElectrumX || _addressOnly) + if (!isElectrumX || + _walletType == ViewOnlyWalletType.addressOnly) FullTextField( key: const Key("viewOnlyAddressRestoreFieldKey"), label: "Address", @@ -442,7 +450,8 @@ class _RestoreViewOnlyWalletViewState }, ), if (!isElectrumX) SizedBox(height: isDesktop ? 16 : 12), - if (isElectrumX && !_addressOnly) + if (isElectrumX && + _walletType == ViewOnlyWalletType.xPub) DropdownButtonHideUnderline( child: DropdownButton2( value: _currentDropDownValue, @@ -469,10 +478,9 @@ class _RestoreViewOnlyWalletViewState isExpanded: true, buttonStyleData: ButtonStyleData( decoration: BoxDecoration( - color: - Theme.of(context) - .extension()! - .textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -485,10 +493,9 @@ class _RestoreViewOnlyWalletViewState Assets.svg.chevronDown, width: 12, height: 6, - color: - Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, ), ), ), @@ -496,10 +503,9 @@ class _RestoreViewOnlyWalletViewState offset: const Offset(0, -10), elevation: 0, decoration: BoxDecoration( - color: - Theme.of(context) - .extension()! - .textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -513,9 +519,11 @@ class _RestoreViewOnlyWalletViewState ), ), ), - if (isElectrumX && !_addressOnly) + if (isElectrumX && + _walletType == ViewOnlyWalletType.xPub) SizedBox(height: isDesktop ? 16 : 12), - if (!isElectrumX || !_addressOnly) + if (!isElectrumX || + _walletType == ViewOnlyWalletType.xPub) FullTextField( key: const Key("viewOnlyKeyRestoreFieldKey"), label: @@ -536,6 +544,21 @@ class _RestoreViewOnlyWalletViewState } }, ), + if (_walletType == ViewOnlyWalletType.spark) + SizedBox(height: isDesktop ? 16 : 12), + if (_walletType == ViewOnlyWalletType.spark) + FullTextField( + key: const Key( + "viewOnlySparkViewKeyRestoreFieldKey", + ), + label: "Spark View Key", + controller: sparkViewKeyController, + onChanged: (value) { + setState(() { + _enableRestoreButton = value.isNotEmpty; + }); + }, + ), if (!isDesktop) const Spacer(), SizedBox(height: isDesktop ? 24 : 16), PrimaryButton( diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart index 50df152020..a1ea19c405 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart @@ -40,13 +40,13 @@ import '../../../utilities/logger.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/crypto_currency/coins/ethereum.dart'; +import '../../../wallets/crypto_currency/coins/solana.dart'; import '../../../wallets/isar/models/wallet_info.dart'; import '../../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; -import '../../../wallets/wallet/impl/monero_wallet.dart'; -import '../../../wallets/wallet/impl/salvium_wallet.dart'; -import '../../../wallets/wallet/impl/wownero_wallet.dart'; import '../../../wallets/wallet/impl/xelis_wallet.dart'; +import '../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../wallets/wallet/intermediate/external_wallet.dart'; import '../../../wallets/wallet/supporting/epiccash_wallet_info_extension.dart'; import '../../../wallets/wallet/supporting/mimblewimblecoin_wallet_info_extension.dart'; @@ -61,6 +61,7 @@ import '../../../widgets/table_view/table_view.dart'; import '../../../widgets/table_view/table_view_cell.dart'; import '../../../widgets/table_view/table_view_row.dart'; import '../../../wl_gen/interfaces/cs_monero_interface.dart'; +import '../../../wl_gen/interfaces/cs_wownero_interface.dart'; import '../../../wl_gen/interfaces/lib_xelis_interface.dart'; import '../../home_view/home_view.dart'; import '../add_token_view/edit_wallet_tokens_view.dart'; @@ -189,7 +190,7 @@ class _RestoreWalletViewState extends ConsumerState { } } if (widget.coin is Wownero) { - final wowneroWordList = csMonero.getWowneroWordList( + final wowneroWordList = csWownero.getWowneroWordList( "English", widget.seedWordsLength, ); @@ -342,35 +343,26 @@ class _RestoreWalletViewState extends ConsumerState { ); // TODO: extract interface with isRestore param - switch (wallet.runtimeType) { - case const (EpiccashWallet): - await (wallet as EpiccashWallet).init(isRestore: true); + switch (wallet) { + case EpiccashWallet(): + await wallet.init(isRestore: true); break; - case const (MimblewimblecoinWallet): - await (wallet as MimblewimblecoinWallet).init(isRestore: true); + case MimblewimblecoinWallet(): + await wallet.init(isRestore: true); break; - case const (MoneroWallet): - await (wallet as MoneroWallet).init(isRestore: true); + case CryptonoteWallet(): + await wallet.init(isRestore: true); break; - case const (WowneroWallet): - await (wallet as WowneroWallet).init(isRestore: true); - break; - - case const (SalviumWallet): - await (wallet as SalviumWallet).init(isRestore: true); - break; - - case const (XelisWallet): - await (wallet as XelisWallet).init(isRestore: true); + case XelisWallet(): + await wallet.init(isRestore: true); break; default: await wallet.init(); } - await wallet.recover(isRescan: false); if (wallet is ExternalWallet) { @@ -422,7 +414,7 @@ class _RestoreWalletViewState extends ConsumerState { (route) => false, ), ); - if (info.coin is Ethereum) { + if (info.coin is Ethereum || info.coin is Solana) { unawaited( Navigator.of(context).pushNamed( EditWalletTokensView.routeName, @@ -621,7 +613,7 @@ class _RestoreWalletViewState extends ConsumerState { try { final qrResult = await ref.read(pBarcodeScanner).scan(context: context); - final results = AddressUtils.decodeQRSeedData(qrResult.rawContent); + final results = AddressUtils.decodeQRSeedData(qrResult.rawContent ?? ""); if (results["mnemonic"] != null) { final list = (results["mnemonic"] as List) @@ -871,6 +863,8 @@ class _RestoreWalletViewState extends ConsumerState { child: Column( children: [ TextFormField( + enableIMEPersonalizedLearning: + false, obscureText: _hideSeedWords, autocorrect: !isDesktop, enableSuggestions: !isDesktop, @@ -1017,6 +1011,8 @@ class _RestoreWalletViewState extends ConsumerState { child: Column( children: [ TextFormField( + enableIMEPersonalizedLearning: + false, obscureText: _hideSeedWords, autocorrect: !isDesktop, enableSuggestions: !isDesktop, @@ -1158,6 +1154,7 @@ class _RestoreWalletViewState extends ConsumerState { vertical: 4, ), child: TextFormField( + enableIMEPersonalizedLearning: false, obscureText: _hideSeedWords, autocorrect: !isDesktop, enableSuggestions: !isDesktop, diff --git a/lib/pages/add_wallet_views/select_wallet_for_sol_token_view.dart b/lib/pages/add_wallet_views/select_wallet_for_sol_token_view.dart new file mode 100644 index 0000000000..d5c012b53a --- /dev/null +++ b/lib/pages/add_wallet_views/select_wallet_for_sol_token_view.dart @@ -0,0 +1,252 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/add_wallet_list_entity/sub_classes/coin_entity.dart'; +import '../../models/add_wallet_list_entity/sub_classes/sol_token_entity.dart'; +import 'add_token_view/edit_wallet_tokens_view.dart'; +import 'create_or_restore_wallet_view/create_or_restore_wallet_view.dart'; +import 'verify_recovery_phrase_view/verify_recovery_phrase_view.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/isar/providers/all_wallets_info_provider.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_app_bar.dart'; +import '../../widgets/desktop/desktop_scaffold.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/eth_wallet_radio.dart'; +import '../../widgets/rounded_container.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/wallet_info_row/wallet_info_row.dart'; +import 'package:tuple/tuple.dart'; + +final newSolWalletTriggerTempUntilHiveCompletelyDeleted = + StateProvider((ref) => false); + +class SelectWalletForSolTokenView extends ConsumerStatefulWidget { + const SelectWalletForSolTokenView({ + super.key, + required this.entity, + }); + + static const String routeName = "/selectWalletForSolTokenView"; + + final SolTokenEntity entity; + + @override + ConsumerState createState() => + _SelectWalletForSolTokenViewState(); +} + +class _SelectWalletForSolTokenViewState + extends ConsumerState { + final isDesktop = Util.isDesktop; + + String? _selectedWalletId; + + void _onContinue() { + Navigator.of(context).pushNamed( + EditWalletTokensView.routeName, + arguments: Tuple2( + _selectedWalletId!, + [widget.entity.token.address], + ), + ); + } + + void _onAddNewSolWallet() { + ref.read(newSolWalletTriggerTempUntilHiveCompletelyDeleted.notifier).state = true; + Navigator.of(context).pushNamed( + CreateOrRestoreWalletView.routeName, + arguments: CoinEntity(widget.entity.cryptoCurrency), + ); + } + + @override + Widget build(BuildContext context) { + final solWalletInfos = ref + .watch(pAllWalletsInfo) + .where((e) => e.coin == widget.entity.cryptoCurrency) + .toList(); + + final _hasSolWallets = solWalletInfos.isNotEmpty; + + final List solWalletIds = []; + + for (final walletId in solWalletInfos.map((e) => e.walletId).toList()) { + final walletTokens = ref.read(pWalletTokenAddresses(walletId)); + if (!walletTokens.contains(widget.entity.token.address)) { + solWalletIds.add(walletId); + } + } + + return WillPopScope( + onWillPop: () async { + ref.read(newSolWalletTriggerTempUntilHiveCompletelyDeleted.notifier).state = false; + return true; + }, + child: ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ), + child: ConditionalParent( + condition: isDesktop, + builder: (child) => DesktopScaffold( + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + ), + body: SizedBox( + width: 500, + child: child, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (isDesktop) + const SizedBox( + height: 24, + ), + Text( + "Select Solana wallet", + textAlign: TextAlign.center, + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox( + height: isDesktop ? 16 : 8, + ), + Text( + "You are adding a Solana token.", + textAlign: TextAlign.center, + style: isDesktop + ? STextStyles.desktopSubtitleH2(context) + : STextStyles.subtitle(context), + ), + const SizedBox( + height: 8, + ), + Text( + "You must choose a Solana wallet in order to use ${widget.entity.name}", + textAlign: TextAlign.center, + style: isDesktop + ? STextStyles.desktopSubtitleH2(context) + : STextStyles.subtitle(context), + ), + SizedBox( + height: isDesktop ? 60 : 16, + ), + solWalletIds.isEmpty + ? RoundedWhiteContainer( + padding: EdgeInsets.all(isDesktop ? 16 : 12), + child: Text( + _hasSolWallets + ? "All current Solana wallets already have ${widget.entity.name}" + : "You do not have any Solana wallets", + style: isDesktop + ? STextStyles.desktopSubtitleH2(context) + : STextStyles.label(context), + ), + ) + : ConditionalParent( + condition: !isDesktop, + builder: (child) => Expanded( + child: Column( + children: [ + RoundedWhiteContainer( + padding: const EdgeInsets.all(8), + child: child, + ), + ], + ), + ), + child: ListView.separated( + itemCount: solWalletIds.length, + shrinkWrap: true, + separatorBuilder: (_, __) => SizedBox( + height: isDesktop ? 12 : 6, + ), + itemBuilder: (_, index) { + return RoundedContainer( + padding: EdgeInsets.all(isDesktop ? 16 : 8), + onPressed: () { + setState(() { + _selectedWalletId = solWalletIds[index]; + }); + }, + color: isDesktop + ? Theme.of(context) + .extension()! + .popupBG + : _selectedWalletId == solWalletIds[index] + ? Theme.of(context) + .extension()! + .highlight + : Colors.transparent, + child: isDesktop + ? EthWalletRadio( + walletId: solWalletIds[index], + selectedWalletId: _selectedWalletId, + ) + : WalletInfoRow( + walletId: solWalletIds[index], + ), + ); + }, + ), + ), + if (solWalletIds.isEmpty || isDesktop) + const SizedBox( + height: 16, + ), + if (isDesktop) + const SizedBox( + height: 16, + ), + solWalletIds.isEmpty + ? PrimaryButton( + label: "Add new Solana wallet", + onPressed: _onAddNewSolWallet, + ) + : PrimaryButton( + label: "Continue", + enabled: _selectedWalletId != null, + onPressed: _onContinue, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart b/lib/pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart index 9494b6630f..2b9802b0dd 100644 --- a/lib/pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart +++ b/lib/pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart @@ -31,16 +31,17 @@ import '../../../utilities/show_loading.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/crypto_currency/coins/ethereum.dart'; +import '../../../wallets/crypto_currency/coins/solana.dart'; import '../../../wallets/crypto_currency/intermediate/bip39_hd_currency.dart'; import '../../../wallets/isar/models/wallet_info.dart'; import '../../../wallets/wallet/impl/epiccash_wallet.dart'; -import '../../../wallets/wallet/impl/monero_wallet.dart'; -import '../../../wallets/wallet/impl/wownero_wallet.dart'; +import '../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; import '../../../wallets/wallet/impl/xelis_wallet.dart'; -import '../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; -import '../../../wallets/wallet/intermediate/lib_salvium_wallet.dart'; +import '../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../wallets/wallet/wallet.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../widgets/desktop/desktop_app_bar.dart'; @@ -48,6 +49,7 @@ import '../../../widgets/desktop/desktop_scaffold.dart'; import '../../../widgets/stack_dialog.dart'; import '../../../wl_gen/interfaces/cs_monero_interface.dart'; import '../../../wl_gen/interfaces/cs_salvium_interface.dart'; +import '../../../wl_gen/interfaces/cs_wownero_interface.dart'; import '../../home_view/home_view.dart'; import '../add_token_view/edit_wallet_tokens_view.dart'; import '../new_wallet_options/new_wallet_options_view.dart'; @@ -102,27 +104,26 @@ class _VerifyRecoveryPhraseViewState return result == "verified"; } - Future _convertToViewOnly() async { + Future _convertToViewOnly(bool firoSpark) async { int height = 0; final Map otherDataJson = { WalletInfoKeys.isViewOnlyKey: true, }; final ViewOnlyWalletType viewOnlyWalletType; - if (widget.wallet is ExtendedKeysInterface) { + if (firoSpark) { + viewOnlyWalletType = .spark; + } else if (widget.wallet is ExtendedKeysInterface) { viewOnlyWalletType = ViewOnlyWalletType.xPub; - } else if (widget.wallet is LibMoneroWallet || - widget.wallet is LibSalviumWallet) { + } else if (widget.wallet is CryptonoteWallet) { if (widget.wallet.cryptoCurrency is Monero) { height = csMonero.getHeightByDate( DateTime.now().subtract(const Duration(days: 7)), - csCoin: CsCoin.monero, ); } if (widget.wallet.cryptoCurrency is Wownero) { - height = csMonero.getHeightByDate( + height = csWownero.getHeightByDate( DateTime.now().subtract(const Duration(days: 7)), - csCoin: CsCoin.wownero, ); } if (widget.wallet.cryptoCurrency is Salvium) { @@ -147,10 +148,18 @@ class _VerifyRecoveryPhraseViewState name: widget.wallet.info.name, restoreHeight: height, otherDataJsonString: jsonEncode(otherDataJson), + overrideAddressType: viewOnlyWalletType == .spark ? .spark : null, ); final ViewOnlyWalletData viewOnlyData; - if (widget.wallet is ExtendedKeysInterface) { + if (viewOnlyWalletType == .spark) { + final sparkViewKey = (widget.wallet as SparkInterface).sparkViewKey; + + viewOnlyData = SparkViewOnlyWalletData( + walletId: voInfo.walletId, + viewKey: sparkViewKey!, + ); + } else if (widget.wallet is ExtendedKeysInterface) { final extendedKeyInfo = await (widget.wallet as ExtendedKeysInterface) .getXPubs(); final testPath = (_coin as Bip39HDCurrency).constructDerivePath( @@ -175,23 +184,8 @@ class _VerifyRecoveryPhraseViewState walletId: voInfo.walletId, xPubs: [xPub], ); - } else if (widget.wallet is LibMoneroWallet) { - final w = widget.wallet as LibMoneroWallet; - - final info = await w - .hackToCreateNewViewOnlyWalletDataFromNewlyCreatedWalletThisFunctionShouldNotBeCalledUnlessYouKnowWhatYouAreDoing(); - final address = info.$1; - final privateViewKey = info.$2; - - await w.exit(); - - viewOnlyData = CryptonoteViewOnlyWalletData( - walletId: voInfo.walletId, - address: address, - privateViewKey: privateViewKey, - ); - } else if (widget.wallet is LibSalviumWallet) { - final w = widget.wallet as LibSalviumWallet; + } else if (widget.wallet is CryptonoteWallet) { + final w = widget.wallet as CryptonoteWallet; final info = await w .hackToCreateNewViewOnlyWalletDataFromNewlyCreatedWalletThisFunctionShouldNotBeCalledUnlessYouKnowWhatYouAreDoing(); @@ -222,21 +216,21 @@ class _VerifyRecoveryPhraseViewState try { // TODO: extract interface with isRestore param - switch (voWallet.runtimeType) { - case const (EpiccashWallet): - await (voWallet as EpiccashWallet).init(isRestore: true); + switch (voWallet) { + case EpiccashWallet(): + await voWallet.init(isRestore: true); break; - case const (MoneroWallet): - await (voWallet as MoneroWallet).init(isRestore: true); + case MimblewimblecoinWallet(): + await voWallet.init(isRestore: true); break; - case const (WowneroWallet): - await (voWallet as WowneroWallet).init(isRestore: true); + case CryptonoteWallet(): + await voWallet.init(isRestore: true); break; - case const (XelisWallet): - await (voWallet as XelisWallet).init(isRestore: true); + case XelisWallet(): + await voWallet.init(isRestore: true); break; default: @@ -318,7 +312,9 @@ class _VerifyRecoveryPhraseViewState try { Exception? ex; await showLoading( - whileFuture: _convertToViewOnly(), + whileFuture: _convertToViewOnly( + ref.read(pNewWalletOptions)?.convertToViewOnlySpark == true, + ), context: context, message: "Converting to view only wallet", rootNavigator: Util.isDesktop, @@ -362,7 +358,7 @@ class _VerifyRecoveryPhraseViewState Navigator.of( context, ).popUntil(ModalRoute.withName(DesktopHomeView.routeName)); - if (_coin is Ethereum) { + if (_coin is Ethereum || _coin is Solana) { unawaited( Navigator.of(context).pushNamed( EditWalletTokensView.routeName, @@ -382,7 +378,7 @@ class _VerifyRecoveryPhraseViewState context, ).pushNamedAndRemoveUntil(HomeView.routeName, (route) => false), ); - if (_coin is Ethereum) { + if (_coin is Ethereum || _coin is Solana) { WidgetsBinding.instance.addPostFrameCallback((_) { ref .read(pNavKey) diff --git a/lib/pages/address_book_views/address_book_view.dart b/lib/pages/address_book_views/address_book_view.dart index 64af275075..7f15268d29 100644 --- a/lib/pages/address_book_views/address_book_view.dart +++ b/lib/pages/address_book_views/address_book_view.dart @@ -64,18 +64,16 @@ class _AddressBookViewState extends ConsumerState { final coins = [...AppConfig.coins]; coins.removeWhere((e) => e is Firo && e.network.isTestNet); - final bool showTestNet = - ref.read(prefsChangeNotifierProvider).showTestNetCoins; + final bool showTestNet = ref + .read(prefsChangeNotifierProvider) + .showTestNetCoins; if (showTestNet) { ref.read(addressBookFilterProvider).addAll(coins, false); } else { ref .read(addressBookFilterProvider) - .addAll( - coins.where((e) => e.network != CryptoCurrencyNetwork.test), - false, - ); + .addAll(coins.where((e) => !e.network.isTestNet), false); } } else { ref.read(addressBookFilterProvider).add(widget.coin!, false); @@ -86,12 +84,10 @@ class _AddressBookViewState extends ConsumerState { final wallets = ref.read(pWallets).wallets; for (final wallet in wallets) { final String addressString; - if (wallet is SparkInterface) { + if (wallet is SparkInterface && + !(wallet.isViewOnly && wallet.viewOnlyType != .spark)) { Address? address = await wallet.getCurrentReceivingSparkAddress(); - if (address == null) { - address = await wallet.generateNextSparkAddress(); - await ref.read(mainDBProvider).updateOrPutAddresses([address]); - } + address ??= await wallet.generateNextSparkAddress(saveToDB: true); addressString = address.value; } else { final address = await wallet.getCurrentReceivingAddress(); @@ -137,8 +133,9 @@ class _AddressBookViewState extends ConsumerState { builder: (child) { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -162,16 +159,14 @@ class _AddressBookViewState extends ConsumerState { key: const Key("addressBookFilterViewButton"), size: 36, shadows: const [], - color: - Theme.of( - context, - ).extension()!.background, + color: Theme.of( + context, + ).extension()!.background, icon: SvgPicture.asset( Assets.svg.filter, - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, width: 20, height: 20, ), @@ -195,16 +190,14 @@ class _AddressBookViewState extends ConsumerState { key: const Key("addressBookAddNewContactViewButton"), size: 36, shadows: const [], - color: - Theme.of( - context, - ).extension()!.background, + color: Theme.of( + context, + ).extension()!.background, icon: SvgPicture.asset( Assets.svg.plus, - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, width: 20, height: 20, ), @@ -260,38 +253,37 @@ class _AddressBookViewState extends ConsumerState { borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), - child: - !isDesktop - ? TextField( - autocorrect: Util.isDesktop ? false : true, - enableSuggestions: Util.isDesktop ? false : true, - controller: _searchController, - focusNode: _searchFocusNode, - onChanged: (value) { - setState(() { - _searchTerm = value; - }); - }, - style: STextStyles.field(context), - decoration: standardInputDecoration( - "Search", - _searchFocusNode, - context, - ).copyWith( - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 16, - height: 16, + child: !isDesktop + ? TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: (value) { + setState(() { + _searchTerm = value; + }); + }, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Search", + _searchFocusNode, + context, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 16, + height: 16, + ), ), - ), - suffixIcon: - _searchController.text.isNotEmpty - ? Padding( + suffixIcon: _searchController.text.isNotEmpty + ? Padding( padding: const EdgeInsets.only(right: 0), child: UnconstrainedBox( child: Row( @@ -309,10 +301,10 @@ class _AddressBookViewState extends ConsumerState { ), ), ) - : null, - ), - ) - : null, + : null, + ), + ) + : null, ), if (!isDesktop) const SizedBox(height: 16), Text("Favorites", style: STextStyles.smallMed12(context)), @@ -324,16 +316,15 @@ class _AddressBookViewState extends ConsumerState { children: [ ...contacts .where( - (element) => - element.addressesSorted - .where( - (e) => ref.watch( - addressBookFilterProvider.select( - (value) => value.coins.contains(e.coin), - ), - ), - ) - .isNotEmpty, + (element) => element.addressesSorted + .where( + (e) => ref.watch( + addressBookFilterProvider.select( + (value) => value.coins.contains(e.coin), + ), + ), + ) + .isNotEmpty, ) .where( (e) => @@ -375,17 +366,15 @@ class _AddressBookViewState extends ConsumerState { children: [ ...contacts .where( - (element) => - element.addressesSorted - .where( - (e) => ref.watch( - addressBookFilterProvider.select( - (value) => - value.coins.contains(e.coin), - ), - ), - ) - .isNotEmpty, + (element) => element.addressesSorted + .where( + (e) => ref.watch( + addressBookFilterProvider.select( + (value) => value.coins.contains(e.coin), + ), + ), + ) + .isNotEmpty, ) .where( (e) => ref diff --git a/lib/pages/address_book_views/subviews/new_contact_address_entry_form.dart b/lib/pages/address_book_views/subviews/new_contact_address_entry_form.dart index b6dcd7bded..1be9783995 100644 --- a/lib/pages/address_book_views/subviews/new_contact_address_entry_form.dart +++ b/lib/pages/address_book_views/subviews/new_contact_address_entry_form.dart @@ -71,6 +71,7 @@ class _NewContactAddressEntryFormState // .state) // .state = false; final qrResult = await ref.read(pBarcodeScanner).scan(context: context); + if (qrResult.rawContent == null) return; // Future.delayed( // const Duration(seconds: 2), @@ -82,7 +83,7 @@ class _NewContactAddressEntryFormState // ); final paymentData = AddressUtils.parsePaymentUri( - qrResult.rawContent, + qrResult.rawContent!, logging: Logging.instance, ); @@ -93,18 +94,19 @@ class _NewContactAddressEntryFormState addressLabelController.text = paymentData.label ?? addressLabelController.text; - ref.read(addressEntryDataProvider(widget.id)).addressLabel = - addressLabelController.text.isEmpty - ? null - : addressLabelController.text; + ref + .read(addressEntryDataProvider(widget.id)) + .addressLabel = addressLabelController.text.isEmpty + ? null + : addressLabelController.text; // now check for non standard encoded basic address } else if (ref.read(addressEntryDataProvider(widget.id)).coin != null) { if (ref .read(addressEntryDataProvider(widget.id)) .coin! - .validateAddress(qrResult.rawContent)) { - addressController.text = qrResult.rawContent; + .validateAddress(qrResult.rawContent!)) { + addressController.text = qrResult.rawContent!; ref.read(addressEntryDataProvider(widget.id)).address = qrResult.rawContent; } @@ -140,13 +142,10 @@ class _NewContactAddressEntryFormState @override void initState() { - addressLabelController = - TextEditingController() - ..text = - ref.read(addressEntryDataProvider(widget.id)).addressLabel ?? ""; - addressController = - TextEditingController() - ..text = ref.read(addressEntryDataProvider(widget.id)).address ?? ""; + addressLabelController = TextEditingController() + ..text = ref.read(addressEntryDataProvider(widget.id)).addressLabel ?? ""; + addressController = TextEditingController() + ..text = ref.read(addressEntryDataProvider(widget.id)).address ?? ""; addressLabelFocusNode = FocusNode(); addressFocusNode = FocusNode(); coins = [...AppConfig.coins]; @@ -177,15 +176,15 @@ class _NewContactAddressEntryFormState coins = [...AppConfig.coins]; coins.removeWhere((e) => e is Firo && e.network.isTestNet); - final showTestNet = - ref.read(prefsChangeNotifierProvider).showTestNetCoins; + final showTestNet = ref + .read(prefsChangeNotifierProvider) + .showTestNetCoins; if (showTestNet) { coins = coins.toList(); } else { - coins = - coins - .where((e) => e.network != CryptoCurrencyNetwork.test) - .toList(); + coins = coins + .where((e) => e.network != CryptoCurrencyNetwork.test) + .toList(); } } @@ -202,10 +201,9 @@ class _NewContactAddressEntryFormState offset: const Offset(0, -10), elevation: 0, decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -249,14 +247,14 @@ class _NewContactAddressEntryFormState const SizedBox(width: 12), Text( coin.prettyName, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( context, ).extension()!.textDark, - ), + ), ), ], ), @@ -279,8 +277,9 @@ class _NewContactAddressEntryFormState child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: RawMaterialButton( - splashColor: - Theme.of(context).extension()!.highlight, + splashColor: Theme.of( + context, + ).extension()!.highlight, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -308,48 +307,47 @@ class _NewContactAddressEntryFormState ) == null ? Text( - "Select cryptocurrency", - style: STextStyles.fieldLabel(context), - ) + "Select cryptocurrency", + style: STextStyles.fieldLabel(context), + ) : Row( - children: [ - SvgPicture.file( - File( - ref.watch( - coinIconProvider( - ref.watch( + children: [ + SvgPicture.file( + File( + ref.watch( + coinIconProvider( + ref.watch( + addressEntryDataProvider( + widget.id, + ).select((value) => value.coin), + )!, + ), + ), + ), + height: 20, + width: 20, + ), + const SizedBox(width: 12), + Text( + ref + .watch( addressEntryDataProvider( widget.id, ).select((value) => value.coin), - )!, - ), - ), + )! + .prettyName, + style: STextStyles.itemSubtitle12(context), ), - height: 20, - width: 20, - ), - const SizedBox(width: 12), - Text( - ref - .watch( - addressEntryDataProvider( - widget.id, - ).select((value) => value.coin), - )! - .prettyName, - style: STextStyles.itemSubtitle12(context), - ), - ], - ), + ], + ), if (!isDesktop) SvgPicture.asset( Assets.svg.chevronDown, width: 8, height: 4, - color: - Theme.of( - context, - ).extension()!.textSubtitle2, + color: Theme.of( + context, + ).extension()!.textSubtitle2, ), ], ), @@ -369,33 +367,35 @@ class _NewContactAddressEntryFormState focusNode: addressLabelFocusNode, controller: addressLabelController, style: STextStyles.field(context), - decoration: standardInputDecoration( - "Enter address label", - addressLabelFocusNode, - context, - ).copyWith( - labelStyle: isDesktop ? STextStyles.fieldLabel(context) : null, - suffixIcon: - addressLabelController.text.isNotEmpty + decoration: + standardInputDecoration( + "Enter address label", + addressLabelFocusNode, + context, + ).copyWith( + labelStyle: isDesktop + ? STextStyles.fieldLabel(context) + : null, + suffixIcon: addressLabelController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - addressLabelController.text = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + addressLabelController.text = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), onChanged: (newValue) { ref.read(addressEntryDataProvider(widget.id)).addressLabel = newValue; @@ -413,76 +413,87 @@ class _NewContactAddressEntryFormState focusNode: addressFocusNode, controller: addressController, style: STextStyles.field(context), - decoration: standardInputDecoration( - "Paste address", - addressFocusNode, - context, - ).copyWith( - labelStyle: isDesktop ? STextStyles.fieldLabel(context) : null, - suffixIcon: UnconstrainedBox( - child: Row( - children: [ - if (ref.watch( - addressEntryDataProvider( - widget.id, - ).select((value) => value.address), - ) != - null) - TextFieldIconButton( - key: const Key("addAddressBookClearAddressButtonKey"), - onTap: () async { - addressController.text = ""; - ref - .read(addressEntryDataProvider(widget.id)) - .address = null; - }, - child: const XIcon(), - ), - if (ref.watch( - addressEntryDataProvider( - widget.id, - ).select((value) => value.address), - ) == - null) - TextFieldIconButton( - key: const Key("addAddressPasteAddressButtonKey"), - onTap: () async { - final ClipboardData? data = await widget.clipboard - .getData(Clipboard.kTextPlain); - - if (data?.text != null && data!.text!.isNotEmpty) { - String content = data.text!.trim(); - if (content.contains("\n")) { - content = content.substring( - 0, - content.indexOf("\n"), - ); - } - addressController.text = content; - ref - .read(addressEntryDataProvider(widget.id)) - .address = content.isEmpty ? null : content; - } - }, - child: const ClipboardIcon(), - ), - if (!Util.isDesktop && - ref.watch( + decoration: + standardInputDecoration( + "Paste address", + addressFocusNode, + context, + ).copyWith( + labelStyle: isDesktop + ? STextStyles.fieldLabel(context) + : null, + suffixIcon: UnconstrainedBox( + child: Row( + children: [ + if (ref.watch( + addressEntryDataProvider( + widget.id, + ).select((value) => value.address), + ) != + null) + TextFieldIconButton( + key: const Key( + "addAddressBookClearAddressButtonKey", + ), + onTap: () async { + addressController.text = ""; + ref + .read(addressEntryDataProvider(widget.id)) + .address = + null; + }, + child: const XIcon(), + ), + if (ref.watch( addressEntryDataProvider( widget.id, ).select((value) => value.address), ) == null) - TextFieldIconButton( - key: const Key("addAddressBookEntryScanQrButtonKey"), - onTap: _onQrTapped, - child: const QrCodeIcon(), - ), - const SizedBox(width: 8), - ], + TextFieldIconButton( + key: const Key("addAddressPasteAddressButtonKey"), + onTap: () async { + final ClipboardData? data = await widget.clipboard + .getData(Clipboard.kTextPlain); + + if (data?.text != null && + data!.text!.isNotEmpty) { + String content = data.text!.trim(); + if (content.contains("\n")) { + content = content.substring( + 0, + content.indexOf("\n"), + ); + } + addressController.text = content; + ref + .read(addressEntryDataProvider(widget.id)) + .address = content.isEmpty + ? null + : content; + } + }, + child: const ClipboardIcon(), + ), + if (!Util.isDesktop && + ref.watch( + addressEntryDataProvider( + widget.id, + ).select((value) => value.address), + ) == + null) + TextFieldIconButton( + key: const Key( + "addAddressBookEntryScanQrButtonKey", + ), + onTap: _onQrTapped, + child: const QrCodeIcon(), + ), + const SizedBox(width: 8), + ], + ), + ), ), - ), - ), key: const Key("addAddressBookEntryViewAddressField"), readOnly: false, autocorrect: false, @@ -517,8 +528,9 @@ class _NewContactAddressEntryFormState "Invalid address", textAlign: TextAlign.left, style: STextStyles.label(context).copyWith( - color: - Theme.of(context).extension()!.textError, + color: Theme.of( + context, + ).extension()!.textError, ), ), ], diff --git a/lib/pages/already_running_view.dart b/lib/pages/already_running_view.dart new file mode 100644 index 0000000000..1678276e55 --- /dev/null +++ b/lib/pages/already_running_view.dart @@ -0,0 +1,191 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../app_config.dart'; +import '../themes/stack_colors.dart'; +import '../themes/theme_providers.dart'; +import '../themes/theme_service.dart'; +import '../utilities/stack_file_system.dart'; +import '../utilities/text_styles.dart'; +import '../utilities/util.dart'; +import '../widgets/app_icon.dart'; +import '../widgets/background.dart'; + +/// Root app widget for the "already running" error path. +/// +/// Mirrors the theme bootstrap performed by [MaterialAppWithTheme] in main.dart +/// but without touching Hive. Requires Isar + ThemeService to already be +/// initialized before [runApp] is called. +class AlreadyRunningApp extends ConsumerStatefulWidget { + const AlreadyRunningApp({super.key}); + + @override + ConsumerState createState() => _AlreadyRunningAppState(); +} + +class _AlreadyRunningAppState extends ConsumerState { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(applicationThemesDirectoryPathProvider.notifier).state = + StackFileSystem.themesDir!.path; + // The first instance already verified/installed the light theme, so + // getTheme cannot return null here. + ref.read(themeProvider.state).state = ref + .read(pThemeService) + .getTheme(themeId: "light")!; + }); + } + + @override + Widget build(BuildContext context) { + final colorScheme = ref.watch(colorProvider.state).state; + return MaterialApp( + debugShowCheckedModeBanner: false, + title: AppConfig.appName, + theme: ThemeData( + extensions: [colorScheme], + fontFamily: GoogleFonts.inter().fontFamily, + splashColor: Colors.transparent, + ), + home: const AlreadyRunningView(), + ); + } +} + +/// Error screen shown when this is a second instance of the app. +/// +/// Mirrors [IntroView]'s layout: themed background, logo, app name heading, +/// short description subtitle, then the error message (in label style, smaller +/// than the subtitle) in place of the action buttons. +class AlreadyRunningView extends ConsumerWidget { + const AlreadyRunningView({super.key}); + + static const _errorMessage = + "${AppConfig.appName} is already running. " + "Close the other window and try again."; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDesktop = Util.isDesktop; + final colors = Theme.of(context).extension()!; + final stack = ref.watch( + themeProvider.select((value) => value.assets.stack), + ); + + return Background( + child: Scaffold( + backgroundColor: colors.background, + body: SafeArea( + child: Center( + child: !isDesktop + ? Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Spacer(flex: 2), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: SizedBox( + width: 266, + height: 266, + child: stack.endsWith(".png") + ? Image.file(File(stack)) + : SvgPicture.file( + File(stack), + width: 266, + height: 266, + ), + ), + ), + ), + const Spacer(flex: 1), + Text( + AppConfig.appName, + textAlign: TextAlign.center, + style: STextStyles.pageTitleH1(context), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 48), + child: Text( + AppConfig.shortDescriptionText, + textAlign: TextAlign.center, + style: STextStyles.subtitle(context), + ), + ), + const Spacer(flex: 4), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + child: Text( + _errorMessage, + textAlign: TextAlign.center, + style: STextStyles.label(context), + ), + ), + ], + ) + : SizedBox( + width: 350, + height: 540, + child: Column( + children: [ + const Spacer(flex: 2), + const SizedBox( + width: 130, + height: 130, + child: AppIcon(), + ), + const Spacer(flex: 42), + Text( + AppConfig.appName, + textAlign: TextAlign.center, + style: STextStyles.pageTitleH1( + context, + ).copyWith(fontSize: 40), + ), + const Spacer(flex: 24), + Text( + AppConfig.shortDescriptionText, + textAlign: TextAlign.center, + style: STextStyles.subtitle( + context, + ).copyWith(fontSize: 24), + ), + const Spacer(flex: 42), + Text( + _errorMessage, + textAlign: TextAlign.center, + style: STextStyles.label( + context, + ).copyWith(fontSize: 18), + ), + const Spacer(flex: 65), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/buy_view/buy_form.dart b/lib/pages/buy_view/buy_form.dart index 8f8a0c9b82..93b64400d4 100644 --- a/lib/pages/buy_view/buy_form.dart +++ b/lib/pages/buy_view/buy_form.dart @@ -57,7 +57,7 @@ import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../address_book_views/address_book_view.dart'; -import '../exchange_view/choose_from_stack_view.dart'; +import '../exchange_view/choose_address_from_stack_view.dart'; import 'buy_quote_preview.dart'; import 'sub_widgets/crypto_selection_view.dart'; import 'sub_widgets/fiat_selection_view.dart'; @@ -163,14 +163,13 @@ class _BuyFormState extends ConsumerState { unawaited( showDialog( context: context, - builder: - (context) => WillPopScope( - child: const CustomLoadingOverlay( - message: "Loading currency data", - eventBus: null, - ), - onWillPop: () async => shouldPop, - ), + builder: (context) => WillPopScope( + child: const CustomLoadingOverlay( + message: "Loading currency data", + eventBus: null, + ), + onWillPop: () async => shouldPop, + ), ), ); await _loadSimplexCryptos(); @@ -204,62 +203,60 @@ class _BuyFormState extends ConsumerState { _fiatFocusNode.unfocus(); _cryptoFocusNode.unfocus(); - final result = - isDesktop - ? await showDialog( - context: context, - builder: (context) { - return DesktopDialog( - maxHeight: 700, - maxWidth: 580, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Choose a crypto to buy", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, + final result = isDesktop + ? await showDialog( + context: context, + builder: (context) { + return DesktopDialog( + maxHeight: 700, + maxWidth: 580, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Choose a crypto to buy", + style: STextStyles.desktopH3(context), ), - child: Row( - children: [ - Expanded( - child: RoundedWhiteContainer( - padding: const EdgeInsets.all(16), - borderColor: - Theme.of( - context, - ).extension()!.background, - child: CryptoSelectionView(coins: coins), - ), + ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + ), + child: Row( + children: [ + Expanded( + child: RoundedWhiteContainer( + padding: const EdgeInsets.all(16), + borderColor: Theme.of( + context, + ).extension()!.background, + child: CryptoSelectionView(coins: coins), ), - ], - ), + ), + ], ), ), - ], - ), - ); - }, - ) - : await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => CryptoSelectionView(coins: coins), - ), - ); + ), + ], + ), + ); + }, + ) + : await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CryptoSelectionView(coins: coins), + ), + ); if (mounted && result is Crypto) { onSelected(result); @@ -272,14 +269,13 @@ class _BuyFormState extends ConsumerState { unawaited( showDialog( context: context, - builder: - (context) => WillPopScope( - child: const CustomLoadingOverlay( - message: "Loading currency data", - eventBus: null, - ), - onWillPop: () async => shouldPop, - ), + builder: (context) => WillPopScope( + child: const CustomLoadingOverlay( + message: "Loading currency data", + eventBus: null, + ), + onWillPop: () async => shouldPop, + ), ), ); await _loadSimplexFiats(); @@ -333,62 +329,60 @@ class _BuyFormState extends ConsumerState { _fiatFocusNode.unfocus(); _cryptoFocusNode.unfocus(); - final result = - isDesktop - ? await showDialog( - context: context, - builder: (context) { - return DesktopDialog( - maxHeight: 700, - maxWidth: 580, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Choose a fiat with which to pay", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, + final result = isDesktop + ? await showDialog( + context: context, + builder: (context) { + return DesktopDialog( + maxHeight: 700, + maxWidth: 580, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Choose a fiat with which to pay", + style: STextStyles.desktopH3(context), ), - child: Row( - children: [ - Expanded( - child: RoundedWhiteContainer( - padding: const EdgeInsets.all(16), - borderColor: - Theme.of( - context, - ).extension()!.background, - child: FiatSelectionView(fiats: fiats), - ), + ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + ), + child: Row( + children: [ + Expanded( + child: RoundedWhiteContainer( + padding: const EdgeInsets.all(16), + borderColor: Theme.of( + context, + ).extension()!.background, + child: FiatSelectionView(fiats: fiats), ), - ], - ), + ), + ], ), ), - ], - ), - ); - }, - ) - : await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => FiatSelectionView(fiats: fiats), - ), - ); + ), + ], + ), + ); + }, + ) + : await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => FiatSelectionView(fiats: fiats), + ), + ); if (mounted && result is Fiat) { onSelected(result); @@ -406,28 +400,25 @@ class _BuyFormState extends ConsumerState { unawaited( showDialog( context: context, - builder: - (context) => WillPopScope( - child: const CustomLoadingOverlay( - message: "Loading quote data", - eventBus: null, - ), - onWillPop: () async => shouldPop, - ), + builder: (context) => WillPopScope( + child: const CustomLoadingOverlay( + message: "Loading quote data", + eventBus: null, + ), + onWillPop: () async => shouldPop, + ), ), ); quote = SimplexQuote( crypto: selectedCrypto!, fiat: selectedFiat!, - youPayFiatPrice: - buyWithFiat - ? Decimal.parse(_buyAmountController.text) - : Decimal.parse("100"), // dummy value - youReceiveCryptoAmount: - buyWithFiat - ? Decimal.parse("0.000420282") // dummy value - : Decimal.parse(_buyAmountController.text), // Ternary for this + youPayFiatPrice: buyWithFiat + ? Decimal.parse(_buyAmountController.text) + : Decimal.parse("100"), // dummy value + youReceiveCryptoAmount: buyWithFiat + ? Decimal.parse("0.000420282") // dummy value + : Decimal.parse(_buyAmountController.text), // Ternary for this id: "id", // anything; we get an ID back receivingAddress: _receiveAddressController.text, buyWithFiat: buyWithFiat, @@ -500,10 +491,9 @@ class _BuyFormState extends ConsumerState { child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), onPressed: () { @@ -587,10 +577,9 @@ class _BuyFormState extends ConsumerState { child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), onPressed: () { @@ -628,62 +617,60 @@ class _BuyFormState extends ConsumerState { _fiatFocusNode.unfocus(); _cryptoFocusNode.unfocus(); - final result = - isDesktop - ? await showDialog( - context: context, - builder: (context) { - return DesktopDialog( - maxHeight: 700, - maxWidth: 580, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Preview quote", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, + final result = isDesktop + ? await showDialog( + context: context, + builder: (context) { + return DesktopDialog( + maxHeight: 700, + maxWidth: 580, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Preview quote", + style: STextStyles.desktopH3(context), ), - child: Row( - children: [ - Expanded( - child: RoundedWhiteContainer( - padding: const EdgeInsets.all(16), - borderColor: - Theme.of( - context, - ).extension()!.background, - child: BuyQuotePreviewView(quote: quote), - ), + ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + ), + child: Row( + children: [ + Expanded( + child: RoundedWhiteContainer( + padding: const EdgeInsets.all(16), + borderColor: Theme.of( + context, + ).extension()!.background, + child: BuyQuotePreviewView(quote: quote), ), - ], - ), + ), + ], ), ), - ], - ), - ); - }, - ) - : await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => BuyQuotePreviewView(quote: quote), - ), - ); + ), + ], + ), + ); + }, + ) + : await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BuyQuotePreviewView(quote: quote), + ), + ); if (mounted && result is SimplexQuote) { onSelected(result); @@ -698,11 +685,12 @@ class _BuyFormState extends ConsumerState { } final qrResult = await ref.read(pBarcodeScanner).scan(context: context); + if (qrResult.rawContent == null) return; Logging.instance.d("qrResult content: ${qrResult.rawContent}"); final paymentData = AddressUtils.parsePaymentUri( - qrResult.rawContent, + qrResult.rawContent!, logging: Logging.instance, ); @@ -816,18 +804,14 @@ class _BuyFormState extends ConsumerState { builder: (child) => SizedBox(width: 458, child: child), child: ConditionalParent( condition: !isDesktop, - builder: - (child) => LayoutBuilder( - builder: - (context, constraints) => SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - child: IntrinsicHeight(child: child), - ), - ), + builder: (child) => LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight(child: child), ), + ), + ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -852,15 +836,14 @@ class _BuyFormState extends ConsumerState { vertical: 6, horizontal: 2, ), - color: - _hovering1 - ? Theme.of(context) - .extension()! - .currencyListItemBG - .withOpacity(_hovering1 ? 0.3 : 0) - : Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: _hovering1 + ? Theme.of(context) + .extension()! + .currencyListItemBG + .withOpacity(_hovering1 ? 0.3 : 0) + : Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: Padding( padding: const EdgeInsets.all(12), child: Row( @@ -878,10 +861,9 @@ class _BuyFormState extends ConsumerState { ), SvgPicture.asset( Assets.svg.chevronDown, - color: - Theme.of(context) - .extension()! - .buttonTextSecondaryDisabled, + color: Theme.of(context) + .extension()! + .buttonTextSecondaryDisabled, width: 10, height: 5, ), @@ -899,8 +881,9 @@ class _BuyFormState extends ConsumerState { Text( "I want to pay with", style: STextStyles.itemSubtitle(context).copyWith( - color: - Theme.of(context).extension()!.textDark3, + color: Theme.of( + context, + ).extension()!.textDark3, ), ), ], @@ -919,15 +902,14 @@ class _BuyFormState extends ConsumerState { vertical: 3, horizontal: 2, ), - color: - _hovering2 - ? Theme.of(context) - .extension()! - .currencyListItemBG - .withOpacity(_hovering2 ? 0.3 : 0) - : Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: _hovering2 + ? Theme.of(context) + .extension()! + .currencyListItemBG + .withOpacity(_hovering2 ? 0.3 : 0) + : Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: Padding( padding: const EdgeInsets.only( left: 12.0, @@ -943,10 +925,9 @@ class _BuyFormState extends ConsumerState { horizontal: 6, ), decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.currencyListItemBG, + color: Theme.of( + context, + ).extension()!.currencyListItemBG, borderRadius: BorderRadius.circular(4), ), child: Text( @@ -955,10 +936,9 @@ class _BuyFormState extends ConsumerState { ), textAlign: TextAlign.center, style: STextStyles.smallMed12(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -976,10 +956,9 @@ class _BuyFormState extends ConsumerState { ), SvgPicture.asset( Assets.svg.chevronDown, - color: - Theme.of(context) - .extension()! - .buttonTextSecondaryDisabled, + color: Theme.of(context) + .extension()! + .buttonTextSecondaryDisabled, width: 10, height: 5, ), @@ -997,8 +976,9 @@ class _BuyFormState extends ConsumerState { Text( buyWithFiat ? "Enter amount" : "Enter crypto amount", style: STextStyles.itemSubtitle(context).copyWith( - color: - Theme.of(context).extension()!.textDark3, + color: Theme.of( + context, + ).extension()!.textDark3, ), ), CustomTextButton( @@ -1026,13 +1006,12 @@ class _BuyFormState extends ConsumerState { // ? _BuyFormState.minFiat.toStringAsFixed(2) ?? '50.00' // : _BuyFormState.minCrypto.toStringAsFixed(8), focusNode: _buyAmountFocusNode, - keyboardType: - Util.isDesktop - ? null - : const TextInputType.numberWithOptions( - signed: false, - decimal: true, - ), + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), textAlign: TextAlign.left, // inputFormatters: [NumericalRangeFormatter()], onChanged: (_) { @@ -1050,10 +1029,9 @@ class _BuyFormState extends ConsumerState { ), hintText: "0", hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultText, + color: Theme.of( + context, + ).extension()!.textFieldDefaultText, ), prefixIcon: FittedBox( fit: BoxFit.scaleDown, @@ -1064,34 +1042,33 @@ class _BuyFormState extends ConsumerState { const SizedBox(width: 2), buyWithFiat ? Container( - padding: const EdgeInsets.symmetric( - vertical: 3, - horizontal: 6, - ), - decoration: BoxDecoration( - color: - Theme.of(context) - .extension()! - .currencyListItemBG, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - format.simpleCurrencySymbol( - selectedFiat?.ticker.toUpperCase() ?? "ERR", + padding: const EdgeInsets.symmetric( + vertical: 3, + horizontal: 6, ), - textAlign: TextAlign.center, - style: STextStyles.smallMed12(context).copyWith( - color: - Theme.of(context) - .extension()! - .accentColorDark, + decoration: BoxDecoration( + color: Theme.of(context) + .extension()! + .currencyListItemBG, + borderRadius: BorderRadius.circular(4), ), - ), - ) + child: Text( + format.simpleCurrencySymbol( + selectedFiat?.ticker.toUpperCase() ?? "ERR", + ), + textAlign: TextAlign.center, + style: STextStyles.smallMed12(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark, + ), + ), + ) : CoinIconForTicker( - ticker: selectedCrypto?.ticker ?? "BTC", - size: 20, - ), + ticker: selectedCrypto?.ticker ?? "BTC", + size: 20, + ), SizedBox( width: buyWithFiat ? 8 : 10, ), // maybe make isDesktop-aware? @@ -1100,10 +1077,9 @@ class _BuyFormState extends ConsumerState { ? selectedFiat?.ticker ?? "ERR" : selectedCrypto?.ticker ?? "ERR", style: STextStyles.smallMed14(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ], @@ -1118,50 +1094,49 @@ class _BuyFormState extends ConsumerState { children: [ _buyAmountController.text.isNotEmpty ? TextFieldIconButton( - key: const Key( - "buyViewClearAmountFieldButtonKey", - ), - onTap: () { - // if (_BuyFormState.buyWithFiat) { - // _buyAmountController.text = _BuyFormState - // .minFiat - // .toStringAsFixed(2); - // } else { - // if (selectedCrypto?.ticker == - // _BuyFormState.boundedCryptoTicker) { - // _buyAmountController.text = _BuyFormState - // .minCrypto - // .toStringAsFixed(8); - // } - // } - _buyAmountController.text = ""; - validateAmount(); - }, - child: const XIcon(), - ) + key: const Key( + "buyViewClearAmountFieldButtonKey", + ), + onTap: () { + // if (_BuyFormState.buyWithFiat) { + // _buyAmountController.text = _BuyFormState + // .minFiat + // .toStringAsFixed(2); + // } else { + // if (selectedCrypto?.ticker == + // _BuyFormState.boundedCryptoTicker) { + // _buyAmountController.text = _BuyFormState + // .minCrypto + // .toStringAsFixed(8); + // } + // } + _buyAmountController.text = ""; + validateAmount(); + }, + child: const XIcon(), + ) : TextFieldIconButton( - key: const Key( - "buyViewPasteAddressFieldButtonKey", - ), - onTap: () async { - final ClipboardData? data = await clipboard - .getData(Clipboard.kTextPlain); + key: const Key( + "buyViewPasteAddressFieldButtonKey", + ), + onTap: () async { + final ClipboardData? data = await clipboard + .getData(Clipboard.kTextPlain); - final amountString = Decimal.tryParse( - data?.text ?? "", - ); - if (amountString != null) { - _buyAmountController.text = - amountString.toString(); + final amountString = Decimal.tryParse( + data?.text ?? "", + ); + if (amountString != null) { + _buyAmountController.text = amountString + .toString(); - validateAmount(); - } - }, - child: - _buyAmountController.text.isEmpty - ? const ClipboardIcon() - : const XIcon(), - ), + validateAmount(); + } + }, + child: _buyAmountController.text.isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), ], ), ), @@ -1182,8 +1157,9 @@ class _BuyFormState extends ConsumerState { Text( "Enter receiving address", style: STextStyles.itemSubtitle(context).copyWith( - color: - Theme.of(context).extension()!.textDark3, + color: Theme.of( + context, + ).extension()!.textDark3, ), ), if (AppConfig.isStackCoin(selectedCrypto?.ticker)) @@ -1196,21 +1172,26 @@ class _BuyFormState extends ConsumerState { ); Navigator.of(context) .pushNamed( - ChooseFromStackView.routeName, + ChooseAddressFromStackView.routeName, arguments: coin, ) .then((value) async { - if (value is String) { + if (value + is ({ + String walletId, + String address, + String walletName, + })) { final wallet = ref .read(pWallets) - .getWallet(value); + .getWallet(value.walletId); // _toController.text = manager.walletName; // model.recipientAddress = // await manager.currentReceivingAddress; - final address = - await wallet.getCurrentReceivingAddress(); + final address = await wallet + .getCurrentReceivingAddress(); if (address!.type == AddressType.p2tr && wallet is Bip39HDWallet) { @@ -1289,85 +1270,87 @@ class _BuyFormState extends ConsumerState { }, focusNode: _receiveAddressFocusNode, style: STextStyles.field(context), - decoration: standardInputDecoration( - "Enter ${selectedCrypto?.ticker} address", - _receiveAddressFocusNode, - context, - ).copyWith( - contentPadding: const EdgeInsets.only( - left: 13, - top: 6, - bottom: 8, - right: 5, - ), - suffixIcon: Padding( - padding: - _receiveAddressController.text.isEmpty + decoration: + standardInputDecoration( + "Enter ${selectedCrypto?.ticker} address", + _receiveAddressFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 13, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _receiveAddressController.text.isEmpty ? const EdgeInsets.only(right: 8) : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _addressToggleFlag - ? TextFieldIconButton( - key: const Key( - "buyViewClearAddressFieldButtonKey", - ), - onTap: () { - _receiveAddressController.text = ""; - _address = ""; - setState(() { - _addressToggleFlag = false; - }); - }, - child: const XIcon(), - ) - : TextFieldIconButton( - key: const Key( - "buyViewPasteAddressFieldButtonKey", - ), - onTap: () async { - final ClipboardData? data = await clipboard - .getData(Clipboard.kTextPlain); - if (data?.text != null && - data!.text!.isNotEmpty) { - String content = data.text!.trim(); - if (content.contains("\n")) { - content = content.substring( - 0, - content.indexOf("\n"), - ); - } - - _receiveAddressController.text = content; - _address = content; - - setState(() { - _addressToggleFlag = - _receiveAddressController - .text - .isNotEmpty; - }); - } - }, - child: - _receiveAddressController.text.isEmpty - ? const ClipboardIcon() - : const XIcon(), - ), - if (_receiveAddressController.text.isEmpty && - AppConfig.isStackCoin(selectedCrypto?.ticker) && - isDesktop) - TextFieldIconButton( - key: const Key("buyViewAddressBookButtonKey"), - onTap: () async { - final entry = await showDialog< - ContactAddressEntry? - >( - context: context, - builder: - (context) => DesktopDialog( + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _addressToggleFlag + ? TextFieldIconButton( + key: const Key( + "buyViewClearAddressFieldButtonKey", + ), + onTap: () { + _receiveAddressController.text = ""; + _address = ""; + setState(() { + _addressToggleFlag = false; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "buyViewPasteAddressFieldButtonKey", + ), + onTap: () async { + final ClipboardData? data = + await clipboard.getData( + Clipboard.kTextPlain, + ); + if (data?.text != null && + data!.text!.isNotEmpty) { + String content = data.text!.trim(); + if (content.contains("\n")) { + content = content.substring( + 0, + content.indexOf("\n"), + ); + } + + _receiveAddressController.text = + content; + _address = content; + + setState(() { + _addressToggleFlag = + _receiveAddressController + .text + .isNotEmpty; + }); + } + }, + child: + _receiveAddressController.text.isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_receiveAddressController.text.isEmpty && + AppConfig.isStackCoin( + selectedCrypto?.ticker, + ) && + isDesktop) + TextFieldIconButton( + key: const Key("buyViewAddressBookButtonKey"), + onTap: () async { + final entry = await showDialog( + context: context, + builder: (context) => DesktopDialog( maxWidth: 696, maxHeight: 600, child: Column( @@ -1410,45 +1393,47 @@ class _BuyFormState extends ConsumerState { ], ), ), - ); + ); - if (entry != null) { - _receiveAddressController.text = - entry.address; - _address = entry.address; + if (entry != null) { + _receiveAddressController.text = + entry.address; + _address = entry.address; - setState(() { - _addressToggleFlag = true; - }); - } - }, - child: const AddressBookIcon(), - ), - if (_receiveAddressController.text.isEmpty && - AppConfig.isStackCoin(selectedCrypto?.ticker) && - !isDesktop) - TextFieldIconButton( - key: const Key("buyViewAddressBookButtonKey"), - onTap: () { - Navigator.of( - context, - rootNavigator: isDesktop, - ).pushNamed(AddressBookView.routeName); - }, - child: const AddressBookIcon(), - ), - if (_receiveAddressController.text.isEmpty && - !isDesktop) - TextFieldIconButton( - key: const Key("buyViewScanQrButtonKey"), - onTap: _onQrTapped, - child: const QrCodeIcon(), - ), - ], + setState(() { + _addressToggleFlag = true; + }); + } + }, + child: const AddressBookIcon(), + ), + if (_receiveAddressController.text.isEmpty && + AppConfig.isStackCoin( + selectedCrypto?.ticker, + ) && + !isDesktop) + TextFieldIconButton( + key: const Key("buyViewAddressBookButtonKey"), + onTap: () { + Navigator.of( + context, + rootNavigator: isDesktop, + ).pushNamed(AddressBookView.routeName); + }, + child: const AddressBookIcon(), + ), + if (_receiveAddressController.text.isEmpty && + !isDesktop) + TextFieldIconButton( + key: const Key("buyViewScanQrButtonKey"), + onTap: _onQrTapped, + child: const QrCodeIcon(), + ), + ], + ), + ), ), ), - ), - ), ), ), SizedBox(height: isDesktop ? 10 : 4), diff --git a/lib/pages/buy_view/sub_widgets/crypto_selection_view.dart b/lib/pages/buy_view/sub_widgets/crypto_selection_view.dart index 089b492af1..7f7705b436 100644 --- a/lib/pages/buy_view/sub_widgets/crypto_selection_view.dart +++ b/lib/pages/buy_view/sub_widgets/crypto_selection_view.dart @@ -98,8 +98,9 @@ class _CryptoSelectionViewState extends ConsumerState { builder: (child) { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () async { @@ -109,7 +110,7 @@ class _CryptoSelectionViewState extends ConsumerState { const Duration(milliseconds: 50), ); } - if (mounted) { + if (context.mounted) { Navigator.of(context).pop(); } }, @@ -145,45 +146,45 @@ class _CryptoSelectionViewState extends ConsumerState { focusNode: _searchFocusNode, onChanged: filter, style: STextStyles.field(context), - decoration: standardInputDecoration( - "Search", - _searchFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 16, - height: 16, - ), - ), - suffixIcon: - _searchController.text.isNotEmpty + decoration: + standardInputDecoration( + "Search", + _searchFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 16, + height: 16, + ), + ), + 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 = ""; - }); - filter(""); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _searchController.text = ""; + }); + filter(""); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), const SizedBox(height: 10), @@ -226,14 +227,12 @@ class _CryptoSelectionViewState extends ConsumerState { const SizedBox(height: 2), Text( _coins[index].ticker.toUpperCase(), - style: STextStyles.smallMed12( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.smallMed12(context) + .copyWith( + color: Theme.of(context) .extension()! .textSubtitle1, - ), + ), ), ], ), diff --git a/lib/pages/cakepay/cakepay_card_detail_view.dart b/lib/pages/cakepay/cakepay_card_detail_view.dart new file mode 100644 index 0000000000..09a205c45e --- /dev/null +++ b/lib/pages/cakepay/cakepay_card_detail_view.dart @@ -0,0 +1,599 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../../services/cakepay/cakepay_service.dart'; +import '../../services/cakepay/src/models/card.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/dialogs/request_external_link_navigation_dialog.dart'; +import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/icon_widgets/credit_card_icon.dart'; +import '../../widgets/loading_indicator.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../../widgets/textfields/adaptive_text_field.dart'; +import 'cakepay_order_view.dart'; + +class CakePayCardDetailView extends StatefulWidget { + const CakePayCardDetailView({super.key, required this.card}); + + static const String routeName = "/cakePayCardDetail"; + + final CakePayCard card; + + @override + State createState() => _CakePayCardDetailViewState(); +} + +class _CakePayCardDetailViewState extends State { + late CakePayCard _card; + bool _purchasing = false; + Decimal? _selectedDenomination; + int _quantity = 1; + bool _termsAccepted = false; + final _customAmountController = TextEditingController(); + final _emailController = TextEditingController(); + + bool _canPurchase = false; + + void _updateCanPurchase() { + if (mounted) { + final check = _checkCanPurchase(); + if (check != _canPurchase) { + setState(() => _canPurchase = check); + } + } + } + + String get _priceString { + if (_card.isFixedDenomination && _selectedDenomination != null) { + return _selectedDenomination!.toStringAsFixed(2); + } + return _customAmountController.text.trim(); + } + + 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; + if (_card.isRangeDenomination) { + if (_card.minValue != null && parsed < _card.minValue!) return false; + if (_card.maxValue != null && parsed > _card.maxValue!) return false; + } + return true; + } + + Future _openTerms() async { + const url = "https://cakepay.com/terms/"; + await showRequestExternalLinkAndMaybeLaunch(context, uri: Uri.parse(url)); + } + + Future _purchase() async { + if (!_checkCanPurchase()) return; + setState(() => _purchasing = true); + + final resp = await CakePayService.instance.client.createOrder( + cardId: _card.id, + price: _priceString, + quantity: _quantity > 1 ? _quantity : null, + userEmail: _emailController.text.trim(), + confirmsNoVpn: true, + confirmsVoidedRefund: true, + confirmsTermsAgreed: true, + ); + + if (mounted) { + setState(() => _purchasing = false); + if (!resp.hasError && resp.value != null) { + final order = resp.value!; + + await CakePayService.instance.addOrderId(order.orderId); + + if (mounted) { + await Navigator.of( + context, + ).pushReplacementNamed(CakePayOrderView.routeName, arguments: order); + } + } else { + final String errorMessage; + if (resp.exception != null) { + final ex = resp.exception!; + final body = ex.responseBody; + errorMessage = "${ex.message}${body != null ? "\n$body" : ""}"; + } else { + errorMessage = "Failed to create order"; + } + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackOkDialog( + title: "Purchase failed", + message: errorMessage, + maxWidth: Util.isDesktop ? 580 : null, + desktopPopRootNavigator: Util.isDesktop, + ); + }, + ); + } + } + } + + @override + void initState() { + super.initState(); + _card = widget.card; + if (_card.isFixedDenomination && _card.denominations.isNotEmpty) { + _selectedDenomination = _card.denominations.first; + } + } + + @override + void dispose() { + _customAmountController.dispose(); + _emailController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final card = _card; + + return ConditionalParent( + condition: isDesktop, + builder: (child) => SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Gift Card", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: child, + ), + ), + ], + ), + ), + ), + child: ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: Text("Gift Card", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 16, left: 16, right: 16), + child: SingleChildScrollView(child: child), + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, + children: [ + if (card.cardImageUrl != null) + _CardImage(imageUrl: card.cardImageUrl!, isDesktop: isDesktop), + SizedBox(height: isDesktop ? 24 : 16), + Text( + card.name, + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + if (card.description != null && card.description!.isNotEmpty) ...[ + SizedBox(height: isDesktop ? 16 : 12), + _PlainInfoBlock(text: card.description!, isDesktop: isDesktop), + ], + if (card.howToUse != null && card.howToUse!.isNotEmpty) ...[ + SizedBox(height: isDesktop ? 16 : 12), + _TitledInfoBlock( + title: "How to use", + body: card.howToUse!, + isDesktop: isDesktop, + ), + ], + if (card.termsAndConditions != null && + card.termsAndConditions!.isNotEmpty) ...[ + SizedBox(height: isDesktop ? 16 : 12), + _TitledInfoBlock( + title: "Terms & conditions", + body: card.termsAndConditions!, + isDesktop: isDesktop, + ), + ], + if (card.expiryAndValidity != null && + card.expiryAndValidity!.isNotEmpty) ...[ + SizedBox(height: isDesktop ? 16 : 12), + _TitledInfoBlock( + title: "Expiry & validity", + body: card.expiryAndValidity!, + isDesktop: isDesktop, + ), + ], + SizedBox(height: isDesktop ? 24 : 16), + _DenominationSelector( + card: card, + isDesktop: isDesktop, + selectedDenomination: _selectedDenomination, + customAmountController: _customAmountController, + onDenominationSelected: (Decimal d) { + setState(() => _selectedDenomination = d); + _updateCanPurchase(); + }, + onCustomAmountChanged: _updateCanPurchase, + ), + SizedBox(height: isDesktop ? 16 : 12), + _QuantityRow( + isDesktop: isDesktop, + quantity: _quantity, + onDecrement: _quantity > 1 + ? () => setState(() => _quantity--) + : null, + onIncrement: () => setState(() => _quantity++), + ), + SizedBox(height: isDesktop ? 24 : 16), + _TermsCheckbox( + isDesktop: isDesktop, + accepted: _termsAccepted, + onToggle: () { + setState(() => _termsAccepted = !_termsAccepted); + _updateCanPurchase(); + }, + onOpenTerms: _openTerms, + ), + SizedBox(height: isDesktop ? 24 : 16), + Text( + "Email for receipt and delivery", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 8), + AdaptiveTextField( + labelText: "Email", + controller: _emailController, + showPasteClearButton: true, + keyboardType: .emailAddress, + onChangedComprehensive: (_) => _updateCanPurchase(), + ), + SizedBox(height: isDesktop ? 24 : 16), + PrimaryButton( + label: _purchasing ? "Processing..." : "Purchase", + enabled: _canPurchase, + onPressed: _canPurchase ? _purchase : null, + ), + SizedBox(height: isDesktop ? 32 : 16), + ], + ), + ), + ); + } +} + +class _CardImage extends StatelessWidget { + const _CardImage({required this.imageUrl, required this.isDesktop}); + + final String imageUrl; + final bool isDesktop; + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: isDesktop, + builder: (child) => Padding( + padding: const .symmetric(vertical: 8), + child: Center(child: child), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + imageUrl, + width: isDesktop ? 300 : null, + fit: isDesktop ? .contain : .fitWidth, + loadingBuilder: (_, child, event) { + if (event != null) { + return LoadingIndicator( + width: isDesktop ? 80 : 60, + height: isDesktop ? 80 : 60, + ); + } + return child; + }, + errorBuilder: (BuildContext _, Object __, StackTrace? ___) => Center( + child: CreditCardIcon( + width: isDesktop ? 80 : 60, + height: isDesktop ? 80 : 60, + ), + ), + ), + ), + ); + } +} + +class _PlainInfoBlock extends StatelessWidget { + const _PlainInfoBlock({required this.text, required this.isDesktop}); + + final String text; + final bool isDesktop; + + @override + Widget build(BuildContext context) { + return RoundedWhiteContainer( + borderColor: isDesktop + ? Theme.of(context).extension()!.textFieldDefaultBG + : null, + padding: isDesktop ? const .all(16) : const .all(12), + child: Text( + text, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ); + } +} + +class _TitledInfoBlock extends StatelessWidget { + const _TitledInfoBlock({ + required this.title, + required this.body, + required this.isDesktop, + }); + + final String title; + final String body; + final bool isDesktop; + + @override + Widget build(BuildContext context) { + return RoundedWhiteContainer( + borderColor: isDesktop + ? Theme.of(context).extension()!.textFieldDefaultBG + : null, + padding: isDesktop ? const .all(16) : const .all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + const SizedBox(height: 8), + Text( + body, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ], + ), + ); + } +} + +class _DenominationSelector extends StatelessWidget { + const _DenominationSelector({ + required this.card, + required this.isDesktop, + required this.selectedDenomination, + required this.customAmountController, + required this.onDenominationSelected, + required this.onCustomAmountChanged, + }); + + final CakePayCard card; + final bool isDesktop; + final Decimal? selectedDenomination; + final TextEditingController customAmountController; + final ValueChanged onDenominationSelected; + final VoidCallback onCustomAmountChanged; + + @override + Widget build(BuildContext context) { + if (card.isFixedDenomination) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: card.denominations.map((d) { + final bool selected = d == selectedDenomination; + return ChoiceChip( + label: Text( + "${d.toStringAsFixed(2)} ${card.currencyCode ?? ''}", + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: selected + ? Theme.of( + context, + ).extension()!.textDark + : null, + ), + ), + selected: selected, + onSelected: (bool val) { + if (val) onDenominationSelected(d); + }, + ); + }).toList(), + ); + } + + if (card.isRangeDenomination) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + children: [ + Text( + "Enter amount (${card.minValue?.toStringAsFixed(2) ?? '?'} - " + "${card.maxValue?.toStringAsFixed(2) ?? '?'} " + "${card.currencyCode ?? ''})", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 8), + AdaptiveTextField( + labelText: "Amount", + controller: customAmountController, + keyboardType: const .numberWithOptions(decimal: true), + onChangedComprehensive: (_) => onCustomAmountChanged(), + ), + ], + ); + } + + return const SizedBox.shrink(); + } +} + +class _QuantityRow extends StatelessWidget { + const _QuantityRow({ + required this.isDesktop, + required this.quantity, + required this.onDecrement, + required this.onIncrement, + }); + + final bool isDesktop; + final int quantity; + final VoidCallback? onDecrement; + final VoidCallback onIncrement; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text( + "Quantity", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.remove_circle_outline, size: 20), + onPressed: onDecrement, + ), + Text( + "$quantity", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + IconButton( + icon: const Icon(Icons.add_circle_outline, size: 20), + onPressed: onIncrement, + ), + ], + ); + } +} + +class _TermsCheckbox extends StatelessWidget { + const _TermsCheckbox({ + required this.isDesktop, + required this.accepted, + required this.onToggle, + required this.onOpenTerms, + }); + + final bool isDesktop; + final bool accepted; + final VoidCallback onToggle; + final VoidCallback onOpenTerms; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onToggle, + child: Container( + color: Colors.transparent, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 20, + height: 26, + child: IgnorePointer( + child: Checkbox( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: accepted, + onChanged: (_) {}, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: RichText( + text: TextSpan( + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.w500_14(context), + children: [ + const TextSpan(text: "I agree to the "), + TextSpan( + text: "terms and conditions", + style: STextStyles.richLink( + context, + ).copyWith(fontSize: isDesktop ? null : 14), + recognizer: TapGestureRecognizer()..onTap = onOpenTerms, + ), + const TextSpan( + text: + ", confirm I am not using a VPN, " + "and understand refunds are voided. " + "I understand that the gift card " + "will be delivered to the listed " + "email.", + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/cakepay/cakepay_confirm_send_view.dart b/lib/pages/cakepay/cakepay_confirm_send_view.dart new file mode 100644 index 0000000000..41ea7a14bf --- /dev/null +++ b/lib/pages/cakepay/cakepay_confirm_send_view.dart @@ -0,0 +1,625 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/isar/models/isar_models.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; +import '../../providers/providers.dart'; +import '../../route_generator.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/amount/amount_formatter.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/models/tx_data.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/rounded_container.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../pinpad_views/lock_screen_view.dart'; +import '../send_view/sub_widgets/sending_transaction_dialog.dart'; +import '../wallet_view/wallet_view.dart'; + +class CakePayConfirmSendView extends ConsumerStatefulWidget { + const CakePayConfirmSendView({ + super.key, + required this.txData, + required this.walletId, + this.routeOnSuccessName = WalletView.routeName, + required this.orderId, + }); + + static const String routeName = "/cakePayConfirmSend"; + + final TxData txData; + final String walletId; + final String routeOnSuccessName; + final String orderId; + + @override + ConsumerState createState() => + _CakePayConfirmSendViewState(); +} + +class _CakePayConfirmSendViewState + extends ConsumerState { + late final String walletId; + late final String routeOnSuccessName; + + final isDesktop = Util.isDesktop; + + Future _attemptSend(BuildContext context) async { + final parentWallet = ref.read(pWallets).getWallet(walletId); + final coin = parentWallet.info.coin; + + final sendProgressController = ProgressAndSuccessController(); + + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return SendingTransactionDialog( + coin: coin, + controller: sendProgressController, + ); + }, + ), + ); + + final time = Future.delayed(const Duration(milliseconds: 2500)); + + late String txid; + final String note = widget.txData.note ?? ""; + + try { + final txidFuture = parentWallet.confirmSend(txData: widget.txData); + + unawaited(parentWallet.refresh()); + + final results = await Future.wait([txidFuture, time]); + + sendProgressController.triggerSuccess?.call(); + await Future.delayed(const Duration(seconds: 5)); + + txid = (results.first as TxData).txid!; + + await ref + .read(mainDBProvider) + .putTransactionNote( + TransactionNote(walletId: walletId, txid: txid, value: note), + ); + + if (context.mounted) { + // pop sending dialog (pushed via showDialog which uses root navigator) + Navigator.of(context, rootNavigator: true).pop(); + + if (Util.isDesktop) { + // pop the confirm send desktop dialog + Navigator.of(context, rootNavigator: true).pop(); + } + + Navigator.of(context).popUntil(ModalRoute.withName(routeOnSuccessName)); + + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Payment sent! Check order status for updates.", + context: context, + ), + ); + } + } + } catch (e, s) { + Logging.instance.e( + "Broadcast transaction failed: ", + error: e, + stackTrace: s, + ); + + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Broadcast transaction failed", + message: e.toString(), + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, + ), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ); + }, + ); + } + } + } + + Future _confirmSend() async { + final dynamic unlocked; + + final coin = ref.read(pWalletCoin(walletId)); + + if (Util.isDesktop) { + unlocked = await showDialog( + context: context, + builder: (context) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [DesktopDialogCloseButton()], + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: DesktopAuthSend(coin: coin), + ), + ], + ), + ), + ); + } else { + unlocked = await Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => const LockscreenView( + showBackButton: true, + popOnSuccess: true, + routeOnSuccessArguments: true, + routeOnSuccess: "", + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: "Authenticate to send transaction", + biometricsAuthenticationTitle: "Confirm Transaction", + ), + settings: const RouteSettings(name: "/confirmsendlockscreen"), + ), + ); + } + + if (unlocked is bool && mounted) { + if (unlocked) { + await _attemptSend(context); + } else { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid passphrase", + context: context, + ), + ); + } + } + } + + @override + void initState() { + walletId = widget.walletId; + routeOnSuccessName = widget.routeOnSuccessName; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final coin = ref.watch(pWalletCoin(walletId)); + + return ConditionalParent( + condition: !isDesktop, + builder: (child) { + return Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, + leading: AppBarBackButton( + onPressed: () async { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Confirm transaction", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return Padding( + padding: const EdgeInsets.only( + left: 12, + top: 12, + right: 12, + ), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 24, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(4), + child: child, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + }, + child: ConditionalParent( + condition: isDesktop, + builder: (child) => DesktopDialog( + maxHeight: double.infinity, + maxWidth: 580, + child: Column( + children: [ + Row( + children: [ + const SizedBox(width: 6), + const AppBarBackButton(isCompact: true, iconSize: 23), + const SizedBox(width: 12), + Text( + "Confirm ${coin.ticker} transaction", + style: STextStyles.desktopH3(context), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: Column( + children: [ + RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + borderColor: Theme.of( + context, + ).extension()!.background, + child: child, + ), + const SizedBox(height: 16), + Row( + children: [ + Text( + "Transaction fee", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + ], + ), + const SizedBox(height: 10), + RoundedContainer( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + ref + .watch(pAmountFormatter(coin)) + .format(widget.txData.fee!), + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + RoundedContainer( + color: Theme.of( + context, + ).extension()!.snackBarBackSuccess, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Total amount", + style: STextStyles.titleBold12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), + ), + Builder( + builder: (context) { + final fee = widget.txData.fee!; + final amount = widget.txData.amountWithoutChange!; + final total = amount + fee; + return Text( + ref.watch(pAmountFormatter(coin)).format(total), + style: STextStyles.itemSubtitle12(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), + textAlign: TextAlign.right, + ); + }, + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: ButtonHeight.l, + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Send", + buttonHeight: isDesktop ? ButtonHeight.l : null, + onPressed: _confirmSend, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ConditionalParent( + condition: isDesktop, + builder: (child) => Container( + decoration: BoxDecoration( + color: Theme.of(context).extension()!.background, + borderRadius: BorderRadius.vertical( + top: Radius.circular(Constants.size.circularBorderRadius), + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row(children: [child]), + ), + ), + child: Text( + "Send ${coin.ticker}", + style: isDesktop + ? STextStyles.desktopTextMedium(context) + : STextStyles.pageTitleH1(context), + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text("Send from", style: STextStyles.smallMed12(context)), + const SizedBox(height: 4), + Text( + ref.watch(pWalletName(walletId)), + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "CakePay address", + style: STextStyles.smallMed12(context), + ), + const SizedBox(height: 4), + Text( + widget.txData.recipients!.first.address, + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Amount", style: STextStyles.smallMed12(context)), + Text( + ref + .watch(pAmountFormatter(coin)) + .format(widget.txData.amountWithoutChange!), + style: STextStyles.itemSubtitle12(context), + textAlign: TextAlign.right, + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Transaction fee", + style: STextStyles.smallMed12(context), + ), + Text( + ref + .watch(pAmountFormatter(coin)) + .format(widget.txData.fee!), + style: STextStyles.itemSubtitle12(context), + textAlign: TextAlign.right, + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text("Note", style: STextStyles.smallMed12(context)), + const SizedBox(height: 4), + Text( + widget.txData.note ?? "", + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Order ID", style: STextStyles.smallMed12(context)), + Text( + widget.orderId.length > 8 + ? "${widget.orderId.substring(0, 8)}..." + : widget.orderId, + style: STextStyles.itemSubtitle12(context), + textAlign: TextAlign.right, + ), + ], + ), + ), + if (!isDesktop) const SizedBox(height: 12), + if (!isDesktop) + RoundedContainer( + color: Theme.of( + context, + ).extension()!.snackBarBackSuccess, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Total amount", + style: STextStyles.titleBold12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textConfirmTotalAmount, + ), + ), + Builder( + builder: (context) { + final fee = widget.txData.fee!; + final amount = widget.txData.amountWithoutChange!; + final total = amount + fee; + return Text( + ref.watch(pAmountFormatter(coin)).format(total), + style: STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textConfirmTotalAmount, + ), + textAlign: TextAlign.right, + ); + }, + ), + ], + ), + ), + if (!isDesktop) const SizedBox(height: 16), + if (!isDesktop) const Spacer(), + if (!isDesktop) + PrimaryButton( + label: "Send", + buttonHeight: isDesktop ? ButtonHeight.l : null, + onPressed: _confirmSend, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/cakepay/cakepay_order_view.dart b/lib/pages/cakepay/cakepay_order_view.dart new file mode 100644 index 0000000000..4232a4edac --- /dev/null +++ b/lib/pages/cakepay/cakepay_order_view.dart @@ -0,0 +1,939 @@ +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../app_config.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../providers/global/cakepay_orders_provider.dart'; +import '../../providers/providers.dart'; +import '../../route_generator.dart'; +import '../../services/cakepay/cakepay_orders_service.dart'; +import '../../services/cakepay/src/models/order.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/qr.dart'; +import '../../widgets/refresh_control.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../wallet_view/transaction_views/transaction_details_view.dart'; +import 'cakepay_send_from_view.dart'; + +class CakePayOrderView extends ConsumerStatefulWidget { + const CakePayOrderView({super.key, required this.order}); + + static const String routeName = "/cakePayOrder"; + + final CakePayOrder order; + + @override + ConsumerState createState() => _CakePayOrderViewState(); +} + +class _CakePayOrderViewState extends ConsumerState { + late final CakePayOrdersService _ordersService; + Timer? _countdownTimer; + int? _countdownExpiration; + int _selectedPaymentMethod = 0; + bool _polling = false; + + @override + void initState() { + super.initState(); + _ordersService = ref.read(pCakePayOrdersService); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _polling = true; + _ordersService.startPolling(widget.order.orderId); + }); + } + + @override + void dispose() { + if (_polling) { + _ordersService.stopPolling(widget.order.orderId); + } + _countdownTimer?.cancel(); + super.dispose(); + } + + void _ensureCountdown(int? expirationTime) { + if (expirationTime == null) { + if (_countdownTimer != null) { + _countdownTimer?.cancel(); + _countdownTimer = null; + _countdownExpiration = null; + } + return; + } + if (_countdownExpiration == expirationTime && _countdownTimer != null) { + return; + } + _countdownExpiration = expirationTime; + _countdownTimer?.cancel(); + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + final remaining = _computeRemaining(expirationTime); + if (remaining <= Duration.zero) { + _countdownTimer?.cancel(); + _countdownTimer = null; + _countdownExpiration = null; + } + setState(() {}); + }); + } + + Duration _computeRemaining(int expirationTime) { + final expiresAt = DateTime.fromMillisecondsSinceEpoch(expirationTime); + final remaining = expiresAt.difference(DateTime.now()); + return remaining.isNegative ? Duration.zero : remaining; + } + + String _formatDuration(Duration d) { + if (d.isNegative || d == Duration.zero) return "Expired"; + final minutes = d.inMinutes; + final seconds = d.inSeconds % 60; + if (d.inHours > 0) { + return "${d.inHours}h ${minutes % 60}m ${seconds}s"; + } + return "${minutes}m ${seconds}s"; + } + + void _navigateToSendFrom({ + required CryptoCurrency coin, + required Amount? amount, + required String address, + required String orderId, + }) { + final isDesktop = Util.isDesktop; + if (isDesktop) { + showDialog( + context: context, + builder: (_) => CakePaySendFromView( + coin: coin, + amount: amount, + address: address, + orderId: orderId, + shouldPopRoot: true, + ), + ); + } else { + Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => CakePaySendFromView( + coin: coin, + amount: amount, + address: address, + orderId: orderId, + ), + settings: const RouteSettings(name: CakePaySendFromView.routeName), + ), + ); + } + } + + /// Resolve an API ticker (e.g. "LTC_MWEB") to a Stack Wallet coin, + /// falling back to the base ticker before "_" if the full one isn't + /// recognised. + CryptoCurrency? _resolveCoin(String apiTicker) { + final ticker = apiTicker.toUpperCase(); + var coin = AppConfig.getCryptoCurrencyForTicker(ticker); + if (coin == null && ticker.contains('_') && !ticker.endsWith('_LN')) { + coin = AppConfig.getCryptoCurrencyForTicker(ticker.split('_').first); + } + return coin; + } + + /// Pretty-print an API ticker for display. + String _tickerLabel(String apiTicker) { + switch (apiTicker.toUpperCase()) { + case 'BTC_LN': + return 'BTC (LN)'; + case 'LTC_MWEB': + return 'LTC (MWEB)'; + default: + return apiTicker.toUpperCase(); + } + } + + void _payWithOption(CakePayPaymentOption option, String orderId) { + final label = _tickerLabel(option.ticker); + final coin = _resolveCoin(option.ticker); + + if (option.address.trim().isEmpty) { + unawaited( + showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "No payment address available for $label", + maxWidth: Util.isDesktop ? 500 : null, + desktopPopRootNavigator: Util.isDesktop, + ), + ), + ); + return; + } + + if (coin == null) { + unawaited( + showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "No wallet support for $label", + maxWidth: Util.isDesktop ? 500 : null, + desktopPopRootNavigator: Util.isDesktop, + ), + ), + ); + return; + } + + final hasWallet = ref + .read(pWallets) + .wallets + .any((w) => w.info.coin == coin); + + if (!hasWallet) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: "No $label wallet found. Create one first.", + context: context, + ); + return; + } + + Amount? amount; + try { + amount = Amount.fromDecimal( + Decimal.parse(option.amountFrom.toString()), + fractionDigits: coin.fractionDigits, + ); + } catch (e, s) { + Logging.instance.e( + "Failed to parse CakePay order amount '${option.amountFrom}'", + error: e, + stackTrace: s, + ); + } + + _navigateToSendFrom( + coin: coin, + amount: amount, + address: option.address, + orderId: orderId, + ); + } + + /// Whether the order has received payment and is being processed or + /// is already complete. Payment UI should be hidden for these. + bool _isPaidOrBeyond(CakePayOrderStatus status) { + return const { + CakePayOrderStatus.paid, + CakePayOrderStatus.pendingPurchase, + CakePayOrderStatus.purchaseProcessing, + CakePayOrderStatus.purchased, + CakePayOrderStatus.pendingEmail, + CakePayOrderStatus.complete, + }.contains(status); + } + + /// Whether payment UI (tabs, QR, address, pay button) should be shown. + bool _showPaymentUI(CakePayOrderStatus status) { + return !_isPaidOrBeyond(status) && + status != CakePayOrderStatus.expired && + status != CakePayOrderStatus.failed && + status != CakePayOrderStatus.pendingRefund && + status != CakePayOrderStatus.refunded; + } + + /// Copyable order ID and created-at timestamp for terminal state banners. + List _orderInfoWidgets(CakePayOrder order, bool isDesktop) { + final subtitleStyle = isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context); + + return [ + // Copyable order ID. + RoundedWhiteContainer( + onPressed: () { + Clipboard.setData(ClipboardData(text: order.orderId)); + showFloatingFlushBar( + type: FlushBarType.info, + message: "Order ID copied", + iconAsset: Assets.svg.copy, + context: context, + ); + }, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Order ID", style: subtitleStyle), + const SizedBox(height: 4), + Text( + order.orderId, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], + ), + ), + IconCopyButton(data: order.orderId), + ], + ), + ), + // Created-at timestamp. + if (order.createdAt != null) ...[ + SizedBox(height: isDesktop ? 8 : 6), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Created", style: subtitleStyle), + Text(order.createdAt!, style: subtitleStyle), + ], + ), + ), + ], + ]; + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + final service = ref.watch(pCakePayOrdersService); + final order = service.get(widget.order.orderId) ?? widget.order; + final isRefreshing = service.isRefreshing(widget.order.orderId); + _ensureCountdown(order.expirationTime); + final remaining = order.expirationTime == null + ? Duration.zero + : _computeRemaining(order.expirationTime!); + final paymentOptions = order.paymentOptions; + + final details = [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: order.status + .color(Theme.of(context).extension()!) + .withValues(alpha: 0.2), + ), + child: Text( + order.status.label, + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: order.status.color( + Theme.of(context).extension()!, + ), + ), + ), + ), + ], + ), + SizedBox(height: isDesktop ? 8 : 6), + RoundedWhiteContainer( + onPressed: () { + Clipboard.setData(ClipboardData(text: order.orderId)); + showFloatingFlushBar( + type: FlushBarType.info, + message: "Order ID copied", + iconAsset: Assets.svg.copy, + context: context, + ); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Order ID", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const SizedBox(width: 8), + Flexible( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: SelectableText( + order.orderId, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ), + const SizedBox(width: 6), + IconCopyButton(data: order.orderId), + ], + ), + ), + ], + ), + ), + SizedBox(height: isDesktop ? 16 : 12), + ]; + + if (order.amountUsd != null) { + details.add( + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Amount", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + Text( + "\$${order.amountUsd} USD", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], + ), + ), + ); + details.add(SizedBox(height: isDesktop ? 16 : 12)); + } + + if (order.cards != null && order.cards!.isNotEmpty) { + for (final item in order.cards!) { + details.add( + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name ?? "Gift Card", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + if (item.priceValue != null) ...[ + const SizedBox(height: 4), + Text( + "${item.priceValue} ${item.currencyCode ?? ''}".trim(), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ], + if (item.priceUsd != null) ...[ + const SizedBox(height: 2), + Text( + item.priceUsd!, + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ], + ], + ), + ), + ); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + } + } + + // Commission / markup info. + if (order.commission != null || order.markupPercent != null) { + details.add( + RoundedWhiteContainer( + child: Column( + children: [ + if (order.commission != null) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Commission", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + Text( + order.commission!, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ], + ), + if (order.commission != null && order.markupPercent != null) + const SizedBox(height: 4), + if (order.markupPercent != null) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Markup", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + Text( + "${order.markupPercent!.toStringAsFixed(2)}%", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ], + ), + ], + ), + ), + ); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + } + + // Expiration countdown. + if (order.expirationTime != null) { + final isExpired = remaining == Duration.zero; + details.add( + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Time remaining", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + Text( + _formatDuration(remaining), + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: isExpired + ? Theme.of( + context, + ).extension()!.accentColorRed + : remaining.inMinutes < 5 + ? Theme.of( + context, + ).extension()!.accentColorOrange + : null, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + } + + // --- Status-dependent payment section --- + final status = order.status; + + // Banner for paid / processing states. + if (_isPaidOrBeyond(status)) { + details.add(SizedBox(height: isDesktop ? 16 : 12)); + details.add( + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SvgPicture.asset( + Assets.svg.checkCircle, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.accentColorGreen, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + status == CakePayOrderStatus.complete + ? "Order complete." + : "Payment received.", + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: Theme.of( + context, + ).extension()!.accentColorGreen, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + "Your gift card details will be sent to " + "the email address provided when creating " + "the order.", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + ); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + details.addAll(_orderInfoWidgets(order, isDesktop)); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + details.add( + const PrimaryButton( + label: "ORDER PAID", + enabled: false, + onPressed: null, + ), + ); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + } + + // Banner for expired / failed / refund states. + if (status == CakePayOrderStatus.expired || + status == CakePayOrderStatus.failed || + status == CakePayOrderStatus.pendingRefund || + status == CakePayOrderStatus.refunded) { + details.add(SizedBox(height: isDesktop ? 16 : 12)); + details.add( + RoundedWhiteContainer( + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.circleX, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + status.label, + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ), + ], + ), + ), + ); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + details.addAll(_orderInfoWidgets(order, isDesktop)); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + } + + // Payment UI: tabs + QR + address + pay button. + // Only shown for states that still accept payment. + if (_showPaymentUI(status) && + paymentOptions != null && + paymentOptions.isNotEmpty) { + // Sort so BTC_LN always appears last. + final options = paymentOptions.values.toList() + ..sort((a, b) { + final aLn = a.ticker.toUpperCase() == 'BTC_LN'; + final bLn = b.ticker.toUpperCase() == 'BTC_LN'; + if (aLn && !bLn) return 1; + if (!aLn && bLn) return -1; + return 0; + }); + if (_selectedPaymentMethod >= options.length) { + _selectedPaymentMethod = 0; + } + final selected = options[_selectedPaymentMethod]; + final label = _tickerLabel(selected.ticker); + final coin = _resolveCoin(selected.ticker); + final bool hasWallet = + coin != null && + ref.watch(pWallets).wallets.any((w) => w.info.coin == coin); + + details.add(SizedBox(height: isDesktop ? 8 : 4)); + details.add( + Text( + "Pay with", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + + // Tab selector. + details.add( + Row( + children: List.generate(options.length, (index) { + final isSelected = _selectedPaymentMethod == index; + return Expanded( + child: GestureDetector( + onTap: () => setState(() => _selectedPaymentMethod = index), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isSelected + ? Theme.of( + context, + ).extension()!.accentColorBlue + : Colors.transparent, + width: 2, + ), + ), + ), + child: Text( + _tickerLabel(options[index].ticker), + textAlign: TextAlign.center, + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: isSelected + ? Theme.of( + context, + ).extension()!.accentColorBlue + : null, + fontWeight: isSelected ? FontWeight.w600 : null, + ), + ), + ), + ), + ); + }), + ), + ); + + details.add(SizedBox(height: isDesktop ? 16 : 12)); + + // QR code for the selected payment address. + if (selected.address.isNotEmpty) { + details.add( + Center( + child: QR(data: selected.address, size: isDesktop ? 200 : 180), + ), + ); + details.add(SizedBox(height: isDesktop ? 16 : 12)); + } + + // Selected method details. + details.add( + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Amount", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + Text( + "${selected.amountFrom} $label", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], + ), + const SizedBox(height: 8), + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: selected.address)); + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + iconAsset: Assets.svg.copy, + context: context, + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + "$label address", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const Spacer(), + IconCopyButton(data: order.orderId), + const SizedBox(width: 4), + Text("Copy", style: STextStyles.link2(context)), + ], + ), + const SizedBox(height: 4), + Text( + selected.address, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + const SizedBox(height: 12), + PrimaryButton( + label: hasWallet ? "Pay with $label" : "$label (no wallet)", + enabled: hasWallet, + onPressed: hasWallet + ? () => _payWithOption(selected, order.orderId) + : null, + ), + ], + ), + ), + ); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + } + + final scrollable = SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: details, + ), + ); + + final content = RefreshControl( + onRefresh: () => service.refreshOne(widget.order.orderId), + child: scrollable, + ); + + return _scaffold( + isDesktop: isDesktop, + isRefreshing: isRefreshing, + onRefresh: () => service.refreshOne(widget.order.orderId), + child: content, + ); + } + + Widget _scaffold({ + required bool isDesktop, + required bool isRefreshing, + required Future Function() onRefresh, + required Widget child, + }) { + return ConditionalParent( + condition: isDesktop, + builder: (child) => SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text("Order", style: STextStyles.desktopH3(context)), + ), + Row( + mainAxisSize: .min, + children: [ + RefreshButton( + isRefreshing: isRefreshing, + onPressed: () => onRefresh(), + ), + const SizedBox(width: 8), + const DesktopDialogCloseButton(), + ], + ), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 8, + ), + child: child, + ), + ), + ], + ), + ), + ), + child: ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: Text("Order", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ), + ), + child: child, + ), + ); + } +} diff --git a/lib/pages/cakepay/cakepay_orders_view.dart b/lib/pages/cakepay/cakepay_orders_view.dart new file mode 100644 index 0000000000..2db794293c --- /dev/null +++ b/lib/pages/cakepay/cakepay_orders_view.dart @@ -0,0 +1,255 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../notifications/show_flush_bar.dart'; +import '../../providers/global/cakepay_orders_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/refresh_control.dart'; +import '../../widgets/rounded_container.dart'; +import 'cakepay_order_view.dart'; + +class CakePayOrdersView extends ConsumerStatefulWidget { + const CakePayOrdersView({super.key}); + + static const String routeName = "/cakePayOrders"; + + @override + ConsumerState createState() => _CakePayOrdersViewState(); +} + +class _CakePayOrdersViewState extends ConsumerState { + Future _refresh() async { + try { + await ref.read(pCakePayOrdersService).refreshAll(); + } catch (e, s) { + Logging.instance.e( + "$runtimeType._refresh failed", + error: e, + stackTrace: s, + ); + + if (!mounted) return; + + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Could not refresh orders", + context: context, + ), + ); + } + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(_refresh()); + }); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final service = ref.watch(pCakePayOrdersService); + final orders = service.all; + final isRefreshing = service.isRefreshingAll; + + final orderItems = []; + if (orders.isEmpty) { + orderItems.add(const SizedBox(height: 80)); + orderItems.add( + Center( + child: Text( + isRefreshing ? "Loading orders..." : "No orders yet", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + ), + ); + } else { + for (var i = 0; i < orders.length; i++) { + final order = orders[i]; + if (i > 0) orderItems.add(SizedBox(height: isDesktop ? 16 : 12)); + orderItems.add( + RoundedContainer( + padding: EdgeInsets.all(isDesktop ? 16 : 12), + borderColor: isDesktop + ? Theme.of(context).extension()!.textFieldDefaultBG + : null, + color: Theme.of(context).extension()!.popupBG, + onPressed: () { + Navigator.of( + context, + ).pushNamed(CakePayOrderView.routeName, arguments: order); + }, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + order.orderId.length > 8 + ? "${order.orderId.substring(0, 8)}..." + : order.orderId, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: order.status + .color( + Theme.of(context).extension()!, + ) + .withValues(alpha: 0.2), + ), + child: Text( + order.status.label, + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: order.status.color( + Theme.of( + context, + ).extension()!, + ), + ), + ), + ), + ], + ), + if (order.amountUsd != null) ...[ + const SizedBox(height: 4), + Text( + "\$${order.amountUsd} USD", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ], + ], + ), + ), + SizedBox(width: isDesktop ? 16 : 8), + SvgPicture.asset( + Assets.svg.chevronRight, + width: 24, + height: 24, + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ], + ), + ), + ); + } + } + + final body = RefreshControl( + onRefresh: _refresh, + child: ListView( + shrinkWrap: true, + physics: const AlwaysScrollableScrollPhysics(), + primary: isDesktop ? false : null, + padding: isDesktop ? const EdgeInsets.only(bottom: 32, top: 8) : null, + children: orderItems, + ), + ); + + return ConditionalParent( + condition: isDesktop, + builder: (child) => SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "My Orders", + style: STextStyles.desktopH3(context), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + RefreshButton( + isRefreshing: isRefreshing, + onPressed: _refresh, + ), + const SizedBox(width: 8), + const DesktopDialogCloseButton(), + ], + ), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: child, + ), + ), + ], + ), + ), + ), + child: ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: Text("My Orders", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ), + ), + child: body, + ), + ); + } +} diff --git a/lib/pages/cakepay/cakepay_send_from_view.dart b/lib/pages/cakepay/cakepay_send_from_view.dart new file mode 100644 index 0000000000..4213625d36 --- /dev/null +++ b/lib/pages/cakepay/cakepay_send_from_view.dart @@ -0,0 +1,408 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../app_config.dart'; +import '../../models/isar/models/blockchain_data/address.dart'; +import '../../providers/providers.dart'; +import '../../route_generator.dart'; +import '../../themes/coin_icon_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../themes/theme_providers.dart'; +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 '../../utilities/util.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/models/tx_data.dart'; +import '../../wallets/wallet/intermediate/external_wallet.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../../pages_desktop_specific/desktop_home_view.dart'; +import '../home_view/home_view.dart'; +import '../send_view/sub_widgets/building_transaction_dialog.dart'; +import 'cakepay_confirm_send_view.dart'; + +class CakePaySendFromView extends ConsumerStatefulWidget { + const CakePaySendFromView({ + super.key, + this.coin, + this.amount, + required this.address, + required this.orderId, + this.shouldPopRoot = false, + }); + + static const String routeName = "/cakePaySendFrom"; + + final CryptoCurrency? coin; + final Amount? amount; + final String address; + final String orderId; + final bool shouldPopRoot; + + @override + ConsumerState createState() => + _CakePaySendFromViewState(); +} + +class _CakePaySendFromViewState extends ConsumerState { + @override + Widget build(BuildContext context) { + final List walletIds; + if (widget.coin != null) { + walletIds = ref + .watch(pWallets) + .wallets + .where((e) => e.info.coin == widget.coin) + .map((e) => e.walletId) + .toList(); + } else { + walletIds = ref.watch(pWallets).wallets.map((e) => e.walletId).toList(); + } + + final isDesktop = Util.isDesktop; + + return ConditionalParent( + condition: !isDesktop, + builder: (child) { + return Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: Text("Send from", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ), + ); + }, + child: ConditionalParent( + condition: isDesktop, + builder: (child) => DesktopDialog( + maxHeight: double.infinity, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Send from ${AppConfig.prefix}", + style: STextStyles.desktopH3(context), + ), + ), + DesktopDialogCloseButton( + onPressedOverride: Navigator.of( + context, + rootNavigator: widget.shouldPopRoot, + ).pop, + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: child, + ), + ], + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Row( + children: [ + Text( + widget.amount != null && widget.coin != null + ? "You need to send ${ref.watch(pAmountFormatter(widget.coin!)).format(widget.amount!)}" + : "Select a wallet to pay", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle(context), + ), + ], + ), + const SizedBox(height: 16), + ConditionalParent( + condition: !isDesktop, + builder: (child) => Expanded(child: child), + child: ListView.builder( + primary: isDesktop ? false : null, + shrinkWrap: isDesktop, + itemCount: walletIds.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: _CakePaySendFromCard( + walletId: walletIds[index], + amount: widget.amount, + address: widget.address, + orderId: widget.orderId, + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _CakePaySendFromCard extends ConsumerStatefulWidget { + const _CakePaySendFromCard({ + required this.walletId, + this.amount, + required this.address, + required this.orderId, + }); + + final String walletId; + final Amount? amount; + final String address; + final String orderId; + + @override + ConsumerState<_CakePaySendFromCard> createState() => + _CakePaySendFromCardState(); +} + +class _CakePaySendFromCardState extends ConsumerState<_CakePaySendFromCard> { + Future _send() async { + final coin = ref.read(pWalletCoin(widget.walletId)); + final Amount? sendAmount = widget.amount; + + if (sendAmount == null) { + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Transaction failed", + message: "Payment amount not available yet", + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, + ), + ), + onPressed: () => Navigator.of(context).pop(), + ), + ); + }, + ); + return; + } + + bool wasCancelled = false; + + try { + final wallet = ref.read(pWallets).getWallet(widget.walletId); + + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopDialog( + maxWidth: 400, + maxHeight: double.infinity, + child: Padding(padding: const EdgeInsets.all(32), child: child), + ), + child: BuildingTransactionDialog( + coin: coin, + isSpark: false, + onCancel: () { + wasCancelled = true; + Navigator.of(context).pop(); + }, + ), + ); + }, + ), + ); + + if (wallet is ExternalWallet) { + await wallet.init(); + await wallet.open(); + } + + final time = Future.delayed(const Duration(milliseconds: 2500)); + + final addressType = + wallet.cryptoCurrency.getAddressType(widget.address) ?? + AddressType.unknown; + + final recipient = TxRecipient( + address: widget.address, + amount: sendAmount, + isChange: false, + addressType: addressType, + ); + + final txDataFuture = wallet.prepareSend( + txData: TxData( + recipients: [recipient], + feeRateType: FeeRateType.average, + ), + ); + + final results = await Future.wait([txDataFuture, time]); + + final txData = (results.first as TxData).copyWith( + note: "CakePay payment", + ); + + if (!wasCancelled) { + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + + if (mounted) { + await Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => CakePayConfirmSendView( + txData: txData, + walletId: widget.walletId, + routeOnSuccessName: Util.isDesktop + ? DesktopHomeView.routeName + : HomeView.routeName, + orderId: widget.orderId, + ), + settings: const RouteSettings( + name: CakePayConfirmSendView.routeName, + ), + ), + ); + } + } + } catch (e, s) { + Logging.instance.e("$e\n$s", error: e, stackTrace: s); + if (mounted && !wasCancelled) { + Navigator.of(context, rootNavigator: true).pop(); + + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Transaction failed", + message: e.toString(), + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, + ), + ), + onPressed: () => Navigator.of(context).pop(), + ), + ); + }, + ); + } + } + } + + @override + Widget build(BuildContext context) { + final coin = ref.watch(pWalletCoin(widget.walletId)); + + return RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + child: MaterialButton( + splashColor: Theme.of(context).extension()!.highlight, + key: Key("cakePayWalletKey_${widget.walletId}"), + padding: const EdgeInsets.all(8), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: () async { + if (mounted) unawaited(_send()); + }, + child: Row( + children: [ + Container( + decoration: BoxDecoration( + color: ref.watch(pCoinColor(coin)).withValues(alpha: 0.5), + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Padding( + padding: const EdgeInsets.all(6), + child: SvgPicture.file( + File(ref.watch(coinIconProvider(coin))), + width: 24, + height: 24, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ref.watch(pWalletName(widget.walletId)), + style: STextStyles.titleBold12(context), + ), + const SizedBox(height: 2), + Text( + ref + .watch(pAmountFormatter(coin)) + .format( + ref.watch(pWalletBalance(widget.walletId)).spendable, + ), + style: STextStyles.itemSubtitle(context), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/cakepay/cakepay_vendors_view.dart b/lib/pages/cakepay/cakepay_vendors_view.dart new file mode 100644 index 0000000000..59b145825b --- /dev/null +++ b/lib/pages/cakepay/cakepay_vendors_view.dart @@ -0,0 +1,503 @@ +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../services/cakepay/cakepay_service.dart'; +import '../../services/cakepay/src/models/card.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/icon_widgets/credit_card_icon.dart'; +import '../../widgets/infinite_scroll_list_view.dart'; +import '../../widgets/loading_indicator.dart'; +import '../../widgets/rounded_container.dart'; +import '../../widgets/stack_text_field.dart'; +import 'cakepay_card_detail_view.dart'; + +class CakePayVendorsView extends StatefulWidget { + const CakePayVendorsView({super.key}); + + static const String routeName = "/cakePayVendors"; + + @override + State createState() => _CakePayVendorsViewState(); +} + +class _CakePayVendorsViewState extends State { + List _countryNames = []; + String? _selectedCountry; + String? _searchQuery; + bool _loading = true; + + final _searchController = TextEditingController(); + final _searchFocusNode = FocusNode(); + final _countrySearchController = TextEditingController(); + + final _listController = InfiniteScrollListController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + _countryNames = await CakePayService.instance.getCountryNames(); + } finally { + if (mounted) setState(() => _loading = false); + } + }); + } + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + _countrySearchController.dispose(); + super.dispose(); + } + + Future<({List cards, int? nextPage})> _fetchCards( + int page, + ) async { + final response = await CakePayService.instance.client.getVendors( + page: page, + pageSize: 50, + country: _selectedCountry, + search: _searchQuery, + ); + + if (response.hasError || response.value == null) { + throw response.exception ?? + Exception("Unknown exception with value is null????"); + } + + return ( + cards: response.value!.vendors + .expand((e) => e.cards.where((e) => e.available)) + .toList(), + nextPage: response.value!.nextPage, + ); + } + + Future _onCardTapped(CakePayCard card) async { + await Navigator.of( + context, + ).pushNamed(CakePayCardDetailView.routeName, arguments: card); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + return ConditionalParent( + condition: isDesktop, + builder: (child) => DesktopDialog( + maxWidth: 580, + maxHeight: MediaQuery.of(context).size.height - 64, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Gift Cards", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: Padding( + padding: const .only(left: 32, right: 32, top: 8), + child: child, + ), + ), + ], + ), + ), + child: ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: Text( + "Gift Cards", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 16, left: 16, right: 16), + child: child, + ), + ), + ), + ), + child: Column( + children: [ + _SearchField( + controller: _searchController, + focusNode: _searchFocusNode, + onSubmitted: (value) { + setState(() => _searchQuery = value); + _listController.refresh(); + }, + ), + if (_countryNames.isNotEmpty) ...[ + SizedBox(height: isDesktop ? 12 : 12), + _CountryDropdown( + countryNames: _countryNames, + selectedCountry: _selectedCountry, + searchController: _countrySearchController, + onChanged: (value) { + setState(() => _selectedCountry = value); + _listController.refresh(); + }, + ), + ], + SizedBox(height: isDesktop ? 16 : 12), + Expanded( + child: _loading + ? const LoadingIndicator(width: 64, height: 64) + : InfiniteScrollListView( + controller: _listController, + prefetchThreshold: 300, + padding: .only(bottom: isDesktop ? 32 : 16), + firstPageKey: 1, + separatorBuilder: (_, _) => + SizedBox(height: isDesktop ? 16 : 12), + fetchPage: (pageKey) async { + final result = await _fetchCards(pageKey); + return InfiniteScrollPage( + items: result.cards, + nextPageKey: result.nextPage, + ); + }, + itemBuilder: (context, item, index) { + return _CardTile( + card: item, + onTap: () => _onCardTapped(item), + ); + }, + firstPageProgressBuilder: (_) => + const LoadingIndicator(width: 64, height: 64), + newPageProgressBuilder: (_) => const Center( + child: Padding( + padding: .all(16), + child: LoadingIndicator(width: 48, height: 48), + ), + ), + emptyBuilder: (_) => Center( + child: Padding( + padding: const .all(24), + child: Text( + "No items", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ), + ), + newPageErrorBuilder: (context, error, retry) => Center( + child: Padding( + padding: const .all(16), + child: Column( + mainAxisSize: .min, + children: [ + Text( + error.toString(), + style: STextStyles.w500_14(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + const SizedBox(height: 16), + SecondaryButton( + label: "Retry", + buttonHeight: isDesktop ? .s : .l, + width: 100, + onPressed: retry, + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Private sub-widgets +// --------------------------------------------------------------------------- + +class _SearchField extends StatelessWidget { + const _SearchField({ + required this.controller, + required this.focusNode, + required this.onSubmitted, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onSubmitted; + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + return ClipRRect( + borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), + child: TextField( + controller: controller, + focusNode: focusNode, + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + ) + : STextStyles.field(context), + decoration: + standardInputDecoration( + "Search gift cards", + focusNode, + context, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 16, + height: 16, + ), + ), + ), + onSubmitted: onSubmitted, + ), + ); + } +} + +class _CountryDropdown extends StatelessWidget { + const _CountryDropdown({ + required this.countryNames, + required this.selectedCountry, + required this.searchController, + required this.onChanged, + }); + + final List countryNames; + final String? selectedCountry; + final TextEditingController searchController; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final colors = Theme.of(context).extension()!; + final borderRadius = BorderRadius.circular( + Constants.size.circularBorderRadius, + ); + + final itemStyle = isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith(color: colors.textFieldActiveText) + : STextStyles.w500_14(context); + + return ClipRRect( + borderRadius: borderRadius, + child: DropdownButtonHideUnderline( + child: DropdownButton2( + value: selectedCountry, + isExpanded: true, + hint: Text( + "All countries", + style: isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith(color: colors.textFieldDefaultSearchIconLeft) + : STextStyles.fieldLabel(context), + ), + items: [ + DropdownMenuItem( + value: null, + child: Text("All countries", style: itemStyle), + ), + ...countryNames.map( + (name) => DropdownMenuItem( + value: name, + child: Text(name, style: itemStyle), + ), + ), + ], + onMenuStateChange: (isOpen) { + if (!isOpen) searchController.clear(); + }, + onChanged: onChanged, + buttonStyleData: ButtonStyleData( + decoration: BoxDecoration( + color: colors.textFieldDefaultBG, + borderRadius: borderRadius, + ), + ), + iconStyleData: IconStyleData( + icon: Padding( + padding: const EdgeInsets.only(right: 10), + child: SvgPicture.asset( + Assets.svg.chevronDown, + width: 12, + height: 6, + colorFilter: ColorFilter.mode( + colors.textFieldActiveSearchIconRight, + BlendMode.srcIn, + ), + ), + ), + ), + dropdownStyleData: DropdownStyleData( + offset: const Offset(0, -10), + elevation: 0, + maxHeight: 300, + decoration: BoxDecoration( + color: colors.textFieldDefaultBG, + borderRadius: borderRadius, + ), + ), + dropdownSearchData: DropdownSearchData( + searchController: searchController, + searchInnerWidgetHeight: 48, + searchInnerWidget: TextFormField( + controller: searchController, + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + hintText: "Search...", + hintStyle: STextStyles.fieldLabel(context), + border: InputBorder.none, + ), + ), + searchMatchFn: (item, searchValue) { + if (item.value == null) { + return "all countries".contains(searchValue.toLowerCase()); + } + return item.value!.toLowerCase().contains( + searchValue.toLowerCase(), + ); + }, + ), + menuItemStyleData: const MenuItemStyleData( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + ), + ); + } +} + +class _CardTile extends StatelessWidget { + const _CardTile({required this.card, required this.onTap}); + + final CakePayCard card; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final colors = Theme.of(context).extension()!; + + return RoundedContainer( + color: colors.popupBG, + borderColor: isDesktop ? colors.textFieldDefaultBG : null, + onPressed: onTap, + padding: isDesktop ? const .all(16) : const .all(12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: card.cardImageUrl != null + ? Image.network( + card.cardImageUrl!, + width: isDesktop ? 60 : 48, + height: isDesktop ? 40 : 32, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => CreditCardIcon( + width: isDesktop ? 40 : 32, + height: isDesktop ? 40 : 32, + ), + ) + : CreditCardIcon( + width: isDesktop ? 40 : 32, + height: isDesktop ? 40 : 32, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + card.name, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + [ + if (card.denominationRange.isNotEmpty) + card.denominationRange, + if (card.currencyCode != null) card.currencyCode!, + ].join(' '), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12( + context, + ).copyWith(color: colors.textSubtitle1), + ), + ], + ), + ), + SvgPicture.asset( + Assets.svg.chevronRight, + width: 20, + height: 20, + colorFilter: ColorFilter.mode(colors.textSubtitle1, .srcIn), + ), + ], + ), + ); + } +} diff --git a/lib/pages/epic_finalize_view/epic_finalize_view.dart b/lib/pages/epic_finalize_view/epic_finalize_view.dart new file mode 100644 index 0000000000..13abb38d6e --- /dev/null +++ b/lib/pages/epic_finalize_view/epic_finalize_view.dart @@ -0,0 +1,338 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2026-01-12 + * + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../notifications/show_flush_bar.dart'; +import '../../providers/global/barcode_scanner_provider.dart'; +import '../../providers/global/wallets_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/barcode_scanner_interface.dart'; +import '../../utilities/clipboard_interface.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/show_loading.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/wallet/impl/epiccash_wallet.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/icon_widgets/clipboard_icon.dart'; +import '../../widgets/icon_widgets/qrcode_icon.dart'; +import '../../widgets/icon_widgets/x_icon.dart'; +import '../../widgets/stack_dialog.dart'; +import '../../widgets/stack_text_field.dart'; +import '../../widgets/textfield_icon_button.dart'; + +class EpicFinalizeView extends ConsumerStatefulWidget { + const EpicFinalizeView({ + super.key, + required this.walletId, + this.clipboard = const ClipboardWrapper(), + }); + + static const String routeName = "/epicFinalizeView"; + + final String walletId; + final ClipboardInterface clipboard; + + @override + ConsumerState createState() => _EpicFinalizeViewState(); +} + +class _EpicFinalizeViewState extends ConsumerState { + late final TextEditingController _slateController; + late final FocusNode _slateFocusNode; + + bool _slateToggleFlag = false; + + Future _pasteSlatepack() async { + final ClipboardData? data = await widget.clipboard.getData( + Clipboard.kTextPlain, + ); + if (data?.text != null && data!.text!.isNotEmpty) { + _slateController.text = data.text!; + setState(() { + _slateToggleFlag = _slateController.text.isNotEmpty; + }); + } + } + + Future _scanQr() async { + try { + if (!Util.isDesktop && _slateFocusNode.hasFocus) { + _slateFocusNode.unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + + if (mounted) { + final qrResult = await ref.read(pBarcodeScanner).scan(context: context); + if (qrResult.rawContent != null && qrResult.rawContent!.isNotEmpty) { + _slateController.text = qrResult.rawContent!; + setState(() { + _slateToggleFlag = _slateController.text.isNotEmpty; + }); + } + } + } on PlatformException catch (e, s) { + if (mounted) { + try { + await checkCamPermDeniedMobileAndOpenAppSettings( + context, + logging: Logging.instance, + ); + } catch (e, s) { + Logging.instance.e( + "Failed to check cam permissions", + error: e, + stackTrace: s, + ); + } + } else { + Logging.instance.e( + "Failed to get camera permissions while trying to scan qr code in SendView: ", + error: e, + stackTrace: s, + ); + } + } + } + + Future _finalize() async { + // add delay for showloading exception catching hack fix + await Future.delayed(const Duration(seconds: 1)); + + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as EpiccashWallet; + + final decoded = await wallet.decodeSlatepack(_slateController.text); + if (!decoded.success) { + throw Exception(decoded.error ?? "Failed to decode slate"); + } + + final analysis = await wallet.analyzeSlatepack(_slateController.text); + if (analysis.status != "S2") { + throw Exception("Invalid slate type: ${analysis.status}"); + } + + final result = await wallet.finalizeSlatepack(_slateController.text); + + if (!result.success) { + throw Exception( + result.error ?? "Finalize failed without providing an error???", + ); + } + } + + Future _finalizePressed() async { + if (!Util.isDesktop && _slateFocusNode.hasFocus) { + _slateFocusNode.unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + if (mounted) { + Exception? ex; + await showLoading( + whileFuture: _finalize(), + context: context, + message: "Finalizing slate...", + rootNavigator: Util.isDesktop, + onException: (e) => ex = e, + ); + + if (mounted) { + if (ex != null) { + await showDialog( + context: context, + builder: (context) => StackOkDialog( + desktopPopRootNavigator: Util.isDesktop, + title: "Slate finalize error", + message: ex?.toString() ?? "Unexpected result without exception", + maxWidth: Util.isDesktop ? 400 : null, + ), + ); + } else { + setState(() { + _slateController.text = ""; + }); + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Transaction finalized and broadcast successfully!", + context: context, + ), + ); + } + } + } + } + + @override + void initState() { + super.initState(); + _slateController = TextEditingController(); + _slateFocusNode = FocusNode(); + } + + @override + void dispose() { + _slateController.dispose(); + _slateFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + return ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Finalize slate", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: Constants.size.standardPadding, + ), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + minLines: 1, + maxLines: 5, + key: const Key("epicFinalizeSlateFieldKey"), + controller: _slateController, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + toolbarOptions: const ToolbarOptions( + copy: false, + cut: false, + paste: true, + selectAll: false, + ), + onChanged: (newValue) { + setState(() { + _slateToggleFlag = newValue.isNotEmpty; + }); + }, + focusNode: _slateFocusNode, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Enter Response Slate JSON", + _slateFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: + 12, // Adjust vertical padding for better alignment + ), + suffixIcon: Padding( + padding: _slateController.text.isEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _slateToggleFlag + ? TextFieldIconButton( + key: const Key( + "epicSlateFinalizeClearFieldButtonKey", + ), + onTap: () { + _slateController.text = ""; + setState(() { + _slateToggleFlag = false; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "epicSlateFinalizePasteFieldButtonKey", + ), + onTap: _pasteSlatepack, + child: _slateController.text.isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_slateController.text.isEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("sendViewScanQrButtonKey"), + onTap: _scanQr, + child: const QrCodeIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + Util.isDesktop ? const SizedBox(height: 24) : const Spacer(), + PrimaryButton( + label: "Finalize Slate", + enabled: _slateToggleFlag, + onPressed: _slateToggleFlag ? _finalizePressed : null, + ), + + if (!Util.isDesktop) SizedBox(height: Constants.size.standardPadding), + ], + ), + ); + } +} diff --git a/lib/pages/exchange_view/choose_address_from_stack_view.dart b/lib/pages/exchange_view/choose_address_from_stack_view.dart new file mode 100644 index 0000000000..c17c58b1a3 --- /dev/null +++ b/lib/pages/exchange_view/choose_address_from_stack_view.dart @@ -0,0 +1,344 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../providers/providers.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/show_loading.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../../widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart'; +import '../../widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart'; + +class ChooseAddressFromStackView extends ConsumerStatefulWidget { + const ChooseAddressFromStackView({super.key, required this.coin}); + + final CryptoCurrency coin; + + static const String routeName = "/chooseFromStack"; + + @override + ConsumerState createState() => + _ChooseFromStackViewState(); +} + +class _ChooseFromStackViewState + extends ConsumerState { + late final CryptoCurrency coin; + + @override + void initState() { + coin = widget.coin; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final walletIds = ref + .watch(pWallets) + .wallets + .where((e) => e.info.coin == coin) + .map((e) => e.walletId) + .toList(); + + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: const AppBarBackButton(), + title: Text( + "Choose your ${coin.ticker.toUpperCase()} wallet", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: walletIds.isEmpty + ? Column( + children: [ + RoundedWhiteContainer( + child: Center( + child: Text( + "No ${coin.ticker.toUpperCase()} wallets", + style: STextStyles.itemSubtitle(context), + ), + ), + ), + ], + ) + : ListView.builder( + itemCount: walletIds.length, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.symmetric(vertical: 5.0), + child: _WalletAddressSelectCard( + walletId: walletIds[index], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _WalletAddressSelectCard extends ConsumerStatefulWidget { + const _WalletAddressSelectCard({required this.walletId}); + + final String walletId; + + @override + ConsumerState<_WalletAddressSelectCard> createState() => + _WalletAddressSelectCardState(); +} + +class _WalletAddressSelectCardState + extends ConsumerState<_WalletAddressSelectCard> { + @override + Widget build(BuildContext context) { + final coin = ref.watch(pWalletCoin(widget.walletId)); + + if (coin is! Firo) { + return RawMaterialButton( + splashColor: Theme.of(context).extension()!.highlight, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + padding: const EdgeInsets.all(0), + elevation: 0, + onPressed: () async { + final wallet = ref.read(pWallets).getWallet(widget.walletId); + + final data = ( + walletId: widget.walletId, + address: + (await wallet.getCurrentReceivingAddress())?.value ?? + wallet.info.cachedReceivingAddress, + walletName: wallet.info.name, + ); + + if (context.mounted) { + Navigator.of(context).pop(data); + } + }, + child: RoundedWhiteContainer( + child: Row( + children: [ + WalletInfoCoinIcon(coin: coin), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ref.watch(pWalletName(widget.walletId)), + style: STextStyles.titleBold12(context), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + WalletInfoRowBalance(walletId: widget.walletId), + ], + ), + ), + ], + ), + ), + ); + } + + return RoundedWhiteContainer( + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Row( + children: [ + WalletInfoCoinIcon(coin: coin), + const SizedBox(width: 12), + Expanded( + child: Text( + ref.watch(pWalletName(widget.walletId)), + style: STextStyles.titleBold12(context), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 10), + RawMaterialButton( + splashColor: Theme.of(context).extension()!.highlight, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + padding: const EdgeInsets.all(0), + elevation: 0, + onPressed: () async { + Future _future() async { + final wallet = + ref.read(pWallets).getWallet(widget.walletId) + as SparkInterface; + final sparkAddress = await wallet + .getCurrentReceivingSparkAddress(); + if (sparkAddress != null) { + return sparkAddress.value; + } + + return (await wallet.generateNextSparkAddress( + saveToDB: true, + )).value; + } + + Exception? ex; + final sparkAddress = await showLoading( + context: context, + message: "Fetching Spark address", + rootNavigator: Util.isDesktop, + delay: const Duration(milliseconds: 1200), + whileFutureAlt: _future, + onException: (e) => ex = e, + ); + + if (context.mounted) { + if (ex != null) { + await showDialog( + context: context, + builder: (context) => StackOkDialog( + title: "Error", + message: ex + .toString() + .replaceFirst("Exception:", "") + .trim(), + ), + ); + } else { + Navigator.of(context).pop(( + walletId: widget.walletId, + address: sparkAddress, + walletName: + "${ref.read(pWalletName(widget.walletId))} (Spark)", + )); + } + } + }, + child: Row( + crossAxisAlignment: .center, + mainAxisAlignment: .spaceBetween, + children: [ + Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text("Spark address", style: STextStyles.w500_12(context)), + const SizedBox(height: 2), + WalletInfoRowBalance( + walletId: widget.walletId, + balanceType: .private, + ), + ], + ), + SizedBox( + width: 25, + height: 25, + child: SvgPicture.asset( + Assets.svg.chevronRight, + colorFilter: ColorFilter.mode( + Theme.of(context).extension()!.textDark, + BlendMode.srcIn, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + RawMaterialButton( + splashColor: Theme.of(context).extension()!.highlight, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + padding: const EdgeInsets.all(0), + elevation: 0, + onPressed: () async { + final wallet = ref.read(pWallets).getWallet(widget.walletId); + + final data = ( + walletId: widget.walletId, + address: + (await wallet.getCurrentReceivingAddress())?.value ?? + wallet.info.cachedReceivingAddress, + walletName: "${wallet.info.name} (Transparent)", + ); + + if (context.mounted) { + Navigator.of(context).pop(data); + } + }, + + child: Row( + crossAxisAlignment: .center, + mainAxisAlignment: .spaceBetween, + children: [ + Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text( + "Transparent address", + style: STextStyles.w500_12(context), + ), + const SizedBox(height: 2), + WalletInfoRowBalance( + walletId: widget.walletId, + balanceType: .public, + ), + ], + ), + SizedBox( + width: 25, + height: 25, + child: SvgPicture.asset( + Assets.svg.chevronRight, + colorFilter: ColorFilter.mode( + Theme.of(context).extension()!.textDark, + BlendMode.srcIn, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/exchange_view/choose_from_stack_view.dart b/lib/pages/exchange_view/choose_from_stack_view.dart deleted file mode 100644 index d4cc11dac5..0000000000 --- a/lib/pages/exchange_view/choose_from_stack_view.dart +++ /dev/null @@ -1,149 +0,0 @@ -/* - * This file is part of Stack Wallet. - * - * Copyright (c) 2023 Cypher Stack - * All Rights Reserved. - * The code is distributed under GPLv3 license, see LICENSE file for details. - * Generated by Cypher Stack on 2023-05-26 - * - */ - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../providers/providers.dart'; -import '../../themes/stack_colors.dart'; -import '../../utilities/constants.dart'; -import '../../utilities/text_styles.dart'; -import '../../wallets/crypto_currency/crypto_currency.dart'; -import '../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../widgets/background.dart'; -import '../../widgets/custom_buttons/app_bar_icon_button.dart'; -import '../../widgets/rounded_white_container.dart'; -import '../../widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart'; -import '../../widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart'; - -class ChooseFromStackView extends ConsumerStatefulWidget { - const ChooseFromStackView({super.key, required this.coin}); - - final CryptoCurrency coin; - - static const String routeName = "/chooseFromStack"; - - @override - ConsumerState createState() => - _ChooseFromStackViewState(); -} - -class _ChooseFromStackViewState extends ConsumerState { - late final CryptoCurrency coin; - - @override - void initState() { - coin = widget.coin; - super.initState(); - } - - @override - Widget build(BuildContext context) { - final walletIds = - ref - .watch(pWallets) - .wallets - .where((e) => e.info.coin == coin) - .map((e) => e.walletId) - .toList(); - - return Background( - child: Scaffold( - backgroundColor: Theme.of(context).extension()!.background, - appBar: AppBar( - leading: const AppBarBackButton(), - title: Text( - "Choose your ${coin.ticker.toUpperCase()} wallet", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: - walletIds.isEmpty - ? Column( - children: [ - RoundedWhiteContainer( - child: Center( - child: Text( - "No ${coin.ticker.toUpperCase()} wallets", - style: STextStyles.itemSubtitle(context), - ), - ), - ), - ], - ) - : ListView.builder( - itemCount: walletIds.length, - itemBuilder: (context, index) { - final walletId = walletIds[index]; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 5.0), - child: RawMaterialButton( - splashColor: - Theme.of( - context, - ).extension()!.highlight, - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - padding: const EdgeInsets.all(0), - // color: Theme.of(context).extension()!.popupBG, - elevation: 0, - onPressed: () async { - if (mounted) { - Navigator.of(context).pop(walletId); - } - }, - child: RoundedWhiteContainer( - // color: Colors.transparent, - child: Row( - children: [ - WalletInfoCoinIcon(coin: coin), - const SizedBox(width: 12), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - ref.watch(pWalletName(walletId)), - style: STextStyles.titleBold12( - context, - ), - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - WalletInfoRowBalance( - walletId: walletIds[index], - ), - ], - ), - ), - ], - ), - ), - ), - ); - }, - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/exchange_view/confirm_change_now_send.dart b/lib/pages/exchange_view/confirm_change_now_send.dart index 69e77f13c1..3a5c759262 100644 --- a/lib/pages/exchange_view/confirm_change_now_send.dart +++ b/lib/pages/exchange_view/confirm_change_now_send.dart @@ -83,10 +83,24 @@ class _ConfirmChangeNowSendViewState final coin = wallet.info.coin; final sendProgressController = ProgressAndSuccessController(); + var isSendingDialogOpen = false; + void closeSendingDialog() { + if (!context.mounted || !isSendingDialogOpen) { + return; + } + final rootNavigator = Navigator.of(context, rootNavigator: true); + if (rootNavigator.canPop()) { + rootNavigator.pop(); + } + isSendingDialogOpen = false; + } + + isSendingDialogOpen = true; unawaited( showDialog( context: context, + useRootNavigator: true, useSafeArea: false, barrierDismissible: false, builder: (context) { @@ -95,7 +109,7 @@ class _ConfirmChangeNowSendViewState controller: sendProgressController, ); }, - ), + ).whenComplete(() => isSendingDialogOpen = false), ); final time = Future.delayed(const Duration(milliseconds: 2500)); @@ -141,10 +155,8 @@ class _ConfirmChangeNowSendViewState // pop back to wallet if (context.mounted) { + closeSendingDialog(); if (Util.isDesktop) { - // pop sending dialog - Navigator.of(context, rootNavigator: true).pop(); - // one day we'll do routing right Navigator.of(context, rootNavigator: true).pop(); if (widget.fromDesktopStep4) { @@ -162,7 +174,7 @@ class _ConfirmChangeNowSendViewState ); // pop sending dialog - Navigator.of(context).pop(); + closeSendingDialog(); await showDialog( context: context, @@ -179,10 +191,9 @@ class _ConfirmChangeNowSendViewState child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), ), onPressed: () { @@ -203,44 +214,38 @@ class _ConfirmChangeNowSendViewState if (Util.isDesktop) { unlocked = await showDialog( context: context, - builder: - (context) => DesktopDialog( - maxWidth: 580, - maxHeight: double.infinity, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [DesktopDialogCloseButton()], - ), - Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, - ), - child: DesktopAuthSend(coin: coin), - ), - ], + builder: (context) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [DesktopDialogCloseButton()], ), - ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: DesktopAuthSend(coin: coin), + ), + ], + ), + ), ); } else { unlocked = await Navigator.push( context, RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: - (_) => const LockscreenView( - showBackButton: true, - popOnSuccess: true, - routeOnSuccessArguments: true, - routeOnSuccess: "", - biometricsCancelButtonString: "CANCEL", - biometricsLocalizedReason: "Authenticate to send transaction", - biometricsAuthenticationTitle: "Confirm Transaction", - ), + builder: (_) => const LockscreenView( + showBackButton: true, + popOnSuccess: true, + routeOnSuccessArguments: true, + routeOnSuccess: "", + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: "Authenticate to send transaction", + biometricsAuthenticationTitle: "Confirm Transaction", + ), settings: const RouteSettings(name: "/confirmsendlockscreen"), ), ); @@ -276,11 +281,13 @@ class _ConfirmChangeNowSendViewState builder: (child) { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.backgroundAppBar, + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, leading: AppBarBackButton( onPressed: () async { // if (FocusScope.of(context).hasFocus) { @@ -326,188 +333,167 @@ class _ConfirmChangeNowSendViewState }, child: ConditionalParent( condition: isDesktop, - builder: - (child) => DesktopDialog( - maxHeight: double.infinity, - maxWidth: 580, - child: Column( + builder: (child) => DesktopDialog( + maxHeight: double.infinity, + maxWidth: 580, + child: Column( + children: [ + Row( children: [ - Row( - children: [ - const SizedBox(width: 6), - const AppBarBackButton(isCompact: true, iconSize: 23), - const SizedBox(width: 12), - Text( - "Confirm ${ref.watch(pWalletCoin(walletId)).ticker} transaction", - style: STextStyles.desktopH3(context), - ), - ], + const SizedBox(width: 6), + const AppBarBackButton(isCompact: true, iconSize: 23), + const SizedBox(width: 12), + Text( + "Confirm ${ref.watch(pWalletCoin(walletId)).ticker} transaction", + style: STextStyles.desktopH3(context), ), - Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, + ], + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: Column( + children: [ + RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + borderColor: Theme.of( + context, + ).extension()!.background, + child: child, ), - child: Column( + const SizedBox(height: 16), + Row( children: [ - RoundedWhiteContainer( - padding: const EdgeInsets.all(0), - borderColor: - Theme.of( - context, - ).extension()!.background, - child: child, - ), - const SizedBox(height: 16), - Row( - children: [ - Text( - "Transaction fee", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ), - ), - ], + Text( + "Transaction fee", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), ), - const SizedBox(height: 10), - RoundedContainer( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text( - ref - .watch( - pAmountFormatter( - ref.watch(pWalletCoin(walletId)), - ), - ) - .format(widget.txData.fee!), - style: STextStyles.desktopTextExtraExtraSmall( + ], + ), + const SizedBox(height: 10), + RoundedContainer( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + ref + .watch( + pAmountFormatter( + ref.watch(pWalletCoin(walletId)), + ), + ) + .format(widget.txData.fee!), + style: + STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark, + color: Theme.of( + context, + ).extension()!.textDark, ), - ), - ], ), - ), - const SizedBox(height: 16), - RoundedContainer( - color: - Theme.of( - context, - ).extension()!.snackBarBackSuccess, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Total amount", - style: STextStyles.titleBold12( - context, - ).copyWith( - color: - Theme.of(context) + ], + ), + ), + const SizedBox(height: 16), + RoundedContainer( + color: Theme.of( + context, + ).extension()!.snackBarBackSuccess, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Total amount", + style: STextStyles.titleBold12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), + ), + Builder( + builder: (context) { + final coin = ref.read(pWalletCoin(walletId)); + final fee = widget.txData.fee!; + final amount = widget.txData.amountWithoutChange!; + final total = amount + fee; + + return Text( + ref.watch(pAmountFormatter(coin)).format(total), + style: STextStyles.itemSubtitle12(context) + .copyWith( + color: Theme.of(context) .extension()! .textConfirmTotalAmount, - ), - ), - Builder( - builder: (context) { - final coin = ref.read(pWalletCoin(walletId)); - final fee = widget.txData.fee!; - final amount = - widget.txData.amountWithoutChange!; - final total = amount + fee; - - return Text( - ref - .watch(pAmountFormatter(coin)) - .format(total), - style: STextStyles.itemSubtitle12( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, ), - textAlign: TextAlign.right, - ); - }, - ), - ], + textAlign: TextAlign.right, + ); + }, + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: ButtonHeight.l, + onPressed: Navigator.of(context).pop, ), ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: SecondaryButton( - label: "Cancel", - buttonHeight: ButtonHeight.l, - onPressed: Navigator.of(context).pop, - ), - ), - const SizedBox(width: 16), - Expanded( - child: PrimaryButton( - label: "Send", - buttonHeight: isDesktop ? ButtonHeight.l : null, - onPressed: _confirmSend, - ), - ), - ], + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Send", + buttonHeight: isDesktop ? ButtonHeight.l : null, + onPressed: _confirmSend, + ), ), ], ), - ), - ], + ], + ), ), - ), + ], + ), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ConditionalParent( condition: isDesktop, - builder: - (child) => Container( - decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.background, - borderRadius: BorderRadius.vertical( - top: Radius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row(children: [child]), - ), + builder: (child) => Container( + decoration: BoxDecoration( + color: Theme.of(context).extension()!.background, + borderRadius: BorderRadius.vertical( + top: Radius.circular(Constants.size.circularBorderRadius), ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row(children: [child]), + ), + ), child: Text( "Send ${ref.watch(pWalletCoin(walletId)).ticker}", - style: - isDesktop - ? STextStyles.desktopTextMedium(context) - : STextStyles.pageTitleH1(context), + style: isDesktop + ? STextStyles.desktopTextMedium(context) + : STextStyles.pageTitleH1(context), ), ), isDesktop ? Container( - color: Theme.of(context).extension()!.background, - height: 1, - ) + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) : const SizedBox(height: 12), RoundedWhiteContainer( child: Column( @@ -524,9 +510,11 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of(context).extension()!.background, - height: 1, - ) + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) : const SizedBox(height: 12), RoundedWhiteContainer( child: Column( @@ -538,7 +526,8 @@ class _ConfirmChangeNowSendViewState ), const SizedBox(height: 4), Text( - widget.txData.recipients!.first.address, + widget.txData.recipients?.first.address ?? + widget.txData.sparkRecipients!.first.address, style: STextStyles.itemSubtitle12(context), ), ], @@ -546,9 +535,11 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of(context).extension()!.background, - height: 1, - ) + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) : const SizedBox(height: 12), RoundedWhiteContainer( child: Row( @@ -557,63 +548,65 @@ class _ConfirmChangeNowSendViewState Text("Amount", style: STextStyles.smallMed12(context)), ConditionalParent( condition: isDesktop, - builder: - (child) => Row( - children: [ - child, - Builder( - builder: (context) { - final coin = ref.watch(pWalletCoin(walletId)); - final price = ref.watch( - priceAnd24hChangeNotifierProvider.select( - (value) => value.getPrice(coin), - ), - ); - final String extra; - if (price == null) { - extra = ""; - } else { - final amountWithoutChange = - widget.txData.amountWithoutChange!; - final value = (price.value * - amountWithoutChange.decimal) + builder: (child) => Row( + children: [ + child, + Builder( + builder: (context) { + final coin = ref.watch(pWalletCoin(walletId)); + final price = ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getPrice(coin), + ), + ); + final String extra; + if (price == null) { + extra = ""; + } else { + final amountWithoutChange = + widget.txData.amountWithoutChange!; + final value = + (price.value * amountWithoutChange.decimal) .toAmount(fractionDigits: 2); - final currency = ref.watch( - prefsChangeNotifierProvider.select( - (value) => value.currency, - ), - ); - final locale = ref.watch( - localeServiceChangeNotifierProvider.select( - (value) => value.locale, - ), - ); + final currency = ref.watch( + prefsChangeNotifierProvider.select( + (value) => value.currency, + ), + ); + final locale = ref.watch( + localeServiceChangeNotifierProvider.select( + (value) => value.locale, + ), + ); - extra = - " | ${value.fiatString(locale: locale)} $currency"; - } + extra = + " | ${value.fiatString(locale: locale)} $currency"; + } - return Text( - extra, - style: STextStyles.desktopTextExtraExtraSmall( + return Text( + extra, + style: + STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: - Theme.of(context) - .extension()! - .textSubtitle2, + color: Theme.of( + context, + ).extension()!.textSubtitle2, ), - ); - }, - ), - ], + ); + }, ), + ], + ), child: Text( ref .watch( pAmountFormatter(ref.watch(pWalletCoin(walletId))), ) - .format((widget.txData.amountWithoutChange!)), + .format( + (widget.txData.amountWithoutChange ?? + widget.txData.amountSparkWithoutChange!), + ), style: STextStyles.itemSubtitle12(context), textAlign: TextAlign.right, ), @@ -623,9 +616,11 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of(context).extension()!.background, - height: 1, - ) + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) : const SizedBox(height: 12), RoundedWhiteContainer( child: Row( @@ -649,9 +644,11 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of(context).extension()!.background, - height: 1, - ) + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) : const SizedBox(height: 12), RoundedWhiteContainer( child: Column( @@ -668,9 +665,11 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of(context).extension()!.background, - height: 1, - ) + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) : const SizedBox(height: 12), RoundedWhiteContainer( child: Row( @@ -688,36 +687,35 @@ class _ConfirmChangeNowSendViewState if (!isDesktop) const SizedBox(height: 12), if (!isDesktop) RoundedContainer( - color: - Theme.of( - context, - ).extension()!.snackBarBackSuccess, + color: Theme.of( + context, + ).extension()!.snackBarBackSuccess, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "Total amount", style: STextStyles.titleBold12(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textConfirmTotalAmount, + color: Theme.of( + context, + ).extension()!.textConfirmTotalAmount, ), ), Builder( builder: (context) { final coin = ref.watch(pWalletCoin(walletId)); final fee = widget.txData.fee!; - final amount = widget.txData.amountWithoutChange!; + final amount = + widget.txData.amountWithoutChange ?? + widget.txData.amountSparkWithoutChange!; final total = amount + fee; return Text( ref.watch(pAmountFormatter(coin)).format(total), style: STextStyles.itemSubtitle12(context).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, + color: Theme.of( + context, + ).extension()!.textConfirmTotalAmount, ), textAlign: TextAlign.right, ); diff --git a/lib/pages/exchange_view/exchange_form.dart b/lib/pages/exchange_view/exchange_form.dart index 675cd30cd7..c328e5e4f7 100644 --- a/lib/pages/exchange_view/exchange_form.dart +++ b/lib/pages/exchange_view/exchange_form.dart @@ -27,11 +27,15 @@ import '../../models/isar/models/ethereum/eth_contract.dart'; import '../../pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart'; import '../../providers/providers.dart'; import '../../services/exchange/change_now/change_now_exchange.dart'; +import '../../services/exchange/cyphergoat/cyphergoat_exchange.dart'; import '../../services/exchange/exchange.dart'; import '../../services/exchange/exchange_data_loading_service.dart'; import '../../services/exchange/exchange_response.dart'; +import '../../services/exchange/exolix/exolix_exchange.dart'; +import '../../services/exchange/lets_exchange/lets_exchange_exchange.dart'; 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/assets.dart'; @@ -80,8 +84,12 @@ class _ExchangeFormState extends ConsumerState { } else { return [ ChangeNowExchange.instance, + ExolixExchange.instance, + LetsExchangeExchange.instance, TrocadorExchange.instance, NanswapExchange.instance, + WizardSwapExchange.instance, + CypherGoatExchange.instance, ]; } } @@ -104,19 +112,18 @@ class _ExchangeFormState extends ConsumerState { showDialog( context: context, barrierDismissible: false, - builder: - (_) => WillPopScope( - onWillPop: () async => false, - child: Container( - color: Theme.of( - context, - ).extension()!.overlay.withOpacity(0.6), - child: const CustomLoadingOverlay( - message: "Updating exchange rate", - eventBus: null, - ), - ), + builder: (_) => WillPopScope( + onWillPop: () async => false, + child: Container( + color: Theme.of( + context, + ).extension()!.overlay.withOpacity(0.6), + child: const CustomLoadingOverlay( + message: "Updating exchange rate", + eventBus: null, ), + ), + ), ), ); @@ -262,71 +269,68 @@ class _ExchangeFormState extends ConsumerState { _sendFocusNode.unfocus(); _receiveFocusNode.unfocus(); - final result = - isDesktop - ? await showDialog( - context: context, - builder: (context) { - return DesktopDialog( - maxHeight: 700, - maxWidth: 580, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Choose a coin to exchange", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, + final result = isDesktop + ? await showDialog( + context: context, + builder: (context) { + return DesktopDialog( + maxHeight: 700, + maxWidth: 580, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Choose a coin to exchange", + style: STextStyles.desktopH3(context), ), - child: Row( - children: [ - Expanded( - child: RoundedWhiteContainer( - padding: const EdgeInsets.all(16), - borderColor: - Theme.of( - context, - ).extension()!.background, - child: ExchangeCurrencySelectionView( - pairedCurrency: paired, - isFixedRate: isFixedRate, - willChangeIsSend: willChangeIsSend, - ), + ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + ), + child: Row( + children: [ + Expanded( + child: RoundedWhiteContainer( + padding: const EdgeInsets.all(16), + borderColor: Theme.of( + context, + ).extension()!.background, + child: ExchangeCurrencySelectionView( + pairedCurrency: paired, + isFixedRate: isFixedRate, + willChangeIsSend: willChangeIsSend, ), ), - ], - ), + ), + ], ), ), - ], - ), - ); - }, - ) - : await Navigator.of(context).push( - MaterialPageRoute( - builder: - (_) => ExchangeCurrencySelectionView( - pairedCurrency: paired, - isFixedRate: isFixedRate, - willChangeIsSend: willChangeIsSend, ), + ], + ), + ); + }, + ) + : await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ExchangeCurrencySelectionView( + pairedCurrency: paired, + isFixedRate: isFixedRate, + willChangeIsSend: willChangeIsSend, ), - ); + ), + ); if (mounted && result is AggregateCurrency) { return result; @@ -402,11 +406,10 @@ class _ExchangeFormState extends ConsumerState { if (fromCurrency == null || toCurrency == null) { await showDialog( context: context, - builder: - (context) => const StackOkDialog( - title: "Missing currency!", - message: "This should not happen. Please contact support", - ), + builder: (context) => const StackOkDialog( + title: "Missing currency!", + message: "This should not happen. Please contact support", + ), ); return; @@ -426,12 +429,11 @@ class _ExchangeFormState extends ConsumerState { if (mounted) { await showDialog( 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", - ), + builder: (context) => const StackOkDialog( + title: "WOW error", + message: + "Wownero is temporarily disabled as a receiving currency for fixed rate trades due to network issues", + ), ); } @@ -440,12 +442,12 @@ class _ExchangeFormState extends ConsumerState { String rate; - final amountToSend = - estimate.reversed ? estimate.estimatedAmount : sendAmount; - final amountToReceive = - estimate.reversed - ? ref.read(efReceiveAmountProvider)! - : estimate.estimatedAmount; + final amountToSend = estimate.reversed + ? estimate.estimatedAmount + : sendAmount; + final amountToReceive = estimate.reversed + ? ref.read(efReceiveAmountProvider)! + : estimate.estimatedAmount; switch (rateType) { case ExchangeRateType.estimated: @@ -495,11 +497,10 @@ class _ExchangeFormState extends ConsumerState { child: SecondaryButton( label: "Cancel", buttonHeight: ButtonHeight.l, - onPressed: - () => Navigator.of( - context, - rootNavigator: true, - ).pop(true), + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(true), ), ), const SizedBox(width: 16), @@ -507,11 +508,10 @@ class _ExchangeFormState extends ConsumerState { child: PrimaryButton( label: "Attempt", buttonHeight: ButtonHeight.l, - onPressed: - () => Navigator.of( - context, - rootNavigator: true, - ).pop(false), + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(false), ), ), ], @@ -631,10 +631,9 @@ class _ExchangeFormState extends ConsumerState { return false; } - final String? ticker = - isSend - ? ref.read(efCurrencyPairProvider).send?.ticker - : ref.read(efCurrencyPairProvider).receive?.ticker; + final String? ticker = isSend + ? ref.read(efCurrencyPairProvider).send?.ticker + : ref.read(efCurrencyPairProvider).receive?.ticker; if (ticker == null) { return false; @@ -652,10 +651,9 @@ class _ExchangeFormState extends ConsumerState { } final reversed = ref.read(efReversedProvider); - final amount = - reversed - ? ref.read(efReceiveAmountProvider) - : ref.read(efSendAmountProvider); + final amount = reversed + ? ref.read(efReceiveAmountProvider) + : ref.read(efSendAmountProvider); final pair = ref.read(efCurrencyPairProvider); if (amount == null || @@ -683,7 +681,7 @@ class _ExchangeFormState extends ConsumerState { ); Logging.instance.d( - "${exchange.name}: fixedRate=$rateType, RANGE=$rangeResponse", + "${exchange.name}: rateType=$rateType, RANGE=$rangeResponse", ); final estimateResponse = await exchange.getEstimates( @@ -696,6 +694,10 @@ class _ExchangeFormState extends ConsumerState { reversed, ); + Logging.instance.d( + "${exchange.name}: estimateResponse=$estimateResponse", + ); + results.addAll({ exchange.name: Tuple2(estimateResponse, rangeResponse.value), }); @@ -842,8 +844,8 @@ class _ExchangeFormState extends ConsumerState { // if (_swapLock) { _receiveController.text = isEstimated && ref.read(efReceiveAmountStringProvider).isEmpty - ? "-" - : ref.read(efReceiveAmountStringProvider); + ? "-" + : ref.read(efReceiveAmountStringProvider); // } if (_receiveFocusNode.hasFocus) { @@ -891,11 +893,13 @@ class _ExchangeFormState extends ConsumerState { textStyle: STextStyles.smallMed14(context).copyWith( color: Theme.of(context).extension()!.textDark, ), - buttonColor: - Theme.of(context).extension()!.buttonBackSecondary, + buttonColor: Theme.of( + context, + ).extension()!.buttonBackSecondary, borderRadius: Constants.size.circularBorderRadius, - background: - Theme.of(context).extension()!.textFieldDefaultBG, + background: Theme.of( + context, + ).extension()!.textFieldDefaultBG, onTap: () { if (_sendController.text == "-") { _sendController.text = ""; @@ -922,23 +926,18 @@ class _ExchangeFormState extends ConsumerState { ), ConditionalParent( condition: isDesktop, - builder: - (child) => MouseRegion( - cursor: SystemMouseCursors.click, - child: child, - ), + builder: (child) => + MouseRegion(cursor: SystemMouseCursors.click, child: child), child: Semantics( label: "Swap Button. Reverse The Exchange Currencies.", excludeSemantics: true, child: RoundedContainer( - padding: - isDesktop - ? const EdgeInsets.all(6) - : const EdgeInsets.all(2), - color: - Theme.of( - context, - ).extension()!.buttonBackSecondary, + padding: isDesktop + ? const EdgeInsets.all(6) + : const EdgeInsets.all(2), + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, radiusMultiplier: 0.75, child: GestureDetector( onTap: () async { @@ -950,10 +949,9 @@ class _ExchangeFormState extends ConsumerState { Assets.svg.swap, width: 20, height: 20, - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -972,19 +970,20 @@ class _ExchangeFormState extends ConsumerState { textStyle: STextStyles.smallMed14(context).copyWith( color: Theme.of(context).extension()!.textDark, ), - buttonColor: - Theme.of(context).extension()!.buttonBackSecondary, + buttonColor: Theme.of( + context, + ).extension()!.buttonBackSecondary, borderRadius: Constants.size.circularBorderRadius, - background: - Theme.of(context).extension()!.textFieldDefaultBG, - onTap: - rateType == ExchangeRateType.estimated - ? null - : () { - if (_sendController.text == "-") { - _sendController.text = ""; - } - }, + background: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + onTap: rateType == ExchangeRateType.estimated + ? null + : () { + if (_sendController.text == "-") { + _sendController.text = ""; + } + }, onChanged: receiveFieldOnChanged, onButtonTap: selectReceiveCurrency, isWalletCoin: isWalletCoin(coin, true), @@ -1002,15 +1001,15 @@ class _ExchangeFormState extends ConsumerState { duration: const Duration(milliseconds: 300), child: ref.watch(efSendAmountProvider) == null && - ref.watch(efReceiveAmountProvider) == null - ? const SizedBox(height: 0) - : Padding( - padding: EdgeInsets.only(top: isDesktop ? 20 : 12), - child: ExchangeProviderOptions( - fixedRate: rateType == ExchangeRateType.fixed, - reversed: ref.watch(efReversedProvider), - ), + ref.watch(efReceiveAmountProvider) == null + ? const SizedBox(height: 0) + : Padding( + padding: EdgeInsets.only(top: isDesktop ? 20 : 12), + child: ExchangeProviderOptions( + fixedRate: rateType == ExchangeRateType.fixed, + reversed: ref.watch(efReversedProvider), ), + ), ), SizedBox(height: isDesktop ? 20 : 12), PrimaryButton( diff --git a/lib/pages/exchange_view/exchange_step_views/step_2_view.dart b/lib/pages/exchange_view/exchange_step_views/step_2_view.dart index a731bf9dfc..1b2fa42c44 100644 --- a/lib/pages/exchange_view/exchange_step_views/step_2_view.dart +++ b/lib/pages/exchange_view/exchange_step_views/step_2_view.dart @@ -35,7 +35,7 @@ import '../../../widgets/stack_text_field.dart'; import '../../../widgets/textfield_icon_button.dart'; import '../../address_book_views/address_book_view.dart'; import '../../address_book_views/subviews/contact_popup.dart'; -import '../choose_from_stack_view.dart'; +import '../choose_address_from_stack_view.dart'; import '../sub_widgets/step_row.dart'; import 'step_3_view.dart'; @@ -70,9 +70,10 @@ class _Step2ViewState extends ConsumerState { void _onRefundQrTapped() async { try { final qrResult = await ref.read(pBarcodeScanner).scan(context: context); + if (qrResult.rawContent == null) return; final paymentData = AddressUtils.parsePaymentUri( - qrResult.rawContent, + qrResult.rawContent!, logging: Logging.instance, ); @@ -87,7 +88,7 @@ class _Step2ViewState extends ConsumerState { _refundController.text.isNotEmpty; }); } else { - _refundController.text = qrResult.rawContent; + _refundController.text = qrResult.rawContent!; model.refundAddress = _refundController.text; setState(() { @@ -123,9 +124,10 @@ class _Step2ViewState extends ConsumerState { void _onToQrTapped() async { try { final qrResult = await ref.read(pBarcodeScanner).scan(context: context); + if (qrResult.rawContent == null) return; final paymentData = AddressUtils.parsePaymentUri( - qrResult.rawContent, + qrResult.rawContent!, logging: Logging.instance, ); @@ -141,7 +143,7 @@ class _Step2ViewState extends ConsumerState { !ref.read(efExchangeProvider).supportsRefundAddress); }); } else { - _toController.text = qrResult.rawContent; + _toController.text = qrResult.rawContent!; model.recipientAddress = _toController.text; setState(() { @@ -297,24 +299,21 @@ class _Step2ViewState extends ConsumerState { Navigator.of(context) .pushNamed( - ChooseFromStackView.routeName, + ChooseAddressFromStackView + .routeName, arguments: coin, ) .then((value) async { - if (value is String) { - final wallet = ref - .read(pWallets) - .getWallet(value); - + if (value + is ({ + String walletId, + String address, + String walletName, + })) { _toController.text = - wallet.info.name; + value.walletName; model.recipientAddress = - (await wallet - .getCurrentReceivingAddress()) - ?.value ?? - wallet - .info - .cachedReceivingAddress; + value.address; setState(() { enableNext = @@ -373,156 +372,168 @@ class _Step2ViewState extends ConsumerState { !supportsRefund); }); }, - decoration: standardInputDecoration( - "Enter the ${model.receiveTicker.toUpperCase()} payout address", - _toFocusNode, - context, - ).copyWith( - contentPadding: const EdgeInsets.only( - left: 16, - top: 6, - bottom: 8, - right: 5, - ), - suffixIcon: Padding( - padding: - _toController.text.isEmpty + decoration: + standardInputDecoration( + "Enter the ${model.receiveTicker.toUpperCase()} payout address", + _toFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _toController.text.isEmpty ? const EdgeInsets.only(right: 8) : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceAround, - children: [ - _toController.text.isNotEmpty - ? TextFieldIconButton( - key: const Key( - "sendViewClearAddressFieldButtonKey", - ), - onTap: () { - _toController.text = ""; - model.recipientAddress = - _toController.text; - - setState(() { - enableNext = - _toController - .text - .isNotEmpty && - (_refundController - .text - .isNotEmpty || - !supportsRefund); - }); - }, - child: const XIcon(), - ) - : TextFieldIconButton( - key: const Key( - "sendViewPasteAddressFieldButtonKey", - ), - onTap: () async { - final ClipboardData? data = - await clipboard.getData( - Clipboard.kTextPlain, - ); - if (data?.text != null && - data!.text!.isNotEmpty) { - final content = - data.text!.trim(); - - _toController.text = - content; - model.recipientAddress = - _toController.text; - - setState(() { - enableNext = - _toController - .text - .isNotEmpty && - (_refundController + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + _toController.text.isNotEmpty + ? TextFieldIconButton( + key: const Key( + "sendViewClearAddressFieldButtonKey", + ), + onTap: () { + _toController.text = ""; + model.recipientAddress = + _toController.text; + + setState(() { + enableNext = + _toController .text - .isNotEmpty || - !supportsRefund); - }); - } - }, - child: - _toController.text.isEmpty - ? const ClipboardIcon() - : const XIcon(), - ), - if (_toController.text.isEmpty) - TextFieldIconButton( - key: const Key( - "sendViewAddressBookButtonKey", - ), - onTap: () { - ref - .read( - exchangeFlowIsActiveStateProvider - .state, + .isNotEmpty && + (_refundController + .text + .isNotEmpty || + !supportsRefund); + }); + }, + child: const XIcon(), ) - .state = true; - Navigator.of( - context, - ).pushNamed(AddressBookView.routeName).then(( - _, - ) { - ref - .read( - exchangeFlowIsActiveStateProvider - .state, - ) - .state = false; + : TextFieldIconButton( + key: const Key( + "sendViewPasteAddressFieldButtonKey", + ), + onTap: () async { + final ClipboardData? + data = await clipboard + .getData( + Clipboard + .kTextPlain, + ); + if (data?.text != + null && + data! + .text! + .isNotEmpty) { + final content = data + .text! + .trim(); + + _toController.text = + content; + model.recipientAddress = + _toController + .text; - final address = + setState(() { + enableNext = + _toController + .text + .isNotEmpty && + (_refundController + .text + .isNotEmpty || + !supportsRefund); + }); + } + }, + child: + _toController + .text + .isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_toController.text.isEmpty) + TextFieldIconButton( + key: const Key( + "sendViewAddressBookButtonKey", + ), + onTap: () { + ref + .read( + exchangeFlowIsActiveStateProvider + .state, + ) + .state = + true; + Navigator.of( + context, + ).pushNamed(AddressBookView.routeName).then(( + _, + ) { ref + .read( + exchangeFlowIsActiveStateProvider + .state, + ) + .state = + false; + + final address = ref .read( exchangeFromAddressBookAddressStateProvider .state, ) .state; - if (address.isNotEmpty) { - _toController.text = - address; - model.recipientAddress = - _toController.text; - ref - .read( - exchangeFromAddressBookAddressStateProvider - .state, - ) - .state = ""; - } - setState(() { - enableNext = - _toController - .text - .isNotEmpty && - (_refundController + if (address.isNotEmpty) { + _toController.text = + address; + model.recipientAddress = + _toController.text; + ref + .read( + exchangeFromAddressBookAddressStateProvider + .state, + ) + .state = + ""; + } + setState(() { + enableNext = + _toController .text - .isNotEmpty || - !supportsRefund); - }); - }); - }, - child: const AddressBookIcon(), - ), - if (_toController.text.isEmpty) - TextFieldIconButton( - key: const Key( - "sendViewScanQrButtonKey", - ), - onTap: _onToQrTapped, - child: const QrCodeIcon(), - ), - ], + .isNotEmpty && + (_refundController + .text + .isNotEmpty || + !supportsRefund); + }); + }); + }, + child: + const AddressBookIcon(), + ), + if (_toController.text.isEmpty) + TextFieldIconButton( + key: const Key( + "sendViewScanQrButtonKey", + ), + onTap: _onToQrTapped, + child: const QrCodeIcon(), + ), + ], + ), + ), ), ), - ), - ), ), ), const SizedBox(height: 6), @@ -557,21 +568,21 @@ class _Step2ViewState extends ConsumerState { Navigator.of(context) .pushNamed( - ChooseFromStackView.routeName, + ChooseAddressFromStackView + .routeName, arguments: coin, ) .then((value) async { - if (value is String) { - final wallet = ref - .read(pWallets) - .getWallet(value); - + if (value + is ({ + String walletId, + String address, + String walletName, + })) { _refundController.text = - wallet.info.name; + value.walletName; model.refundAddress = - (await wallet - .getCurrentReceivingAddress())! - .value; + value.address; } setState(() { enableNext = @@ -628,154 +639,172 @@ class _Step2ViewState extends ConsumerState { _refundController.text.isNotEmpty; }); }, - decoration: standardInputDecoration( - "Enter ${model.sendTicker.toUpperCase()} refund address", - _refundFocusNode, - context, - ).copyWith( - contentPadding: const EdgeInsets.only( - left: 16, - top: 6, - bottom: 8, - right: 5, - ), - suffixIcon: Padding( - padding: - _refundController.text.isEmpty + decoration: + standardInputDecoration( + "Enter ${model.sendTicker.toUpperCase()} refund address", + _refundFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: + _refundController.text.isEmpty ? const EdgeInsets.only(right: 16) : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceAround, - children: [ - _refundController.text.isNotEmpty - ? TextFieldIconButton( - key: const Key( - "sendViewClearAddressFieldButtonKey", - ), - onTap: () { - _refundController.text = ""; - model.refundAddress = - _refundController.text; - - setState(() { - enableNext = - _toController - .text - .isNotEmpty && - _refundController - .text - .isNotEmpty; - }); - }, - child: const XIcon(), - ) - : TextFieldIconButton( - key: const Key( - "sendViewPasteAddressFieldButtonKey", - ), - onTap: () async { - final ClipboardData? data = - await clipboard.getData( - Clipboard.kTextPlain, - ); - if (data?.text != null && - data! - .text! - .isNotEmpty) { - final content = - data.text!.trim(); - - _refundController.text = - content; - model.refundAddress = + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + _refundController + .text + .isNotEmpty + ? TextFieldIconButton( + key: const Key( + "sendViewClearAddressFieldButtonKey", + ), + onTap: () { _refundController - .text; + .text = + ""; + model.refundAddress = + _refundController + .text; - setState(() { - enableNext = - _toController - .text - .isNotEmpty && + setState(() { + enableNext = + _toController + .text + .isNotEmpty && + _refundController + .text + .isNotEmpty; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "sendViewPasteAddressFieldButtonKey", + ), + onTap: () async { + final ClipboardData? + data = await clipboard + .getData( + Clipboard + .kTextPlain, + ); + if (data?.text != + null && + data! + .text! + .isNotEmpty) { + final content = data + .text! + .trim(); + + _refundController + .text = + content; + model.refundAddress = + _refundController + .text; + + setState(() { + enableNext = + _toController + .text + .isNotEmpty && + _refundController + .text + .isNotEmpty; + }); + } + }, + child: _refundController .text - .isNotEmpty; - }); - } - }, - child: - _refundController - .text - .isEmpty - ? const ClipboardIcon() - : const XIcon(), - ), - if (_refundController.text.isEmpty) - TextFieldIconButton( - key: const Key( - "sendViewAddressBookButtonKey", - ), - onTap: () { - ref - .read( - exchangeFlowIsActiveStateProvider - .state, - ) - .state = true; - Navigator.of(context) - .pushNamed( - AddressBookView - .routeName, - ) - .then((_) { - ref - .read( - exchangeFlowIsActiveStateProvider - .state, - ) - .state = false; - final address = + .isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_refundController + .text + .isEmpty) + TextFieldIconButton( + key: const Key( + "sendViewAddressBookButtonKey", + ), + onTap: () { + ref + .read( + exchangeFlowIsActiveStateProvider + .state, + ) + .state = + true; + Navigator.of(context) + .pushNamed( + AddressBookView + .routeName, + ) + .then((_) { ref + .read( + exchangeFlowIsActiveStateProvider + .state, + ) + .state = + false; + final address = ref .read( exchangeFromAddressBookAddressStateProvider .state, ) .state; - if (address - .isNotEmpty) { - _refundController - .text = address; - model.refundAddress = + if (address + .isNotEmpty) { _refundController - .text; - } - setState(() { - enableNext = - _toController - .text - .isNotEmpty && - _refundController - .text - .isNotEmpty; - }); - }); - }, - child: const AddressBookIcon(), - ), - if (_refundController.text.isEmpty) - TextFieldIconButton( - key: const Key( - "sendViewScanQrButtonKey", - ), - onTap: _onRefundQrTapped, - child: const QrCodeIcon(), - ), - ], + .text = + address; + model.refundAddress = + _refundController + .text; + } + setState(() { + enableNext = + _toController + .text + .isNotEmpty && + _refundController + .text + .isNotEmpty; + }); + }); + }, + child: + const AddressBookIcon(), + ), + if (_refundController + .text + .isEmpty) + TextFieldIconButton( + key: const Key( + "sendViewScanQrButtonKey", + ), + onTap: _onRefundQrTapped, + child: const QrCodeIcon(), + ), + ], + ), + ), ), ), - ), - ), ), ), if (supportsRefund) const SizedBox(height: 6), @@ -802,14 +831,12 @@ class _Step2ViewState extends ConsumerState { ), child: Text( "Back", - style: STextStyles.button( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.button(context) + .copyWith( + color: Theme.of(context) .extension()! .buttonTextSecondary, - ), + ), ), ), ), diff --git a/lib/pages/exchange_view/exchange_step_views/step_4_view.dart b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart index 973298bd62..896feb74be 100644 --- a/lib/pages/exchange_view/exchange_step_views/step_4_view.dart +++ b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart @@ -11,17 +11,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:tuple/tuple.dart'; import '../../../app_config.dart'; import '../../../models/exchange/incomplete_exchange.dart'; -import '../../../notifications/show_flush_bar.dart'; import '../../../providers/providers.dart'; import '../../../route_generator.dart'; -import '../../../services/wallets.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/amount/amount_formatter.dart'; @@ -31,15 +28,17 @@ import '../../../utilities/constants.dart'; import '../../../utilities/enums/fee_rate_type_enum.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/models/tx_data.dart'; import '../../../wallets/wallet/impl/firo_wallet.dart'; -import '../../../wallets/wallet/intermediate/external_wallet.dart'; -import '../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; import '../../../widgets/background.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../widgets/custom_buttons/simple_copy_button.dart'; +import '../../../widgets/desktop/primary_button.dart'; import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/detail_item.dart'; import '../../../widgets/qr.dart'; import '../../../widgets/rounded_container.dart'; import '../../../widgets/rounded_white_container.dart'; @@ -76,26 +75,6 @@ class _Step4ViewState extends ConsumerState { Timer? _statusTimer; - bool isWalletCoinAndCanSendWithoutWalletOpened( - String ticker, - Wallets walletsInstance, - ) { - try { - final coin = AppConfig.getCryptoCurrencyForTicker(ticker); - return walletsInstance.wallets - .where( - (e) => - e.info.coin == coin && - (e is! ExternalWallet || - e is MwebInterface), // ltc mweb is external but swaps - // should not use mweb, hence the odd logic check here - ) - .isNotEmpty; - } catch (_) { - return false; - } - } - Future _updateStatus() async { final statusResponse = await ref .read(efExchangeProvider) @@ -117,15 +96,69 @@ class _Step4ViewState extends ConsumerState { } } + void _showQr() { + showDialog( + context: context, + barrierDismissible: true, + builder: (_) { + return StackDialogBase( + child: Column( + children: [ + const SizedBox(height: 8), + Center( + child: Text( + "Send ${model.sendTicker} to this address", + style: STextStyles.pageTitleH2(context), + ), + ), + const SizedBox(height: 24), + Center( + child: QR( + // TODO: grab coin uri scheme from somewhere + // data: "${coin.uriScheme}:$receivingAddress", + data: model.trade!.payInAddress, + size: MediaQuery.of(context).size.width / 2, + ), + ), + const SizedBox(height: 24), + Row( + children: [ + const Spacer(), + Expanded( + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Cancel", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, + ), + ), + ), + ), + ], + ), + ], + ), + ); + }, + ); + } + @override void initState() { model = widget.model; clipboard = widget.clipboard; - isWalletCoinAndCanSend = isWalletCoinAndCanSendWithoutWalletOpened( - model.trade!.payInCurrency, - ref.read(pWallets), - ); + isWalletCoinAndCanSend = + Util.isWalletCoinAndCanSendWithoutWalletOpenedIgnoringXMR( + model.trade!.payInCurrency, + ref.read(pWallets).wallets, + ); _statusTimer = Timer.periodic(const Duration(seconds: 60), (_) { _updateStatus(); @@ -162,8 +195,9 @@ class _Step4ViewState extends ConsumerState { return await showModalBottomSheet( context: context, - backgroundColor: - Theme.of(context).extension()!.backgroundAppBar, + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(Constants.size.circularBorderRadius * 3), @@ -261,14 +295,14 @@ class _Step4ViewState extends ConsumerState { "${model.trade!.payInCurrency.toUpperCase()}/" "${model.trade!.payOutCurrency.toUpperCase()} exchange", ), + requireChaumV2: true, ); } else { - final memo = - wallet.info.coin is Stellar - ? model.trade!.payInExtraId.isNotEmpty - ? model.trade!.payInExtraId - : null - : null; + final memo = wallet.info.coin is Stellar + ? model.trade!.payInExtraId.isNotEmpty + ? model.trade!.payInExtraId + : null + : null; txDataFuture = wallet.prepareSend( txData: TxData( recipients: [recipient], @@ -297,14 +331,13 @@ class _Step4ViewState extends ConsumerState { Navigator.of(context).push( RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: - (_) => ConfirmChangeNowSendView( - txData: txData, - walletId: tuple.item1, - routeOnSuccessName: HomeView.routeName, - trade: model.trade!, - shouldSendPublicFiroFunds: firoPublicSend, - ), + builder: (_) => ConfirmChangeNowSendView( + txData: txData, + walletId: tuple.item1, + routeOnSuccessName: HomeView.routeName, + trade: model.trade!, + shouldSendPublicFiroFunds: firoPublicSend, + ), settings: const RouteSettings( name: ConfirmChangeNowSendView.routeName, ), @@ -335,10 +368,9 @@ class _Step4ViewState extends ConsumerState { child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), ), onPressed: () { @@ -362,8 +394,9 @@ class _Step4ViewState extends ConsumerState { }, child: Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: Padding( padding: const EdgeInsets.all(10), @@ -375,10 +408,12 @@ class _Step4ViewState extends ConsumerState { Assets.svg.x, width: 24, height: 24, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.topNavIconPrimary, + .srcIn, + ), ), onPressed: _close, ), @@ -405,297 +440,60 @@ class _Step4ViewState extends ConsumerState { StepRow(count: 4, current: 3, width: width), const SizedBox(height: 14), Text( - "Send ${model.sendTicker.toUpperCase()} to the address below", + "Send ${model.sendTicker.toUpperCase()} " + "to the address below", style: STextStyles.pageTitleH1(context), ), const SizedBox(height: 8), Text( - "Send ${model.sendTicker.toUpperCase()} to the address below. Once it is received, ${model.trade!.exchangeName} will send the ${model.receiveTicker.toUpperCase()} to the recipient address you provided. You can find this trade details and check its status in the list of trades.", + "Send ${model.sendTicker.toUpperCase()} " + "to the address below. Once it is received, " + "${model.trade!.exchangeName} will send the " + "${model.receiveTicker.toUpperCase()} to the " + "recipient address you provided. You can find" + " this trade details and check its status in " + "the list of trades.", style: STextStyles.itemSubtitle(context), ), const SizedBox(height: 12), - RoundedContainer( - color: - Theme.of(context) - .extension()! - .warningBackground, - child: RichText( - text: TextSpan( - text: - "You must send at least ${model.sendAmount.toString()} ${model.sendTicker}. ", - style: STextStyles.label700( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .warningForeground, - ), - children: [ - TextSpan( - text: - "If you send less than ${model.sendAmount.toString()} ${model.sendTicker}, your transaction may not be converted and it may not be refunded.", - style: STextStyles.label( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .warningForeground, - ), - ), - ], - ), - ), - ), + _WarningInfo(model: model), const SizedBox(height: 8), - RoundedWhiteContainer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - "Amount", - style: STextStyles.itemSubtitle( - context, - ), - ), - GestureDetector( - onTap: () async { - final data = ClipboardData( - text: model.sendAmount.toString(), - ); - await clipboard.setData(data); - if (context.mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.info, - message: - "Copied to clipboard", - context: context, - ), - ); - } - }, - child: Row( - children: [ - SvgPicture.asset( - Assets.svg.copy, - color: - Theme.of(context) - .extension< - StackColors - >()! - .infoItemIcons, - width: 10, - ), - const SizedBox(width: 4), - Text( - "Copy", - style: STextStyles.link2( - context, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - "${model.sendAmount.toString()} ${model.sendTicker.toUpperCase()}", - style: STextStyles.itemSubtitle12( - context, - ), - ), - ], + DetailItem( + title: "Amount", + detail: + "${model.sendAmount.toString()} " + "${model.sendTicker.toUpperCase()}", + button: SimpleCopyButton( + data: model.sendAmount.toString(), ), ), const SizedBox(height: 8), - RoundedWhiteContainer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - "Send ${model.sendTicker.toUpperCase()} to this address", - style: STextStyles.itemSubtitle( - context, - ), - ), - GestureDetector( - onTap: () async { - final data = ClipboardData( - text: model.trade!.payInAddress, - ); - await clipboard.setData(data); - if (context.mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.info, - message: - "Copied to clipboard", - context: context, - ), - ); - } - }, - child: Row( - children: [ - SvgPicture.asset( - Assets.svg.copy, - color: - Theme.of(context) - .extension< - StackColors - >()! - .infoItemIcons, - width: 10, - ), - const SizedBox(width: 4), - Text( - "Copy", - style: STextStyles.link2( - context, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - model.trade!.payInAddress, - style: STextStyles.itemSubtitle12( - context, - ), - ), - ], + DetailItem( + title: + "Send " + "${model.sendTicker.toUpperCase()}" + " to this address", + detail: model.trade!.payInAddress, + button: SimpleCopyButton( + data: model.trade!.payInAddress, ), ), const SizedBox(height: 6), if (model.trade!.payInExtraId.isNotEmpty) - RoundedWhiteContainer( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - "Memo", - style: STextStyles.itemSubtitle( - context, - ), - ), - GestureDetector( - onTap: () async { - final data = ClipboardData( - text: model.trade!.payInExtraId, - ); - await clipboard.setData(data); - if (context.mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.info, - message: - "Copied to clipboard", - context: context, - ), - ); - } - }, - child: Row( - children: [ - SvgPicture.asset( - Assets.svg.copy, - color: - Theme.of(context) - .extension< - StackColors - >()! - .infoItemIcons, - width: 10, - ), - const SizedBox(width: 4), - Text( - "Copy", - style: STextStyles.link2( - context, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - model.trade!.payInExtraId, - style: STextStyles.itemSubtitle12( - context, - ), - ), - ], + DetailItem( + title: "Memo", + detail: model.trade!.payInExtraId, + button: SimpleCopyButton( + data: model.trade!.payInExtraId, ), ), if (model.trade!.payInExtraId.isNotEmpty) const SizedBox(height: 6), - RoundedWhiteContainer( - child: Row( - children: [ - Text( - "Trade ID", - style: STextStyles.itemSubtitle(context), - ), - const Spacer(), - Row( - children: [ - Text( - model.trade!.tradeId, - style: STextStyles.itemSubtitle12( - context, - ), - ), - const SizedBox(width: 10), - GestureDetector( - onTap: () async { - final data = ClipboardData( - text: model.trade!.tradeId, - ); - await clipboard.setData(data); - if (context.mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.info, - message: - "Copied to clipboard", - context: context, - ), - ); - } - }, - child: SvgPicture.asset( - Assets.svg.copy, - color: - Theme.of(context) - .extension()! - .infoItemIcons, - width: 12, - ), - ), - ], - ), - ], + DetailItem( + title: "Trade ID", + detail: model.trade!.tradeId, + button: SimpleCopyButton( + data: model.trade!.tradeId, ), ), const SizedBox(height: 6), @@ -710,198 +508,28 @@ class _Step4ViewState extends ConsumerState { ), Text( _statusString, - style: STextStyles.itemSubtitle( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .colorForStatus(_statusString), - ), + style: STextStyles.itemSubtitle(context) + .copyWith( + color: Theme.of(context) + .extension()! + .colorForStatus(_statusString), + ), ), ], ), ), const Spacer(), const SizedBox(height: 12), - TextButton( - onPressed: () { - showDialog( - context: context, - barrierDismissible: true, - builder: (_) { - return StackDialogBase( - child: Column( - children: [ - const SizedBox(height: 8), - Center( - child: Text( - "Send ${model.sendTicker} to this address", - style: STextStyles.pageTitleH2( - context, - ), - ), - ), - const SizedBox(height: 24), - Center( - child: QR( - // TODO: grab coin uri scheme from somewhere - // data: "${coin.uriScheme}:$receivingAddress", - data: model.trade!.payInAddress, - size: - MediaQuery.of( - context, - ).size.width / - 2, - ), - ), - const SizedBox(height: 24), - Row( - children: [ - const Spacer(), - Expanded( - child: TextButton( - onPressed: - () => - Navigator.of( - context, - ).pop(), - style: Theme.of(context) - .extension< - StackColors - >()! - .getSecondaryEnabledButtonStyle( - context, - ), - child: Text( - "Cancel", - style: STextStyles.button( - context, - ).copyWith( - color: - Theme.of(context) - .extension< - StackColors - >()! - .buttonTextSecondary, - ), - ), - ), - ), - ], - ), - ], - ), - ); - }, - ); - }, - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - child: Text( - "Show QR Code", - style: STextStyles.button(context), - ), + PrimaryButton( + label: "Show QR Code", + onPressed: _showQr, ), if (isWalletCoinAndCanSend) const SizedBox(height: 12), if (isWalletCoinAndCanSend) - Builder( - builder: (context) { - String buttonTitle = - "Send from ${AppConfig.appName}"; - - final tuple = - ref - .read( - exchangeSendFromWalletIdStateProvider - .state, - ) - .state; - if (tuple != null && - model.sendTicker.toLowerCase() == - tuple.item2.ticker.toLowerCase()) { - final walletName = - ref - .read(pWallets) - .getWallet(tuple.item1) - .info - .name; - buttonTitle = "Send from $walletName"; - } - - return TextButton( - onPressed: - tuple != null && - model.sendTicker - .toLowerCase() == - tuple.item2.ticker - .toLowerCase() - ? () async { - await _confirmSend(tuple); - } - : () { - Navigator.of(context).push( - RouteGenerator.getRoute( - shouldUseMaterialRoute: - RouteGenerator - .useMaterialPageRoute, - builder: ( - BuildContext context, - ) { - final coin = AppConfig - .coins - .firstWhere( - (e) => - e.ticker - .toLowerCase() == - model - .trade! - .payInCurrency - .toLowerCase(), - ); - - return SendFromView( - coin: coin, - amount: model.sendAmount - .toAmount( - fractionDigits: - coin.fractionDigits, - ), - address: - model - .trade! - .payInAddress, - trade: model.trade!, - ); - }, - settings: - const RouteSettings( - name: - SendFromView - .routeName, - ), - ), - ); - }, - style: Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle( - context, - ), - child: Text( - buttonTitle, - style: STextStyles.button( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .buttonTextSecondary, - ), - ), - ); - }, + _SendFromButton( + model: model, + confirmSend: _confirmSend, ), ], ), @@ -918,3 +546,98 @@ class _Step4ViewState extends ConsumerState { ); } } + +class _WarningInfo extends StatelessWidget { + const _WarningInfo({super.key, required this.model}); + final IncompleteExchangeModel model; + + @override + Widget build(BuildContext context) { + return RoundedContainer( + color: Theme.of(context).extension()!.warningBackground, + child: RichText( + text: TextSpan( + text: + "You must send at least " + "${model.sendAmount.toString()} ${model.sendTicker}. ", + style: STextStyles.label700(context).copyWith( + color: Theme.of( + context, + ).extension()!.warningForeground, + ), + children: [ + TextSpan( + text: + "If you send less than " + "${model.sendAmount.toString()} ${model.sendTicker}," + " your transaction may not be converted and it may not be" + " refunded.", + style: STextStyles.label(context).copyWith( + color: Theme.of( + context, + ).extension()!.warningForeground, + ), + ), + ], + ), + ), + ); + } +} + +class _SendFromButton extends ConsumerWidget { + const _SendFromButton({ + super.key, + required this.model, + required this.confirmSend, + }); + + final IncompleteExchangeModel model; + final Future Function(Tuple2 tuple) confirmSend; + + @override + Widget build(BuildContext context, WidgetRef ref) { + String buttonTitle = "Send from ${AppConfig.appName}"; + + final tuple = ref.read(exchangeSendFromWalletIdStateProvider.state).state; + if (tuple != null && + model.sendTicker.toLowerCase() == tuple.item2.ticker.toLowerCase()) { + final walletName = ref.read(pWallets).getWallet(tuple.item1).info.name; + buttonTitle = "Send from $walletName"; + } + + return SecondaryButton( + label: buttonTitle, + onPressed: () async { + if (tuple != null && + model.sendTicker.toLowerCase() == + tuple.item2.ticker.toLowerCase()) { + await confirmSend(tuple); + } else { + await Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (BuildContext context) { + final coin = AppConfig.coins.firstWhere( + (e) => + e.ticker.toLowerCase() == + model.trade!.payInCurrency.toLowerCase(), + ); + + return SendFromView( + coin: coin, + amount: model.sendAmount.toAmount( + fractionDigits: coin.fractionDigits, + ), + address: model.trade!.payInAddress, + trade: model.trade!, + ); + }, + settings: const RouteSettings(name: SendFromView.routeName), + ), + ); + } + }, + ); + } +} diff --git a/lib/pages/exchange_view/send_from_view.dart b/lib/pages/exchange_view/send_from_view.dart index fa0283ff45..9fe4121ee0 100644 --- a/lib/pages/exchange_view/send_from_view.dart +++ b/lib/pages/exchange_view/send_from_view.dart @@ -92,13 +92,12 @@ class _SendFromViewState extends ConsumerState { Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); - final walletIds = - ref - .watch(pWallets) - .wallets - .where((e) => e.info.coin == coin) - .map((e) => e.walletId) - .toList(); + final walletIds = ref + .watch(pWallets) + .wallets + .where((e) => e.info.coin == coin) + .map((e) => e.walletId) + .toList(); final isDesktop = Util.isDesktop; @@ -107,8 +106,9 @@ class _SendFromViewState extends ConsumerState { builder: (child) { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -125,41 +125,35 @@ class _SendFromViewState extends ConsumerState { }, child: ConditionalParent( condition: isDesktop, - builder: - (child) => DesktopDialog( - maxHeight: double.infinity, - child: Column( + builder: (child) => DesktopDialog( + maxHeight: double.infinity, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Send from ${AppConfig.prefix}", - style: STextStyles.desktopH3(context), - ), - ), - DesktopDialogCloseButton( - onPressedOverride: - Navigator.of( - context, - rootNavigator: widget.shouldPopRoot, - ).pop, - ), - ], - ), Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, + padding: const EdgeInsets.only(left: 32), + child: Text( + "Send from ${AppConfig.prefix}", + style: STextStyles.desktopH3(context), ), - child: child, + ), + DesktopDialogCloseButton( + onPressedOverride: Navigator.of( + context, + rootNavigator: widget.shouldPopRoot, + ).pop, ), ], ), - ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: child, + ), + ], + ), + ), child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ @@ -167,10 +161,9 @@ class _SendFromViewState extends ConsumerState { children: [ Text( "You need to send ${ref.watch(pAmountFormatter(coin)).format(amount)}", - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.itemSubtitle(context), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle(context), ), ], ), @@ -245,15 +238,11 @@ class _SendFromCardState extends ConsumerState { builder: (context) { return ConditionalParent( condition: Util.isDesktop, - builder: - (child) => DesktopDialog( - maxWidth: 400, - maxHeight: double.infinity, - child: Padding( - padding: const EdgeInsets.all(32), - child: child, - ), - ), + builder: (child) => DesktopDialog( + maxWidth: 400, + maxHeight: double.infinity, + child: Padding(padding: const EdgeInsets.all(32), child: child), + ), child: BuildingTransactionDialog( coin: coin, isSpark: @@ -269,10 +258,9 @@ class _SendFromCardState extends ConsumerState { ), ); - // Currently CwBasedInterface wallets (xmr/wow) shouldn't even have - // access to this screen but this is needed to get past an error that - // would occur only to lead to another error which is why xmr/wow wallets - // don't have access to this screen currently + // Currently most external wallets need to fully sync before they can + // which will cause errors and things and stuff + // TODO come back to this some day if (wallet is ExternalWallet) { await wallet.init(); await wallet.open(); @@ -292,12 +280,11 @@ class _SendFromCardState extends ConsumerState { // if not firo then do normal send if (shouldSendPublicFiroFunds == null) { - final memo = - coin is Stellar - ? trade.payInExtraId.isNotEmpty - ? trade.payInExtraId - : null - : null; + final memo = coin is Stellar || coin is Solana + ? trade.payInExtraId.isNotEmpty + ? trade.payInExtraId + : null + : null; txDataFuture = wallet.prepareSend( txData: TxData( recipients: [recipient], @@ -318,9 +305,19 @@ class _SendFromCardState extends ConsumerState { } else { txDataFuture = firoWallet.prepareSendSpark( txData: TxData( - recipients: [recipient], - // feeRateType: FeeRateType.average, + recipients: recipient.addressType == .spark ? null : [recipient], + sparkRecipients: recipient.addressType == .spark + ? [ + ( + address: recipient.address, + amount: recipient.amount, + memo: "", + isChange: false, + ), + ] + : null, ), + requireChaumV2: true, ); } } @@ -346,18 +343,16 @@ class _SendFromCardState extends ConsumerState { await Navigator.of(context).push( RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: - (_) => ConfirmChangeNowSendView( - txData: txData, - walletId: walletId, - routeOnSuccessName: - Util.isDesktop - ? DesktopExchangeView.routeName - : HomeView.routeName, - trade: trade, - shouldSendPublicFiroFunds: shouldSendPublicFiroFunds, - fromDesktopStep4: widget.fromDesktopStep4, - ), + builder: (_) => ConfirmChangeNowSendView( + txData: txData, + walletId: walletId, + routeOnSuccessName: Util.isDesktop + ? DesktopExchangeView.routeName + : HomeView.routeName, + trade: trade, + shouldSendPublicFiroFunds: shouldSendPublicFiroFunds, + fromDesktopStep4: widget.fromDesktopStep4, + ), settings: const RouteSettings( name: ConfirmChangeNowSendView.routeName, ), @@ -369,7 +364,7 @@ class _SendFromCardState extends ConsumerState { Logging.instance.e("$e\n$s", error: e, stackTrace: s); if (mounted) { // pop building dialog - Navigator.of(context).pop(); + Navigator.of(context, rootNavigator: Util.isDesktop).pop(); await showDialog( context: context, @@ -386,10 +381,9 @@ class _SendFromCardState extends ConsumerState { child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), ), onPressed: () { @@ -422,161 +416,86 @@ class _SendFromCardState extends ConsumerState { padding: const EdgeInsets.all(0), child: ConditionalParent( condition: isFiro, - builder: - (child) => Expandable( - header: Container( - color: Colors.transparent, - child: Padding(padding: const EdgeInsets.all(12), child: child), - ), - body: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!trade.exchangeName.startsWith( - TrocadorExchange.exchangeName, - )) - MaterialButton( - splashColor: - Theme.of(context).extension()!.highlight, - key: Key( - "walletsSheetItemButtonFiroPrivateKey_$walletId", - ), - padding: const EdgeInsets.all(0), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), + builder: (child) => Expandable( + header: Container( + color: Colors.transparent, + child: Padding(padding: const EdgeInsets.all(12), child: child), + ), + body: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!trade.exchangeName.startsWith(TrocadorExchange.exchangeName)) + MaterialButton( + splashColor: Theme.of( + context, + ).extension()!.highlight, + key: Key("walletsSheetItemButtonFiroPrivateKey_$walletId"), + padding: const EdgeInsets.all(0), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: () async { + if (mounted) { + unawaited(_send(shouldSendPublicFiroFunds: false)); + } + }, + child: Container( + color: Colors.transparent, + child: Padding( + padding: const EdgeInsets.only( + top: 6, + left: 16, + right: 16, + bottom: 6, ), - onPressed: () async { - if (mounted) { - unawaited(_send(shouldSendPublicFiroFunds: false)); - } - }, - child: Container( - color: Colors.transparent, - child: Padding( - padding: const EdgeInsets.only( - top: 6, - left: 16, - right: 16, - bottom: 6, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Use private balance", - style: STextStyles.itemSubtitle(context), - ), - Text( - ref - .watch(pAmountFormatter(coin)) - .format( - ref - .watch( - pWalletBalanceTertiary( - walletId, - ), - ) - .spendable, - ), - style: STextStyles.itemSubtitle(context), - ), - ], + Text( + "Use private balance", + style: STextStyles.itemSubtitle(context), ), - SvgPicture.asset( - Assets.svg.chevronRight, - height: 14, - width: 7, - color: - Theme.of( - context, - ).extension()!.infoItemLabel, + Text( + ref + .watch(pAmountFormatter(coin)) + .format( + ref + .watch( + pWalletBalanceTertiary(walletId), + ) + .spendable, + ), + style: STextStyles.itemSubtitle(context), ), ], ), - ), - ), - ), - MaterialButton( - splashColor: - Theme.of(context).extension()!.highlight, - key: Key("walletsSheetItemButtonFiroPublicKey_$walletId"), - padding: const EdgeInsets.all(0), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - onPressed: () async { - if (mounted) { - unawaited(_send(shouldSendPublicFiroFunds: true)); - } - }, - child: Container( - color: Colors.transparent, - child: Padding( - padding: const EdgeInsets.only( - top: 6, - left: 16, - right: 16, - bottom: 6, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Use public balance", - style: STextStyles.itemSubtitle(context), - ), - Text( - ref - .watch(pAmountFormatter(coin)) - .format( - ref - .watch(pWalletBalance(walletId)) - .spendable, - ), - style: STextStyles.itemSubtitle(context), - ), - ], - ), - SvgPicture.asset( - Assets.svg.chevronRight, - height: 14, - width: 7, - color: - Theme.of( - context, - ).extension()!.infoItemLabel, - ), - ], - ), + SvgPicture.asset( + Assets.svg.chevronRight, + height: 14, + width: 7, + color: Theme.of( + context, + ).extension()!.infoItemLabel, + ), + ], ), ), ), - const SizedBox(height: 6), - ], - ), - ), - child: ConditionalParent( - condition: !isFiro, - builder: - (child) => MaterialButton( - splashColor: - Theme.of(context).extension()!.highlight, - key: Key("walletsSheetItemButtonKey_$walletId"), - padding: const EdgeInsets.all(8), + ), + MaterialButton( + splashColor: Theme.of( + context, + ).extension()!.highlight, + key: Key("walletsSheetItemButtonFiroPublicKey_$walletId"), + padding: const EdgeInsets.all(0), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( @@ -585,11 +504,77 @@ class _SendFromCardState extends ConsumerState { ), onPressed: () async { if (mounted) { - unawaited(_send()); + unawaited(_send(shouldSendPublicFiroFunds: true)); } }, - child: child, + child: Container( + color: Colors.transparent, + child: Padding( + padding: const EdgeInsets.only( + top: 6, + left: 16, + right: 16, + bottom: 6, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Use public balance", + style: STextStyles.itemSubtitle(context), + ), + Text( + ref + .watch(pAmountFormatter(coin)) + .format( + ref + .watch(pWalletBalance(walletId)) + .spendable, + ), + style: STextStyles.itemSubtitle(context), + ), + ], + ), + SvgPicture.asset( + Assets.svg.chevronRight, + height: 14, + width: 7, + color: Theme.of( + context, + ).extension()!.infoItemLabel, + ), + ], + ), + ), + ), ), + const SizedBox(height: 6), + ], + ), + ), + child: ConditionalParent( + condition: !isFiro, + builder: (child) => MaterialButton( + splashColor: Theme.of(context).extension()!.highlight, + key: Key("walletsSheetItemButtonKey_$walletId"), + padding: const EdgeInsets.all(8), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: () async { + if (mounted) { + unawaited(_send()); + } + }, + child: child, + ), child: Row( children: [ Container( diff --git a/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart b/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart index 8b56ae2130..700088664c 100644 --- a/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart +++ b/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart @@ -11,7 +11,6 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/svg.dart'; import '../../../app_config.dart'; import '../../../models/exchange/aggregate_currency.dart'; @@ -24,7 +23,6 @@ import '../../../themes/stack_colors.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/amount/amount_formatter.dart'; import '../../../utilities/amount/amount_unit.dart'; -import '../../../utilities/assets.dart'; import '../../../utilities/enums/exchange_rate_type_enum.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/text_styles.dart'; @@ -37,6 +35,8 @@ import '../../../widgets/desktop/primary_button.dart'; import '../../../widgets/dialogs/basic_dialog.dart'; import '../../../widgets/exchange/trocador/trocador_kyc_info_button.dart'; import '../../../widgets/exchange/trocador/trocador_rating_type_enum.dart'; +import '../../../widgets/icon_widgets/exchange_icon.dart'; +import '../../../widgets/loading_indicator.dart'; class ExchangeOption extends ConsumerStatefulWidget { const ExchangeOption({ @@ -66,18 +66,16 @@ class _ExchangeOptionState extends ConsumerState { efCurrencyPairProvider.select((value) => value.receive), ); final reversed = ref.watch(efReversedProvider); - final amount = - reversed - ? ref.watch(efReceiveAmountProvider) - : ref.watch(efSendAmountProvider); + final amount = reversed + ? ref.watch(efReceiveAmountProvider) + : ref.watch(efSendAmountProvider); final data = ref.watch(efEstimatesListProvider(widget.exchange.name)); final estimates = data?.item1.value; - final pair = - sendCurrency != null && receivingCurrency != null - ? (from: sendCurrency, to: receivingCurrency) - : null; + final pair = sendCurrency != null && receivingCurrency != null + ? (from: sendCurrency, to: receivingCurrency) + : null; return AnimatedSize( duration: const Duration(milliseconds: 500), @@ -86,7 +84,7 @@ class _ExchangeOptionState extends ConsumerState { builder: (_) { if (ref.watch(efRefreshingProvider)) { // show loading - return _ProviderOption( + return ExchProviderOption( exchange: widget.exchange, estimate: null, pair: pair, @@ -108,10 +106,9 @@ class _ExchangeOptionState extends ConsumerState { int decimals; try { - decimals = - AppConfig.getCryptoCurrencyForTicker( - receivingCurrency.ticker, - )!.fractionDigits; + decimals = AppConfig.getCryptoCurrencyForTicker( + receivingCurrency.ticker, + )!.fractionDigits; } catch (_) { decimals = 8; // some reasonable alternative } @@ -161,23 +158,21 @@ class _ExchangeOptionState extends ConsumerState { return ConditionalParent( condition: i > 0, - builder: - (child) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - isDesktop - ? Container( - height: 1, - color: - Theme.of(context) - .extension()! - .background, - ) - : const SizedBox(height: 16), - child, - ], - ), - child: _ProviderOption( + builder: (child) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + isDesktop + ? Container( + height: 1, + color: Theme.of( + context, + ).extension()!.background, + ) + : const SizedBox(height: 16), + child, + ], + ), + child: ExchProviderOption( key: Key(widget.exchange.name + e.exchangeProvider), exchange: widget.exchange, pair: pair, @@ -209,26 +204,27 @@ class _ExchangeOptionState extends ConsumerState { } else if (data?.item1.value == null) { final rateType = ref.watch(efRateTypeProvider) == - ExchangeRateType.estimated - ? "estimated" - : "fixed"; + ExchangeRateType.estimated + ? "estimated" + : "fixed"; message ??= "Pair unavailable on $rateType rate flow"; } - return _ProviderOption( + return ExchProviderOption( exchange: widget.exchange, estimate: null, pair: pair, rateString: message ?? "Failed to fetch rate", - rateColor: - Theme.of(context).extension()!.textError, + rateColor: Theme.of( + context, + ).extension()!.textError, ); }, ); } } else { // show n/a - return _ProviderOption( + return ExchProviderOption( exchange: widget.exchange, estimate: null, pair: pair, @@ -241,8 +237,8 @@ class _ExchangeOptionState extends ConsumerState { } } -class _ProviderOption extends ConsumerStatefulWidget { - const _ProviderOption({ +class ExchProviderOption extends ConsumerStatefulWidget { + const ExchProviderOption({ super.key, required this.exchange, required this.estimate, @@ -262,10 +258,10 @@ class _ProviderOption extends ConsumerStatefulWidget { final Color? rateColor; @override - ConsumerState<_ProviderOption> createState() => _ProviderOptionState(); + ConsumerState createState() => _ProviderOptionState(); } -class _ProviderOptionState extends ConsumerState<_ProviderOption> { +class _ProviderOptionState extends ConsumerState { final isDesktop = Util.isDesktop; late final String _id; @@ -335,9 +331,8 @@ class _ProviderOptionState extends ConsumerState<_ProviderOption> { return ConditionalParent( condition: isDesktop, - builder: - (child) => - MouseRegion(cursor: SystemMouseCursors.click, child: child), + builder: (child) => + MouseRegion(cursor: SystemMouseCursors.click, child: child), child: GestureDetector( onTap: () { ref.read(efExchangeProvider.notifier).state = widget.exchange; @@ -347,8 +342,9 @@ class _ProviderOptionState extends ConsumerState<_ProviderOption> { child: Container( color: Colors.transparent, child: Padding( - padding: - isDesktop ? const EdgeInsets.all(16) : const EdgeInsets.all(0), + padding: isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(0), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -358,18 +354,18 @@ class _ProviderOptionState extends ConsumerState<_ProviderOption> { child: Padding( padding: EdgeInsets.only(top: isDesktop ? 20.0 : 15.0), child: Radio( - activeColor: - Theme.of( - context, - ).extension()!.radioButtonIconEnabled, + activeColor: Theme.of( + context, + ).extension()!.radioButtonIconEnabled, value: _id, groupValue: groupValue, onChanged: (_) { ref.read(efExchangeProvider.notifier).state = widget.exchange; ref - .read(efExchangeProviderNameProvider.notifier) - .state = widget.estimate?.exchangeProvider ?? + .read(efExchangeProviderNameProvider.notifier) + .state = + widget.estimate?.exchangeProvider ?? widget.exchange.name; }, ), @@ -383,47 +379,27 @@ class _ProviderOptionState extends ConsumerState<_ProviderOption> { height: isDesktop ? 32 : 24, child: widget.estimate?.exchangeProviderLogo != null && - widget - .estimate! - .exchangeProviderLogo! - .isNotEmpty - ? ClipRRect( - borderRadius: BorderRadius.circular(5), - child: Image.network( - widget.estimate!.exchangeProviderLogo!, - loadingBuilder: ( - context, - child, - loadingProgress, - ) { - if (loadingProgress == null) { - return child; - } else { - return const Center( - child: CircularProgressIndicator(), - ); - } - }, - errorBuilder: (context, error, stackTrace) { - return SvgPicture.asset( - Assets.exchange.getIconFor( - exchangeName: widget.exchange.name, - ), - width: isDesktop ? 32 : 24, - height: isDesktop ? 32 : 24, - ); - }, - width: isDesktop ? 32 : 24, - height: isDesktop ? 32 : 24, - ), - ) - : SvgPicture.asset( - Assets.exchange.getIconFor( - exchangeName: widget.exchange.name, - ), + widget.estimate!.exchangeProviderLogo!.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Image.network( + widget.estimate!.exchangeProviderLogo!, + loadingBuilder: + (context, child, loadingProgress) { + if (loadingProgress == null) { + return child; + } else { + return const LoadingIndicator(); + } + }, + errorBuilder: (context, error, stackTrace) { + return ExchangeIcon(exchange: widget.exchange); + }, width: isDesktop ? 32 : 24, height: isDesktop ? 32 : 24, ), + ) + : ExchangeIcon(exchange: widget.exchange), ), ), const SizedBox(width: 10), @@ -435,55 +411,54 @@ class _ProviderOptionState extends ConsumerState<_ProviderOption> { children: [ ConditionalParent( condition: _warnings.isNotEmpty, - builder: - (child) => Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - child, - CustomTextButton( - text: _warnings.first.value, - onTap: () { - _showNoSparkWarning(); - }, - ), - ], + builder: (child) => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + child, + CustomTextButton( + text: _warnings.first.value, + onTap: () { + _showNoSparkWarning(); + }, ), + ], + ), child: Text( widget.estimate?.exchangeProvider ?? widget.exchange.name, style: STextStyles.titleBold12(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark2, + color: Theme.of( + context, + ).extension()!.textDark2, ), ), ), widget.loadingString ? AnimatedText( - stringsToLoopThrough: const [ - "Loading", - "Loading.", - "Loading..", - "Loading...", - ], - style: STextStyles.itemSubtitle12(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textSubtitle1, - ), - ) + stringsToLoopThrough: const [ + "Loading", + "Loading.", + "Loading..", + "Loading...", + ], + style: STextStyles.itemSubtitle12(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ) : Text( - widget.rateString, - style: STextStyles.itemSubtitle12(context).copyWith( - color: - widget.rateColor ?? - Theme.of( - context, - ).extension()!.textSubtitle1, + widget.rateString, + style: STextStyles.itemSubtitle12(context) + .copyWith( + color: + widget.rateColor ?? + Theme.of(context) + .extension()! + .textSubtitle1, + ), ), - ), ], ), ), diff --git a/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart b/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart index a2ef393053..dda1cd4c53 100644 --- a/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart +++ b/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart @@ -14,14 +14,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../models/exchange/aggregate_currency.dart'; import '../../../providers/providers.dart'; import '../../../services/exchange/change_now/change_now_exchange.dart'; +import '../../../services/exchange/cyphergoat/cyphergoat_exchange.dart'; import '../../../services/exchange/exchange.dart'; +import '../../../services/exchange/exolix/exolix_exchange.dart'; +import '../../../services/exchange/lets_exchange/lets_exchange_exchange.dart'; 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/prefs.dart'; import '../../../utilities/util.dart'; import '../../../widgets/rounded_white_container.dart'; -import 'exchange_provider_option.dart'; +import 'sorted_exchange_providers.dart'; class ExchangeProviderOptions extends ConsumerStatefulWidget { const ExchangeProviderOptions({ @@ -91,49 +95,82 @@ class _ExchangeProviderOptionsState sendCurrency: sendCurrency, receiveCurrency: receivingCurrency, ); + final showWizardSwap = exchangeSupported( + exchangeName: WizardSwapExchange.exchangeName, + sendCurrency: sendCurrency, + receiveCurrency: receivingCurrency, + ); + final showExolix = exchangeSupported( + exchangeName: ExolixExchange.exchangeName, + sendCurrency: sendCurrency, + receiveCurrency: receivingCurrency, + ); + final showCypherGoat = exchangeSupported( + exchangeName: CypherGoatExchange.exchangeName, + sendCurrency: sendCurrency, + receiveCurrency: receivingCurrency, + ); + final showLetsExchange = exchangeSupported( + exchangeName: LetsExchangeExchange.exchangeName, + sendCurrency: sendCurrency, + receiveCurrency: receivingCurrency, + ); return RoundedWhiteContainer( padding: isDesktop ? const EdgeInsets.all(0) : const EdgeInsets.all(12), - borderColor: - isDesktop - ? Theme.of(context).extension()!.background - : null, - child: Column( - children: [ - if (showChangeNow) - ExchangeOption( - exchange: ChangeNowExchange.instance, - fixedRate: widget.fixedRate, - reversed: widget.reversed, - ), - if (showChangeNow && showTrocador) - isDesktop - ? Container( - height: 1, - color: Theme.of(context).extension()!.background, - ) - : const SizedBox(height: 16), - if (showTrocador) - ExchangeOption( - fixedRate: widget.fixedRate, - reversed: widget.reversed, - exchange: TrocadorExchange.instance, - ), - if ((showChangeNow || showTrocador) && showNanswap) - isDesktop - ? Container( - height: 1, - color: Theme.of(context).extension()!.background, - ) - : const SizedBox(height: 16), - if (showNanswap) - ExchangeOption( - fixedRate: widget.fixedRate, - reversed: widget.reversed, - exchange: NanswapExchange.instance, - ), + borderColor: isDesktop + ? Theme.of(context).extension()!.background + : null, + child: SortedExchangeProviders( + exchangees: [ + if (showChangeNow) ChangeNowExchange.instance, + if (showExolix) ExolixExchange.instance, + if (showLetsExchange) LetsExchangeExchange.instance, + if (showTrocador) TrocadorExchange.instance, + if (showNanswap) NanswapExchange.instance, + if (showWizardSwap) WizardSwapExchange.instance, + if (showCypherGoat) CypherGoatExchange.instance, ], + fixedRate: widget.fixedRate, + reversed: widget.reversed, ), + + // Column( + // children: [ + // if (showChangeNow) + // ExchangeOption( + // exchange: ChangeNowExchange.instance, + // fixedRate: widget.fixedRate, + // reversed: widget.reversed, + // ), + // if (showChangeNow && showTrocador) + // isDesktop + // ? Container( + // height: 1, + // color: Theme.of(context).extension()!.background, + // ) + // : const SizedBox(height: 16), + // if (showTrocador) + // ExchangeOption( + // fixedRate: widget.fixedRate, + // reversed: widget.reversed, + // exchange: TrocadorExchange.instance, + // ), + // if ((showChangeNow || showTrocador) && showNanswap) + // isDesktop + // ? Container( + // height: 1, + // color: Theme.of(context).extension()!.background, + // ) + // : const SizedBox(height: 16), + // if (showNanswap) + // ExchangeOption( + // fixedRate: widget.fixedRate, + // reversed: widget.reversed, + // exchange: NanswapExchange.instance, + // ), + // ], + // ), ); } } diff --git a/lib/pages/exchange_view/sub_widgets/sorted_exchange_providers.dart b/lib/pages/exchange_view/sub_widgets/sorted_exchange_providers.dart new file mode 100644 index 0000000000..c478f9ead0 --- /dev/null +++ b/lib/pages/exchange_view/sub_widgets/sorted_exchange_providers.dart @@ -0,0 +1,265 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tuple/tuple.dart'; + +import '../../../app_config.dart'; +import '../../../models/exchange/response_objects/estimate.dart'; +import '../../../models/exchange/response_objects/range.dart'; +import '../../../providers/exchange/exchange_form_state_provider.dart'; +import '../../../providers/global/locale_provider.dart'; +import '../../../services/exchange/exchange.dart'; +import '../../../services/exchange/exchange_response.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/amount/amount_formatter.dart'; +import '../../../utilities/amount/amount_unit.dart'; +import '../../../utilities/enums/exchange_rate_type_enum.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../widgets/conditional_parent.dart'; +import '../../../widgets/loading_indicator.dart'; +import 'exchange_provider_option.dart'; + +class SortedExchangeProviders extends ConsumerStatefulWidget { + const SortedExchangeProviders({ + super.key, + required this.exchangees, + required this.fixedRate, + required this.reversed, + }); + + final List exchangees; + final bool fixedRate; + final bool reversed; + + @override + ConsumerState createState() => + _SortedExchangeProvidersState(); +} + +class _SortedExchangeProvidersState + extends ConsumerState { + final List<(Exchange, Tuple2>, Range?>?)> + dataList = []; + final List<(Exchange, List?)> estimates = []; + + List<(Exchange, Estimate?)> transform(Decimal amount, String rcvTicker) { + final List<(Exchange, Estimate?)> flattened = []; + + for (final s in estimates) { + if (s.$2 != null && s.$2!.isNotEmpty) { + for (final e in s.$2!) { + flattened.add((s.$1, e)); + } + } else { + flattened.add((s.$1, null)); + } + } + + 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; + + // or we get problems!!! + assert(a.$2!.reversed == b.$2!.reversed); + + return _getRate(a.$2!, amount, rcvTicker) > + _getRate(b.$2!, amount, rcvTicker) + ? 0 + : 1; + }); + + return flattened; + } + + Amount _getRate(Estimate e, Decimal amount, String rcvTicker) { + int decimals; + try { + decimals = AppConfig.getCryptoCurrencyForTicker( + rcvTicker, + )!.fractionDigits; + } catch (_) { + decimals = 8; // some reasonable alternative + } + Amount rate; + if (e.reversed) { + rate = (amount / e.estimatedAmount) + .toDecimal(scaleOnInfinitePrecision: 18) + .toAmount(fractionDigits: decimals); + } else { + rate = (e.estimatedAmount / amount) + .toDecimal(scaleOnInfinitePrecision: 18) + .toAmount(fractionDigits: decimals); + } + return rate; + } + + @override + Widget build(BuildContext context) { + final sendCurrency = ref.watch( + efCurrencyPairProvider.select((value) => value.send), + ); + final receivingCurrency = ref.watch( + efCurrencyPairProvider.select((value) => value.receive), + ); + final reversed = ref.watch(efReversedProvider); + final amount = reversed + ? ref.watch(efReceiveAmountProvider) + : ref.watch(efSendAmountProvider); + + dataList.clear(); + estimates.clear(); + for (final exchange in widget.exchangees) { + final data = ref.watch(efEstimatesListProvider(exchange.name)); + dataList.add((exchange, data)); + estimates.add((exchange, data?.item1.value)); + } + + // final data = ref.watch(efEstimatesListProvider(widget.exchange.name)); + // final estimates = data?.item1.value; + + final pair = sendCurrency != null && receivingCurrency != null + ? (from: sendCurrency, to: receivingCurrency) + : null; + + if (ref.watch(efRefreshingProvider)) { + return const LoadingIndicator(width: 48, height: 48); + } + + if (sendCurrency != null && + receivingCurrency != null && + amount != null && + amount > Decimal.zero) { + final estimates = transform(amount, receivingCurrency.ticker); + + if (estimates.isNotEmpty) { + return Column( + mainAxisSize: .min, + children: [ + for (int i = 0; i < estimates.length; i++) + Builder( + builder: (context) { + final e = estimates[i].$2; + + if (e == null) { + return Consumer( + builder: (_, ref, __) { + String? message; + + final data = dataList + .firstWhere((e) => identical(e.$1, estimates[i].$1)) + .$2; + + final range = data?.item2; + if (range != null) { + if (range.min != null && amount < range.min!) { + message ??= "Amount too small"; + } else if (range.max != null && amount > range.max!) { + message ??= "Amount too large"; + } + } else if (data?.item1.value == null) { + final rateType = + ref.watch(efRateTypeProvider) == + ExchangeRateType.estimated + ? "estimated" + : "fixed"; + message ??= "Pair unavailable on $rateType rate flow"; + } + + return ExchProviderOption( + exchange: estimates[i].$1, + estimate: null, + pair: pair, + rateString: message ?? "Failed to fetch rate", + rateColor: Theme.of( + context, + ).extension()!.textError, + ); + }, + ); + } + + final rate = _getRate(e, amount, receivingCurrency.ticker); + + CryptoCurrency? coin; + try { + coin = AppConfig.getCryptoCurrencyForTicker( + receivingCurrency.ticker, + ); + } catch (_) { + coin = null; + } + + final String rateString; + if (coin != null) { + rateString = + "1 ${sendCurrency.ticker.toUpperCase()} " + "~ ${ref.watch(pAmountFormatter(coin)).format(rate)}"; + } else { + final formatter = AmountFormatter( + unit: AmountUnit.normal, + locale: ref.watch( + localeServiceChangeNotifierProvider.select( + (value) => value.locale, + ), + ), + coin: Bitcoin( + CryptoCurrencyNetwork.main, + ), // some sane default + maxDecimals: 8, // some sane default + ); + rateString = + "1 ${sendCurrency.ticker.toUpperCase()} " + "~ ${formatter.format(rate, withUnitName: false)}" + " ${receivingCurrency.ticker.toUpperCase()}"; + } + + return ConditionalParent( + condition: i > 0, + builder: (child) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Util.isDesktop + ? Container( + height: 1, + color: Theme.of( + context, + ).extension()!.background, + ) + : const SizedBox(height: 16), + child, + ], + ), + child: ExchProviderOption( + key: Key(estimates[i].$1.name + e.exchangeProvider), + exchange: estimates[i].$1, + pair: pair, + estimate: e, + rateString: rateString, + kycRating: e.kycRating, + ), + ); + }, + ), + ], + ); + } + } + + return Column( + mainAxisSize: .min, + children: [ + ...widget.exchangees.map( + (e) => ExchProviderOption( + exchange: e, + estimate: null, + pair: pair, + rateString: "n/a", + ), + ), + ], + ); + } +} diff --git a/lib/pages/exchange_view/trade_details_view.dart b/lib/pages/exchange_view/trade_details_view.dart index 39623c3d84..b5799e039a 100644 --- a/lib/pages/exchange_view/trade_details_view.dart +++ b/lib/pages/exchange_view/trade_details_view.dart @@ -28,11 +28,14 @@ import '../../providers/global/trades_service_provider.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; import '../../services/exchange/change_now/change_now_exchange.dart'; +import '../../services/exchange/cyphergoat/cyphergoat_exchange.dart'; import '../../services/exchange/exchange.dart'; +import '../../services/exchange/exolix/exolix_exchange.dart'; +import '../../services/exchange/lets_exchange/lets_exchange_exchange.dart'; import '../../services/exchange/nanswap/nanswap_exchange.dart'; import '../../services/exchange/simpleswap/simpleswap_exchange.dart'; import '../../services/exchange/trocador/trocador_exchange.dart'; -import '../../services/wallets.dart'; +import '../../services/exchange/wizard_swap/wizard_swap_exchange.dart'; import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; import '../../utilities/amount/amount.dart'; @@ -44,8 +47,6 @@ import '../../utilities/format.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; -import '../../wallets/wallet/intermediate/external_wallet.dart'; -import '../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -121,17 +122,21 @@ class _TradeDetailsViewState extends ConsumerState { String _fetchIconAssetForStatus(String statusString, IThemeAssets assets) { ChangeNowTransactionStatus? status; try { - if (statusString.toLowerCase().startsWith("waiting")) { + if (statusString.toLowerCase().startsWith("waiting") || + statusString.toLowerCase() == "wait") { statusString = "Waiting"; } status = changeNowTransactionStatusFromStringIgnoreCase(statusString); } on ArgumentError catch (_) { switch (statusString.toLowerCase()) { + case "confirmed": // exolix case + case "confirmation": // exolix case case "funds confirming": case "processing payment": return assets.txExchangePending; case "completed": + case "success": // exolix case return assets.txExchange; default: @@ -155,26 +160,6 @@ class _TradeDetailsViewState extends ConsumerState { } } - bool isWalletCoinAndCanSendWithoutWalletOpened( - String ticker, - Wallets walletsInstance, - ) { - try { - final coin = AppConfig.getCryptoCurrencyForTicker(ticker); - return walletsInstance.wallets - .where( - (e) => - e.info.coin == coin && - (e is! ExternalWallet || - e is MwebInterface), // ltc mweb is external but swaps - // should not use mweb, hence the odd logic check here - ) - .isNotEmpty; - } catch (_) { - return false; - } - } - @override Widget build(BuildContext context) { final bool sentFromStack = @@ -190,6 +175,7 @@ class _TradeDetailsViewState extends ConsumerState { sentFromStack || !(trade.status == "New" || trade.status == "new" || + trade.status == "wait" || trade.status == "Waiting" || trade.status == "waiting" || trade.status == "Refunded" || @@ -200,6 +186,7 @@ class _TradeDetailsViewState extends ConsumerState { trade.status == "expired" || trade.status == "Failed" || trade.status == "failed" || + trade.status == "overdue" || trade.status.toLowerCase().startsWith("waiting")); //todo: check if print needed @@ -217,141 +204,131 @@ class _TradeDetailsViewState extends ConsumerState { final showSendFromStackButton = !hasTx && AppConfig.isStackCoin(trade.payInCurrency) && - isWalletCoinAndCanSendWithoutWalletOpened( + Util.isWalletCoinAndCanSendWithoutWalletOpenedIgnoringXMR( trade.payInCurrency, - ref.read(pWallets), + ref.read(pWallets).wallets, ) && (trade.status == "New" || trade.status == "new" || trade.status == "waiting" || + trade.status == "wait" || trade.status == "Waiting"); return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.background, - leading: AppBarBackButton( - onPressed: () async { - Navigator.of(context).pop(); - }, - ), - title: Text( - "Trade details", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(12), - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(4), - child: child, - ), - ), - ), + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.background, + leading: AppBarBackButton( + onPressed: () async { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Trade details", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(12), + child: SingleChildScrollView( + child: Padding(padding: const EdgeInsets.all(4), child: child), ), ), ), + ), + ), child: Padding( - padding: - isDesktop - ? const EdgeInsets.only(left: 32) - : const EdgeInsets.all(0), + padding: isDesktop + ? const EdgeInsets.only(left: 32) + : const EdgeInsets.all(0), child: BranchedParent( condition: isDesktop, - conditionBranchBuilder: - (children) => Padding( - padding: const EdgeInsets.only(right: 20), - child: Padding( - padding: const EdgeInsets.only(right: 12), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - RoundedWhiteContainer( - borderColor: - Theme.of( - context, - ).extension()!.backgroundAppBar, - padding: const EdgeInsets.all(0), - child: ListView( - primary: false, - shrinkWrap: true, - children: children, - ), - ), - if (showSendFromStackButton) const SizedBox(height: 32), - if (showSendFromStackButton) - SecondaryButton( - label: "Send from ${AppConfig.prefix}", - buttonHeight: ButtonHeight.l, - onPressed: () { - CryptoCurrency coin; - try { - coin = - AppConfig.getCryptoCurrencyForTicker( - trade.payInCurrency, - )!; - } catch (_) { - coin = AppConfig.getCryptoCurrencyByPrettyName( - trade.payInCurrency, - ); - } - final amount = Amount.fromDecimal( - sendAmount, - fractionDigits: coin.fractionDigits, - ); - final address = trade.payInAddress; - - Navigator.of(context).pushNamed( - SendFromView.routeName, - arguments: Tuple4(coin, amount, address, trade), - ); - }, - ), - const SizedBox(height: 32), - ], + conditionBranchBuilder: (children) => Padding( + padding: const EdgeInsets.only(right: 20), + child: Padding( + padding: const EdgeInsets.only(right: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RoundedWhiteContainer( + borderColor: Theme.of( + context, + ).extension()!.backgroundAppBar, + padding: const EdgeInsets.all(0), + child: ListView( + primary: false, + shrinkWrap: true, + children: children, + ), ), - ), - ), - otherBranchBuilder: - (children) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: isDesktop ? MainAxisSize.min : MainAxisSize.max, - children: children, + if (showSendFromStackButton) const SizedBox(height: 32), + if (showSendFromStackButton) + SecondaryButton( + label: "Send from ${AppConfig.prefix}", + buttonHeight: ButtonHeight.l, + onPressed: () { + CryptoCurrency coin; + try { + coin = AppConfig.getCryptoCurrencyForTicker( + trade.payInCurrency, + )!; + } catch (_) { + coin = AppConfig.getCryptoCurrencyByPrettyName( + trade.payInCurrency, + ); + } + final amount = Amount.fromDecimal( + sendAmount, + fractionDigits: coin.fractionDigits, + ); + final address = trade.payInAddress; + + Navigator.of(context).pushNamed( + SendFromView.routeName, + arguments: Tuple4(coin, amount, address, trade), + ); + }, + ), + const SizedBox(height: 32), + ], ), + ), + ), + otherBranchBuilder: (children) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: isDesktop ? MainAxisSize.min : MainAxisSize.max, + children: children, + ), children: [ RoundedWhiteContainer( - padding: - isDesktop - ? const EdgeInsets.all(0) - : const EdgeInsets.all(12), + padding: isDesktop + ? const EdgeInsets.all(0) + : const EdgeInsets.all(12), child: Container( - decoration: - isDesktop - ? BoxDecoration( - color: - Theme.of( - context, - ).extension()!.backgroundAppBar, - borderRadius: BorderRadius.vertical( - top: Radius.circular( - Constants.size.circularBorderRadius, - ), + decoration: isDesktop + ? BoxDecoration( + color: Theme.of( + context, + ).extension()!.backgroundAppBar, + borderRadius: BorderRadius.vertical( + top: Radius.circular( + Constants.size.circularBorderRadius, ), - ) - : null, + ), + ) + : null, child: Padding( - padding: - isDesktop - ? const EdgeInsets.all(12) - : const EdgeInsets.all(0), + padding: isDesktop + ? const EdgeInsets.all(12) + : const EdgeInsets.all(0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -376,10 +353,9 @@ class _TradeDetailsViewState extends ConsumerState { ], ), Column( - crossAxisAlignment: - isDesktop - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, + crossAxisAlignment: isDesktop + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, children: [ SelectableText( "${trade.payInCurrency.toUpperCase()} → ${trade.payOutCurrency.toUpperCase()}", @@ -443,10 +419,9 @@ class _TradeDetailsViewState extends ConsumerState { ), isDesktop ? const _Divider() : const SizedBox(height: 12), RoundedWhiteContainer( - padding: - isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), + padding: isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -473,98 +448,87 @@ class _TradeDetailsViewState extends ConsumerState { isDesktop ? const _Divider() : const SizedBox(height: 12), if (!sentFromStack && !hasTx) RoundedContainer( - padding: - isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), - color: - isDesktop - ? Theme.of(context).extension()!.popupBG - : Theme.of( - context, - ).extension()!.warningBackground, + padding: isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), + color: isDesktop + ? Theme.of(context).extension()!.popupBG + : Theme.of( + context, + ).extension()!.warningBackground, child: ConditionalParent( condition: isDesktop, - builder: - (child) => Column( - mainAxisSize: MainAxisSize.min, + builder: (child) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Amount", - style: - STextStyles.desktopTextExtraExtraSmall( - context, - ), - ), - const SizedBox(height: 2), - Text( - "${trade.payInAmount} ${trade.payInCurrency.toUpperCase()}", - style: - STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textDark, - ), - ), - ], + Text( + "Amount", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + const SizedBox(height: 2), + Text( + "${trade.payInAmount} ${trade.payInCurrency.toUpperCase()}", + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), ), - tdv.IconCopyButton(data: trade.payInAmount), ], ), - const SizedBox(height: 6), - child, + tdv.IconCopyButton(data: trade.payInAmount), ], ), + const SizedBox(height: 6), + child, + ], + ), child: RichText( text: TextSpan( text: "You must send at least ${sendAmount.toStringAsFixed(trade.payInCurrency.toLowerCase() == "xmr" ? 12 : 8)} ${trade.payInCurrency.toUpperCase()}. ", - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( context, - ).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorRed, - ) - : STextStyles.label(context).copyWith( - color: - Theme.of(context) - .extension()! - .warningForeground, - ), + ).extension()!.accentColorRed, + ) + : STextStyles.label(context).copyWith( + color: Theme.of( + context, + ).extension()!.warningForeground, + ), children: [ TextSpan( text: "If you send less than ${sendAmount.toStringAsFixed(trade.payInCurrency.toLowerCase() == "xmr" ? 12 : 8)} ${trade.payInCurrency.toUpperCase()}, your transaction may not be converted and it may not be refunded.", - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .accentColorRed, - ) - : STextStyles.label(context).copyWith( - color: - Theme.of(context) - .extension()! - .warningForeground, - ), + ).extension()!.accentColorRed, + ) + : STextStyles.label(context).copyWith( + color: Theme.of( + context, + ).extension()!.warningForeground, + ), ), ], ), @@ -575,10 +539,9 @@ class _TradeDetailsViewState extends ConsumerState { isDesktop ? const _Divider() : const SizedBox(height: 12), if (sentFromStack) RoundedWhiteContainer( - padding: - isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), + padding: isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -592,10 +555,9 @@ class _TradeDetailsViewState extends ConsumerState { CustomTextButton( text: "View transaction", onTap: () { - final coin = - AppConfig.getCryptoCurrencyForTicker( - trade.payInCurrency, - )!; + final coin = AppConfig.getCryptoCurrencyForTicker( + trade.payInCurrency, + )!; if (isDesktop) { Navigator.of(context).push( @@ -634,10 +596,9 @@ class _TradeDetailsViewState extends ConsumerState { isDesktop ? const _Divider() : const SizedBox(height: 12), if (sentFromStack) RoundedWhiteContainer( - padding: - isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), + padding: isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, @@ -672,10 +633,9 @@ class _TradeDetailsViewState extends ConsumerState { isDesktop ? const _Divider() : const SizedBox(height: 12), if (!sentFromStack && !hasTx) RoundedWhiteContainer( - padding: - isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), + padding: isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -689,40 +649,39 @@ class _TradeDetailsViewState extends ConsumerState { isDesktop ? tdv.IconCopyButton(data: trade.payInAddress) : GestureDetector( - onTap: () async { - final address = trade.payInAddress; - await Clipboard.setData( - ClipboardData(text: address), - ); - if (context.mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.info, - message: "Copied to clipboard", - context: context, - ), + onTap: () async { + final address = trade.payInAddress; + await Clipboard.setData( + ClipboardData(text: address), ); - } - }, - child: Row( - children: [ - SvgPicture.asset( - Assets.svg.copy, - width: 12, - height: 12, - color: - Theme.of(context) - .extension()! - .infoItemIcons, - ), - const SizedBox(width: 4), - Text( - "Copy", - style: STextStyles.link2(context), - ), - ], + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + context: context, + ), + ); + } + }, + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.copy, + width: 12, + height: 12, + color: Theme.of( + context, + ).extension()!.infoItemIcons, + ), + const SizedBox(width: 4), + Text( + "Copy", + style: STextStyles.link2(context), + ), + ], + ), ), - ), ], ), const SizedBox(height: 4), @@ -785,14 +744,12 @@ class _TradeDetailsViewState extends ConsumerState { ), child: Text( "Cancel", - style: STextStyles.button( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.button(context) + .copyWith( + color: Theme.of(context) .extension()! .accentColorDark, - ), + ), ), ), ), @@ -809,10 +766,9 @@ class _TradeDetailsViewState extends ConsumerState { Assets.svg.qrcode, width: 12, height: 12, - color: - Theme.of( - context, - ).extension()!.infoItemIcons, + color: Theme.of( + context, + ).extension()!.infoItemIcons, ), const SizedBox(width: 4), Text( @@ -828,10 +784,9 @@ class _TradeDetailsViewState extends ConsumerState { isDesktop ? const _Divider() : const SizedBox(height: 12), if (trade.payInExtraId.isNotEmpty && !sentFromStack && !hasTx) RoundedWhiteContainer( - padding: - isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), + padding: isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -842,40 +797,39 @@ class _TradeDetailsViewState extends ConsumerState { isDesktop ? tdv.IconCopyButton(data: trade.payInExtraId) : GestureDetector( - onTap: () async { - final address = trade.payInExtraId; - await Clipboard.setData( - ClipboardData(text: address), - ); - if (context.mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.info, - message: "Copied to clipboard", - context: context, - ), + onTap: () async { + final address = trade.payInExtraId; + await Clipboard.setData( + ClipboardData(text: address), ); - } - }, - child: Row( - children: [ - SvgPicture.asset( - Assets.svg.copy, - width: 12, - height: 12, - color: - Theme.of(context) - .extension()! - .infoItemIcons, - ), - const SizedBox(width: 4), - Text( - "Copy", - style: STextStyles.link2(context), - ), - ], + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + context: context, + ), + ); + } + }, + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.copy, + width: 12, + height: 12, + color: Theme.of( + context, + ).extension()!.infoItemIcons, + ), + const SizedBox(width: 4), + Text( + "Copy", + style: STextStyles.link2(context), + ), + ], + ), ), - ), ], ), const SizedBox(height: 4), @@ -889,10 +843,9 @@ class _TradeDetailsViewState extends ConsumerState { if (trade.payInExtraId.isNotEmpty && !sentFromStack && !hasTx) isDesktop ? const _Divider() : const SizedBox(height: 12), RoundedWhiteContainer( - padding: - isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), + padding: isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -905,86 +858,6 @@ class _TradeDetailsViewState extends ConsumerState { ), isDesktop ? tdv.IconPencilButton( - onPressed: () { - showDialog( - context: context, - builder: (context) { - return DesktopDialog( - maxWidth: 580, - maxHeight: 360, - child: EditTradeNoteView( - tradeId: tradeId, - note: ref - .read(tradeNoteServiceProvider) - .getNote(tradeId: tradeId), - ), - ); - }, - ); - }, - ) - : GestureDetector( - onTap: () { - Navigator.of(context).pushNamed( - EditTradeNoteView.routeName, - arguments: Tuple2( - tradeId, - ref - .read(tradeNoteServiceProvider) - .getNote(tradeId: tradeId), - ), - ); - }, - child: Row( - children: [ - SvgPicture.asset( - Assets.svg.pencil, - width: 10, - height: 10, - color: - Theme.of( - context, - ).extension()!.infoItemIcons, - ), - const SizedBox(width: 4), - Text("Edit", style: STextStyles.link2(context)), - ], - ), - ), - ], - ), - const SizedBox(height: 4), - SelectableText( - ref.watch( - tradeNoteServiceProvider.select( - (value) => value.getNote(tradeId: tradeId), - ), - ), - style: STextStyles.itemSubtitle12(context), - ), - ], - ), - ), - if (sentFromStack) - isDesktop ? const _Divider() : const SizedBox(height: 12), - if (sentFromStack) - RoundedWhiteContainer( - padding: - isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Transaction note", - style: STextStyles.itemSubtitle(context), - ), - isDesktop - ? tdv.IconPencilButton( onPressed: () { showDialog( context: context, @@ -992,22 +865,26 @@ class _TradeDetailsViewState extends ConsumerState { return DesktopDialog( maxWidth: 580, maxHeight: 360, - child: EditNoteView( - txid: transactionIfSentFromStack!.txid, - walletId: walletId!, + child: EditTradeNoteView( + tradeId: tradeId, + note: ref + .read(tradeNoteServiceProvider) + .getNote(tradeId: tradeId), ), ); }, ); }, ) - : GestureDetector( + : GestureDetector( onTap: () { Navigator.of(context).pushNamed( - EditNoteView.routeName, + EditTradeNoteView.routeName, arguments: Tuple2( - transactionIfSentFromStack!.txid, - walletId, + tradeId, + ref + .read(tradeNoteServiceProvider) + .getNote(tradeId: tradeId), ), ); }, @@ -1017,10 +894,9 @@ class _TradeDetailsViewState extends ConsumerState { Assets.svg.pencil, width: 10, height: 10, - color: - Theme.of(context) - .extension()! - .infoItemIcons, + color: Theme.of( + context, + ).extension()!.infoItemIcons, ), const SizedBox(width: 4), Text( @@ -1030,6 +906,84 @@ class _TradeDetailsViewState extends ConsumerState { ], ), ), + ], + ), + const SizedBox(height: 4), + SelectableText( + ref.watch( + tradeNoteServiceProvider.select( + (value) => value.getNote(tradeId: tradeId), + ), + ), + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + if (sentFromStack) + isDesktop ? const _Divider() : const SizedBox(height: 12), + if (sentFromStack) + RoundedWhiteContainer( + padding: isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Transaction note", + style: STextStyles.itemSubtitle(context), + ), + isDesktop + ? tdv.IconPencilButton( + onPressed: () { + showDialog( + context: context, + builder: (context) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 360, + child: EditNoteView( + txid: + transactionIfSentFromStack!.txid, + walletId: walletId!, + ), + ); + }, + ); + }, + ) + : GestureDetector( + onTap: () { + Navigator.of(context).pushNamed( + EditNoteView.routeName, + arguments: Tuple2( + transactionIfSentFromStack!.txid, + walletId, + ), + ); + }, + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.pencil, + width: 10, + height: 10, + color: Theme.of( + context, + ).extension()!.infoItemIcons, + ), + const SizedBox(width: 4), + Text( + "Edit", + style: STextStyles.link2(context), + ), + ], + ), + ), ], ), const SizedBox(height: 4), @@ -1050,10 +1004,9 @@ class _TradeDetailsViewState extends ConsumerState { ), isDesktop ? const _Divider() : const SizedBox(height: 12), RoundedWhiteContainer( - padding: - isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), + padding: isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, @@ -1069,14 +1022,12 @@ class _TradeDetailsViewState extends ConsumerState { Format.extractDateFrom( trade.timestamp.millisecondsSinceEpoch ~/ 1000, ), - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( context, ).extension()!.textDark, - ), + ), ), ], ), @@ -1098,10 +1049,9 @@ class _TradeDetailsViewState extends ConsumerState { ), isDesktop ? const _Divider() : const SizedBox(height: 12), RoundedWhiteContainer( - padding: - isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), + padding: isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, @@ -1132,10 +1082,9 @@ class _TradeDetailsViewState extends ConsumerState { ), isDesktop ? const _Divider() : const SizedBox(height: 12), RoundedWhiteContainer( - padding: - isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), + padding: isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, @@ -1180,10 +1129,9 @@ class _TradeDetailsViewState extends ConsumerState { }, child: SvgPicture.asset( Assets.svg.copy, - color: - Theme.of( - context, - ).extension()!.infoItemIcons, + color: Theme.of( + context, + ).extension()!.infoItemIcons, width: 12, ), ), @@ -1196,10 +1144,9 @@ class _TradeDetailsViewState extends ConsumerState { isDesktop ? const _Divider() : const SizedBox(height: 12), if (trade.exchangeName != "Majestic Bank") RoundedWhiteContainer( - padding: - isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), + padding: isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1221,6 +1168,17 @@ class _TradeDetailsViewState extends ConsumerState { url = "https://nanswap.com/transaction/${trade.tradeId}"; break; + case WizardSwapExchange.exchangeName: + url = + "https://www.wizardswap.io/api/exchange/${trade.tradeId}"; + break; + case ExolixExchange.exchangeName: + url = + "https://exolix.com/transaction/${trade.tradeId}"; + break; + case LetsExchangeExchange.exchangeName: + url = "https://letsexchange.io/transaction-status"; + break; default: if (trade.exchangeName.startsWith( @@ -1228,15 +1186,18 @@ class _TradeDetailsViewState extends ConsumerState { )) { url = "https://trocador.app/en/checkout/${trade.tradeId}"; + } else if (trade.exchangeName.startsWith( + CypherGoatExchange.exchangeName, + )) { + url = trade.other ?? "error"; } } return ConditionalParent( condition: isDesktop, - builder: - (child) => MouseRegion( - cursor: SystemMouseCursors.click, - child: child, - ), + builder: (child) => MouseRegion( + cursor: SystemMouseCursors.click, + child: child, + ), child: GestureDetector( onTap: () { launchUrl( @@ -1259,10 +1220,9 @@ class _TradeDetailsViewState extends ConsumerState { onPressed: () { CryptoCurrency coin; try { - coin = - AppConfig.getCryptoCurrencyForTicker( - trade.payInCurrency, - )!; + coin = AppConfig.getCryptoCurrencyForTicker( + trade.payInCurrency, + )!; } catch (_) { coin = AppConfig.getCryptoCurrencyByPrettyName( trade.payInCurrency, diff --git a/lib/pages/finalize_view/finalize_view.dart b/lib/pages/finalize_view/finalize_view.dart index e0a3415907..65fb50a1be 100644 --- a/lib/pages/finalize_view/finalize_view.dart +++ b/lib/pages/finalize_view/finalize_view.dart @@ -80,8 +80,8 @@ class _FinalizeViewState extends ConsumerState { if (mounted) { final qrResult = await ref.read(pBarcodeScanner).scan(context: context); - if (qrResult.rawContent.isNotEmpty && qrResult.rawContent != "null") { - _slateController.text = qrResult.rawContent; + if (qrResult.rawContent != null && qrResult.rawContent!.isNotEmpty) { + _slateController.text = qrResult.rawContent!; setState(() { _slateToggleFlag = _slateController.text.isNotEmpty; }); @@ -156,14 +156,12 @@ class _FinalizeViewState extends ConsumerState { if (ex != null) { await showDialog( context: context, - builder: - (context) => StackOkDialog( - desktopPopRootNavigator: Util.isDesktop, - title: "Slatepack finalize error", - message: - ex?.toString() ?? "Unexpected result without exception", - maxWidth: Util.isDesktop ? 400 : null, - ), + builder: (context) => StackOkDialog( + desktopPopRootNavigator: Util.isDesktop, + title: "Slatepack finalize error", + message: ex?.toString() ?? "Unexpected result without exception", + maxWidth: Util.isDesktop ? 400 : null, + ), ); } else { setState(() { @@ -201,45 +199,45 @@ class _FinalizeViewState extends ConsumerState { return ConditionalParent( condition: !Util.isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton( - onPressed: () { - Navigator.of(context).pop(); - }, - ), - title: Text( - "Finalize slatepack", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - child: IntrinsicHeight( - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: Constants.size.standardPadding, - ), - child: child, - ), + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Finalize slatepack", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: Constants.size.standardPadding, ), + child: child, ), - ); - }, - ), - ), + ), + ), + ); + }, ), ), + ), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -268,61 +266,61 @@ class _FinalizeViewState extends ConsumerState { }, focusNode: _slateFocusNode, style: STextStyles.field(context), - decoration: standardInputDecoration( - "Enter Final Slatepack Message", - _slateFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, // Adjust vertical padding for better alignment - ), - suffixIcon: Padding( - padding: - _slateController.text.isEmpty + decoration: + standardInputDecoration( + "Enter Final Slatepack Message", + _slateFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: + 12, // Adjust vertical padding for better alignment + ), + suffixIcon: Padding( + padding: _slateController.text.isEmpty ? const EdgeInsets.only(right: 8) : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _slateToggleFlag - ? TextFieldIconButton( - key: const Key( - "slateFinalizeClearFieldButtonKey", + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _slateToggleFlag + ? TextFieldIconButton( + key: const Key( + "slateFinalizeClearFieldButtonKey", + ), + onTap: () { + _slateController.text = ""; + setState(() { + _slateToggleFlag = false; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "slateFinalizePasteFieldButtonKey", + ), + onTap: _pasteSlatepack, + child: _slateController.text.isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_slateController.text.isEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("sendViewScanQrButtonKey"), + onTap: _scanQr, + child: const QrCodeIcon(), ), - onTap: () { - _slateController.text = ""; - setState(() { - _slateToggleFlag = false; - }); - }, - child: const XIcon(), - ) - : TextFieldIconButton( - key: const Key( - "slateFinalizePasteFieldButtonKey", - ), - onTap: _pasteSlatepack, - child: - _slateController.text.isEmpty - ? const ClipboardIcon() - : const XIcon(), - ), - if (_slateController.text.isEmpty) - TextFieldIconButton( - semanticsLabel: - "Scan QR Button. Opens Camera For Scanning QR Code.", - key: const Key("sendViewScanQrButtonKey"), - onTap: _scanQr, - child: const QrCodeIcon(), - ), - ], + ], + ), + ), ), ), - ), - ), ), ), Util.isDesktop ? const SizedBox(height: 24) : const Spacer(), diff --git a/lib/pages/home_view/home_view.dart b/lib/pages/home_view/home_view.dart index 9c5af137cf..12867fc2f0 100644 --- a/lib/pages/home_view/home_view.dart +++ b/lib/pages/home_view/home_view.dart @@ -18,6 +18,7 @@ import 'package:flutter_svg/svg.dart'; import '../../app_config.dart'; import '../../providers/global/notifications_provider.dart'; import '../../providers/global/prefs_provider.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/ui/home_view_index_provider.dart'; import '../../providers/ui/unread_notifications_provider.dart'; import '../../route_generator.dart'; @@ -38,6 +39,8 @@ import '../../widgets/small_tor_icon.dart'; import '../../widgets/stack_dialog.dart'; import '../buy_view/buy_view.dart'; import '../exchange_view/exchange_view.dart'; +import '../more_view/gift_cards_view.dart'; +import '../more_view/services_view.dart'; import '../notification_views/notifications_view.dart'; import '../pinpad_views/lock_screen_view.dart'; import '../settings_views/global_settings_view/global_settings_view.dart'; @@ -227,6 +230,11 @@ class _HomeViewState extends ConsumerState { const ExchangeView(), if (AppConfig.hasFeature(AppFeature.buy) && Constants.enableExchange) const BuyView(), + if (AppConfig.hasFeature(AppFeature.cakePay) && Constants.enableExchange) + const GiftCardsView(), + if (AppConfig.hasFeature(AppFeature.shopinBit) && + Constants.enableExchange) + const ServicesView(), ]; ref.read(notificationsProvider).startCheckingWatchedNotifications(); @@ -339,11 +347,7 @@ class _HomeViewState extends ConsumerState { context, ).extension()!.backgroundAppBar, icon: - ref.watch( - notificationsProvider.select( - (value) => value.hasUnreadNotifications, - ), - ) + ref.watch(pAnyGlobalUnreadNotifications) ? SvgPicture.file( File( ref.watch( @@ -355,11 +359,7 @@ class _HomeViewState extends ConsumerState { width: 20, height: 20, color: - ref.watch( - notificationsProvider.select( - (value) => value.hasUnreadNotifications, - ), - ) + ref.watch(pAnyGlobalUnreadNotifications) ? null : Theme.of( context, @@ -370,11 +370,7 @@ class _HomeViewState extends ConsumerState { width: 20, height: 20, color: - ref.watch( - notificationsProvider.select( - (value) => value.hasUnreadNotifications, - ), - ) + ref.watch(pAnyGlobalUnreadNotifications) ? null : Theme.of( context, @@ -491,7 +487,7 @@ class _HomeViewState extends ConsumerState { previous, next, ) { - if (next is int && next >= 0 && next <= 2) { + if (next >= 0 && next < _children.length) { // if (next == 1) { // _exchangeDataLoadingService.loadAll(ref); // } diff --git a/lib/pages/home_view/sub_widgets/home_view_button_bar.dart b/lib/pages/home_view/sub_widgets/home_view_button_bar.dart index 9741603ac5..2fefa8c6ce 100644 --- a/lib/pages/home_view/sub_widgets/home_view_button_bar.dart +++ b/lib/pages/home_view/sub_widgets/home_view_button_bar.dart @@ -8,180 +8,319 @@ * */ +import 'dart:ui'; + import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app_config.dart'; import '../../../providers/providers.dart'; import '../../../themes/stack_colors.dart'; +import '../../../utilities/constants.dart'; import '../../../utilities/text_styles.dart'; -class HomeViewButtonBar extends ConsumerStatefulWidget { +const double _fadeWidth = 64; +const double _fadeRampDistance = _fadeWidth; +const Duration _fadeDuration = Duration(milliseconds: 200); + +enum _FadeEdge { left, right } + +class HomeViewButtonBar extends StatefulWidget { const HomeViewButtonBar({super.key}); @override - ConsumerState createState() => _HomeViewButtonBarState(); + State createState() => _HomeViewButtonBarState(); } -class _HomeViewButtonBarState extends ConsumerState { - // final DateTime _lastRefreshed = DateTime.now(); - // final Duration _refreshInterval = const Duration(hours: 1); +class _HomeViewButtonBarState extends State { + double _leftProximity = 0; + double _rightProximity = 0; - @override - void initState() { - // ref.read(exchangeFormStateProvider).setOnError( - // onError: (String message) => showDialog( - // context: context, - // barrierDismissible: true, - // builder: (_) => StackDialog( - // title: "Exchange API Call Failed", - // message: message, - // ), - // ), - // ); - super.initState(); + void _updateEdges(ScrollMetrics metrics) { + final double leftProximity = clampDouble( + metrics.extentBefore / _fadeRampDistance, + 0, + 1, + ); + final double rightProximity = clampDouble( + metrics.extentAfter / _fadeRampDistance, + 0, + 1, + ); + if (leftProximity == _leftProximity && rightProximity == _rightProximity) { + return; + } + setState(() { + _leftProximity = leftProximity; + _rightProximity = rightProximity; + }); } @override Widget build(BuildContext context) { - final selectedIndex = ref.watch(homeViewPageIndexStateProvider.state).state; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: TextButton( - style: selectedIndex == 0 - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context)! - .copyWith( - minimumSize: - MaterialStateProperty.all(const Size(46, 36)), - ) - : Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context)! - .copyWith( - minimumSize: - MaterialStateProperty.all(const Size(46, 36)), - ), - onPressed: () { - FocusScope.of(context).unfocus(); - if (selectedIndex != 0) { - ref.read(homeViewPageIndexStateProvider.state).state = 0; - } - }, - child: Text( - "Wallets", - style: STextStyles.button(context).copyWith( - fontSize: 14, - color: selectedIndex == 0 - ? Theme.of(context) - .extension()! - .buttonTextPrimary - : Theme.of(context) - .extension()! - .buttonTextSecondary, - ), + return NotificationListener( + onNotification: (notification) { + _updateEdges(notification.metrics); + return false; + }, + child: NotificationListener( + onNotification: (notification) { + _updateEdges(notification.metrics); + return false; + }, + child: Stack( + children: [ + const RepaintBoundary(child: _HomeViewButtonBarContent()), + Positioned( + left: 0, + top: 0, + bottom: 0, + child: _EdgeFadeStrip(edge: .left, proximity: _leftProximity), ), - ), + Positioned( + right: 0, + top: 0, + bottom: 0, + child: _EdgeFadeStrip(edge: .right, proximity: _rightProximity), + ), + ], ), - if (AppConfig.hasFeature(AppFeature.swap)) - const SizedBox( - width: 8, - ), - if (AppConfig.hasFeature(AppFeature.swap)) - Expanded( - child: TextButton( - style: selectedIndex == 1 - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context)! - .copyWith( - minimumSize: - MaterialStateProperty.all(const Size(46, 36)), - ) - : Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context)! - .copyWith( - minimumSize: - MaterialStateProperty.all(const Size(46, 36)), - ), - onPressed: () async { - FocusScope.of(context).unfocus(); - if (selectedIndex != 1) { - ref.read(homeViewPageIndexStateProvider.state).state = 1; - } - // DateTime now = DateTime.now(); - // if (ref.read(prefsChangeNotifierProvider).externalCalls) { - // print("loading?"); - // await ExchangeDataLoadingService().loadAll(ref); - // } - // if (now.difference(_lastRefreshed) > _refreshInterval) { - // await ExchangeDataLoadingService().loadAll(ref); - // } - }, - child: Text( - "Swap", - style: STextStyles.button(context).copyWith( - fontSize: 14, - color: selectedIndex == 1 - ? Theme.of(context) - .extension()! - .buttonTextPrimary - : Theme.of(context) - .extension()! - .buttonTextSecondary, + ), + ); + } +} + +class _EdgeFadeStrip extends StatelessWidget { + const _EdgeFadeStrip({required this.edge, required this.proximity}); + + static const int _rampSamples = 8; + + static final List _ramp = [ + for (int i = 0; i <= _rampSamples; i++) + 1 - Curves.easeInOutSine.transform(i / _rampSamples), + ]; + + final _FadeEdge edge; + final double proximity; + + @override + Widget build(BuildContext context) { + final Color background = Theme.of( + context, + ).extension()!.background; + final (Alignment begin, Alignment end) = switch (edge) { + .left => (.centerLeft, .centerRight), + .right => (.centerRight, .centerLeft), + }; + + return RepaintBoundary( + child: IgnorePointer( + child: TweenAnimationBuilder( + tween: Tween(end: proximity > 0 ? 1 : 0), + duration: _fadeDuration, + curve: Curves.easeOut, + builder: (context, timeStrength, _) { + final double strength = timeStrength * proximity; + if (strength == 0) { + return const SizedBox(width: _fadeWidth); + } + return SizedBox( + width: _fadeWidth, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: begin, + end: end, + colors: [ + for (final double factor in _ramp) + background.withValues(alpha: strength * factor), + ], + ), ), ), + ); + }, + ), + ), + ); + } +} + +class _HomeViewButtonBarContent extends StatelessWidget { + const _HomeViewButtonBarContent(); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + scrollDirection: .horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: IntrinsicWidth( + child: Row( + spacing: 8, + children: [ + const Expanded( + child: _HomeViewTopMenuButton(index: 0, label: "Wallets"), + ), + if (AppConfig.hasFeature(.swap) && Constants.enableExchange) + const Expanded( + child: _HomeViewTopMenuButton(index: 1, label: "Swap"), + ), + if (AppConfig.hasFeature(AppFeature.buy) && + Constants.enableExchange) + const Expanded( + child: _HomeViewTopMenuButton(index: 2, label: "Buy"), + ), + if (AppConfig.hasFeature(.cakePay) && Constants.enableExchange) + const Expanded( + child: _HomeViewTopMenuButton( + index: 3, + label: "Gift cards", + ), + ), + if (AppConfig.hasFeature(.shopinBit) && + Constants.enableExchange) + const Expanded( + child: _HomeViewTopMenuButton(index: 4, label: "Services"), + ), + ], ), ), - if (AppConfig.hasFeature(AppFeature.buy)) - const SizedBox( - width: 8, - ), - if (AppConfig.hasFeature(AppFeature.buy)) - Expanded( - child: TextButton( - style: selectedIndex == 2 - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context)! - .copyWith( - minimumSize: - MaterialStateProperty.all(const Size(46, 36)), - ) - : Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context)! - .copyWith( - minimumSize: - MaterialStateProperty.all(const Size(46, 36)), - ), - onPressed: () async { - FocusScope.of(context).unfocus(); - if (selectedIndex != 2) { - ref.read(homeViewPageIndexStateProvider.state).state = 2; - } - // await BuyDataLoadingService().loadAll(ref); - }, - child: Text( - "Buy", - style: STextStyles.button(context).copyWith( - fontSize: 14, - color: selectedIndex == 2 - ? Theme.of(context) - .extension()! - .buttonTextPrimary - : Theme.of(context) - .extension()! - .buttonTextSecondary, + ), + ), + ); + } +} + +class _HomeViewTopMenuButton extends ConsumerStatefulWidget { + const _HomeViewTopMenuButton({ + super.key, + required this.index, + required this.label, + }); + + final int index; + final String label; + + @override + ConsumerState<_HomeViewTopMenuButton> createState() => + _HomeViewTopMenuButtonState(); +} + +class _HomeViewTopMenuButtonState + extends ConsumerState<_HomeViewTopMenuButton> { + static const Duration _revealDuration = Duration(milliseconds: 250); + + void _scheduleReveal({required bool animate}) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _revealIfCovered(animate: animate); + } + }); + } + + void _revealIfCovered({required bool animate}) { + final RenderObject? renderObject = context.findRenderObject(); + final ScrollableState? scrollable = Scrollable.maybeOf( + context, + axis: .horizontal, + ); + if (renderObject == null || !renderObject.attached || scrollable == null) { + return; + } + final RenderAbstractViewport? viewport = RenderAbstractViewport.maybeOf( + renderObject, + ); + final ScrollPosition position = scrollable.position; + if (viewport == null || !position.hasContentDimensions) { + return; + } + + // The window of offsets that keeps this button _fadeWidth clear + // of both viewport edges. + final double lower = + viewport.getOffsetToReveal(renderObject, 1).offset + _fadeWidth; + final double upper = + viewport.getOffsetToReveal(renderObject, 0).offset - _fadeWidth; + + double target = upper < lower + ? viewport.getOffsetToReveal(renderObject, 0.5).offset + : clampDouble(position.pixels, lower, upper); + target = clampDouble( + target, + position.minScrollExtent, + position.maxScrollExtent, + ); + + if ((target - position.pixels).abs() < 1) { + return; + } + + if (animate) { + position.animateTo( + target, + duration: _revealDuration, + curve: Curves.easeOutCubic, + ); + } else { + position.jumpTo(target); + } + } + + @override + void initState() { + super.initState(); + if (ref.read(homeViewPageIndexStateProvider) == widget.index) { + _scheduleReveal(animate: false); + } + } + + @override + Widget build(BuildContext context) { + final bool isSelected = ref.watch( + homeViewPageIndexStateProvider.select((index) => index == widget.index), + ); + + ref.listen( + homeViewPageIndexStateProvider.select((index) => index == widget.index), + (previous, next) { + if (next) { + _scheduleReveal(animate: true); + } + }, + ); + + final StackColors colors = Theme.of(context).extension()!; + return TextButton( + style: + (isSelected + ? colors.getPrimaryEnabledButtonStyle(context)! + : colors.getSecondaryEnabledButtonStyle(context)!) + .copyWith( + minimumSize: MaterialStateProperty.all( + const Size(46, 36), ), ), - ), + onPressed: () { + FocusScope.of(context).unfocus(); + if (!isSelected) { + ref.read(homeViewPageIndexStateProvider.state).state = widget.index; + } + }, + child: Padding( + padding: const .symmetric(horizontal: 8), + child: Text( + widget.label, + style: STextStyles.button(context).copyWith( + fontSize: 14, + color: isSelected + ? colors.buttonTextPrimary + : colors.buttonTextSecondary, ), - ], + ), + ), ); } } diff --git a/lib/pages/masternodes/create_masternode_view.dart b/lib/pages/masternodes/create_masternode_view.dart new file mode 100644 index 0000000000..3692724966 --- /dev/null +++ b/lib/pages/masternodes/create_masternode_view.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../themes/stack_colors.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import 'sub_widgets/register_masternode_form.dart'; + +class CreateMasternodeView extends ConsumerStatefulWidget { + const CreateMasternodeView({ + super.key, + required this.firoWalletId, + required this.collateralTxid, + required this.collateralVout, + required this.collateralAddress, + this.popTxidOnSuccess = true, + }); + + static const routeName = "/createMasternodeView"; + + final String firoWalletId; + final String collateralTxid; + final int collateralVout; + final String collateralAddress; + final bool popTxidOnSuccess; + + @override + ConsumerState createState() => + _CreateMasternodeDialogState(); +} + +class _CreateMasternodeDialogState extends ConsumerState { + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => SizedBox( + width: 660, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: .spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Create masternode", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.only(left: 32, bottom: 32, right: 32), + child: child, + ), + ), + ], + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.background, + leading: AppBarBackButton( + onPressed: () async { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Create masternode", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: child, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: RegisterMasternodeForm( + firoWalletId: widget.firoWalletId, + collateralTxid: widget.collateralTxid, + collateralVout: widget.collateralVout, + collateralAddress: widget.collateralAddress, + onRegistrationSuccess: (txid) { + if (widget.popTxidOnSuccess && mounted) { + Navigator.of(context, rootNavigator: Util.isDesktop).pop(txid); + } + }, + ), + ), + ); + } +} diff --git a/lib/pages/masternodes/masternode_details_view.dart b/lib/pages/masternodes/masternode_details_view.dart new file mode 100644 index 0000000000..ebc3ea2d48 --- /dev/null +++ b/lib/pages/masternodes/masternode_details_view.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import '../../themes/stack_colors.dart'; +import '../../utilities/text_styles.dart'; +import '../../wallets/wallet/impl/firo_wallet.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import 'sub_widgets/masternode_info_widget.dart'; + +class MasternodeDetailsView extends StatelessWidget { + const MasternodeDetailsView({super.key, required this.node}); + + static const String routeName = "/masternodeDetailsView"; + + final MasternodeInfo node; + + @override + Widget build(BuildContext context) { + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: const AppBarBackButton(), + title: Text( + "Masternode details", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + mainAxisSize: .min, + children: [ + MasternodeInfoWidget(info: node), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/masternodes/masternodes_home_view.dart b/lib/pages/masternodes/masternodes_home_view.dart new file mode 100644 index 0000000000..f6dd1caeeb --- /dev/null +++ b/lib/pages/masternodes/masternodes_home_view.dart @@ -0,0 +1,813 @@ +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:isar_community/isar.dart'; +import 'package:tuple/tuple.dart'; + +import '../../models/isar/models/blockchain_data/utxo.dart'; +import '../../models/send_view_auto_fill_data.dart'; +import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart'; +import '../../providers/global/wallets_provider.dart'; +import '../../providers/wallet/public_private_balance_state_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/show_loading.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/isar/models/wallet_info.dart'; +import '../../wallets/wallet/impl/firo_wallet.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_app_bar.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/desktop_scaffold.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/loading_indicator.dart'; +import '../../widgets/stack_dialog.dart'; +import '../send_view/send_view.dart'; +import 'create_masternode_view.dart'; +import 'sub_widgets/masternodes_list.dart'; + +class MasternodesHomeView extends ConsumerStatefulWidget { + const MasternodesHomeView({super.key, required this.walletId}); + + final String walletId; + + static const String routeName = "/masternodesHomeView"; + + @override + ConsumerState createState() => + _MasternodesHomeViewState(); +} + +class _MasternodesHomeViewState extends ConsumerState { + static final BigInt _masternodeCollateralRaw = Amount.fromDecimal( + kMasterNodeValue, + fractionDigits: 8, + ).raw; + + late Future> _masternodesFuture; + bool _hasPromptedForCollateral = false; + bool _isCheckingForCollateral = false; + + Set _dismissedCollateral(FiroWallet wallet) { + final raw = + wallet.info.otherData[WalletInfoKeys.firoMasternodeCollateralDismissed]; + if (raw is! List) { + return {}; + } + return raw.whereType().toSet(); + } + + Future _persistDismissedCollateral( + FiroWallet wallet, + String txid, + int vout, + ) async { + final set = _dismissedCollateral(wallet); + set.add("$txid:$vout"); + await wallet.info.updateOtherData( + newEntries: { + WalletInfoKeys.firoMasternodeCollateralDismissed: set.toList(), + }, + isar: wallet.mainDB.isar, + ); + } + + Future> _registeredCollateral() async { + try { + return (await _masternodesFuture) + .map((e) => "${e.collateralHash}:${e.collateralIndex}") + .toSet(); + } catch (_) { + return {}; + } + } + + Future<({String txid, int vout, String address})?> + _findCollateralUtxo() async { + final wallet = ref.read(pWallets).getWallet(widget.walletId) as FiroWallet; + final List utxos = await wallet.mainDB + .getUTXOs(widget.walletId) + .findAll(); + final currentChainHeight = await wallet.chainHeight; + final registered = await _registeredCollateral(); + final masternodeRaw = Amount.fromDecimal( + kMasterNodeValue, + fractionDigits: wallet.cryptoCurrency.fractionDigits, + ).raw.toInt(); + + for (final utxo in utxos) { + if (utxo.value == masternodeRaw && + !utxo.isBlocked && + utxo.used != true && + !registered.contains("${utxo.txid}:${utxo.vout}") && + utxo.isConfirmed( + currentChainHeight, + wallet.cryptoCurrency.minConfirms, + wallet.cryptoCurrency.minCoinbaseConfirms, + ) && + utxo.address != null) { + return (txid: utxo.txid, vout: utxo.vout, address: utxo.address!); + } + } + return null; + } + + Future< + ({String txid, int vout, String address, int confirmations, int required})? + > + _findPendingCollateralUtxo() async { + final wallet = ref.read(pWallets).getWallet(widget.walletId) as FiroWallet; + final List utxos = await wallet.mainDB + .getUTXOs(widget.walletId) + .findAll(); + final currentChainHeight = await wallet.chainHeight; + final requiredConfirms = wallet.cryptoCurrency.minConfirms; + final masternodeRaw = Amount.fromDecimal( + kMasterNodeValue, + fractionDigits: wallet.cryptoCurrency.fractionDigits, + ).raw.toInt(); + + ({String txid, int vout, String address, int confirmations, int required})? + bestPending; + + for (final utxo in utxos) { + if (utxo.value != masternodeRaw || + utxo.isBlocked || + utxo.used == true || + utxo.address == null) { + continue; + } + + final confirmations = utxo.getConfirmations(currentChainHeight); + final isConfirmed = utxo.isConfirmed( + currentChainHeight, + wallet.cryptoCurrency.minConfirms, + wallet.cryptoCurrency.minCoinbaseConfirms, + ); + + if (isConfirmed) { + continue; + } + + final candidate = ( + txid: utxo.txid, + vout: utxo.vout, + address: utxo.address!, + confirmations: confirmations, + required: requiredConfirms, + ); + + if (bestPending == null || + candidate.confirmations > bestPending.confirmations) { + bestPending = candidate; + } + } + + return bestPending; + } + + bool _createMasternodeLock = false; + Future _createMasternode() async { + if (_createMasternodeLock) return; + _createMasternodeLock = true; + + try { + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as FiroWallet; + final collateral = await showLoading( + whileFuture: _findCollateralUtxo(), + rootNavigator: Util.isDesktop, + context: context, + message: "Checking for collateral UTXO...", + delay: const Duration(seconds: 1), + ); + if (!mounted) { + return; + } + + if (collateral == null) { + final pendingCollateral = await showLoading( + whileFuture: _findPendingCollateralUtxo(), + rootNavigator: Util.isDesktop, + context: context, + message: "Checking for pending collateral UTXO...", + delay: const Duration(seconds: 1), + ); + if (!mounted) { + return; + } + if (pendingCollateral != null) { + const message = + "Your 1000 FIRO collateral is on its way.\n\n" + "Waiting for confirmations...\n" + "Once confirmed, click Create Masternode again to continue."; + await showDialog( + context: context, + builder: (ctx) => StackOkDialog( + title: "Waiting for collateral confirmation", + message: message, + desktopPopRootNavigator: Util.isDesktop, + maxWidth: Util.isDesktop ? 420 : null, + ), + ); + return; + } + + final spendableBalance = wallet.info.cachedBalance.spendable.raw; + final sparkBalance = wallet.info.cachedBalanceTertiary.spendable.raw; + + Amount estimatedConsolidationFee; + try { + final feeObject = await wallet.fees; + final collateralAmount = Amount( + rawValue: _masternodeCollateralRaw, + fractionDigits: wallet.cryptoCurrency.fractionDigits, + ); + estimatedConsolidationFee = await wallet.estimateFeeFor( + collateralAmount, + feeObject.medium, + ); + } catch (_) { + estimatedConsolidationFee = wallet.roughFeeEstimate( + 10, + 2, + BigInt.from(100000), + ); + } + if (!mounted) return; + + if (spendableBalance >= _masternodeCollateralRaw && + spendableBalance < + _masternodeCollateralRaw + estimatedConsolidationFee.raw) { + final feeDecimal = estimatedConsolidationFee.decimal; + + final feeBuffer = Amount.fromDecimal( + Decimal.parse("0.00001"), + fractionDigits: wallet.cryptoCurrency.fractionDigits, + ); + final desiredOnTransparent = estimatedConsolidationFee + feeBuffer; + + Amount sparkFeeEstimate; + try { + sparkFeeEstimate = await wallet.estimateFeeForSpark( + desiredOnTransparent, + ); + } catch (_) { + sparkFeeEstimate = estimatedConsolidationFee; + } + if (!mounted) return; + + final requiredFromSpark = desiredOnTransparent + sparkFeeEstimate; + final canUnshieldFromSpark = sparkBalance >= requiredFromSpark.raw; + + if (canUnshieldFromSpark) { + final unshieldDecimal = requiredFromSpark.decimal; + final shouldOpenSend = await showDialog( + context: context, + builder: (ctx) => _OpenSendDialog( + title: "Unshield FIRO to cover consolidation fee?", + message: + "You have exactly 1000 FIRO on your transparent balance, " + "but a network fee of $feeDecimal FIRO is needed to " + "consolidate it into a single 1000 FIRO collateral UTXO.\n\n" + "Your private Spark balance has enough to cover this fee. " + "Do you want to unshield $unshieldDecimal FIRO from your " + "private Spark balance to your transparent balance? Once " + "this transaction is confirmed, click \"Create Masternode\" " + "again to continue to the next step.", + ), + ); + if (shouldOpenSend == true && mounted) { + await _openCreateCollateralSendFlow( + wallet, + fromPrivate: true, + unshieldAmount: unshieldDecimal, + ); + } + return; + } + + await showDialog( + context: context, + builder: (ctx) => StackOkDialog( + title: "Insufficient balance for consolidation fee", + message: + "You have exactly 1000 FIRO, but a network fee of " + "$feeDecimal FIRO is needed to consolidate your balance " + "into a single 1000 FIRO collateral UTXO.\n\n" + "Please add at least $feeDecimal FIRO to your wallet, " + "then click Create Masternode again.", + desktopPopRootNavigator: Util.isDesktop, + maxWidth: Util.isDesktop ? 420 : null, + ), + ); + return; + } + + if (spendableBalance < _masternodeCollateralRaw) { + final totalBalance = spendableBalance + sparkBalance; + if (totalBalance >= _masternodeCollateralRaw) { + // User has enough combined (public + Spark) — offer to unshield + // only the deficit needed to reach 1000 on transparent. + final deficitRaw = _masternodeCollateralRaw - spendableBalance; + final deficitDecimal = Amount( + rawValue: deficitRaw, + fractionDigits: wallet.cryptoCurrency.fractionDigits, + ).decimal; + + final shouldOpenSend = await showDialog( + context: context, + builder: (_) => _OpenSendDialog( + title: "Unshield FIRO for masternode collateral?", + message: + "Masternode collateral must be a single 1000 FIRO UTXO " + "in your transparent balance. You will need to unshield " + "part of your Spark private balance into your transparent " + "balance to create this collateral along with the " + "transaction fee required to register it.\n\n" + "Do you want to unshield $deficitDecimal FIRO from your " + "private Spark balance to your transparent balance? Once " + "this transaction is confirmed, click \"Create Masternode\" " + "again to continue to the next step.\n\n" + "Note: there may be an additional step to consolidate your " + "transparent balance into a single UTXO before allowing " + "you to register your masternode.", + ), + ); + if (shouldOpenSend == true && mounted) { + await _openCreateCollateralSendFlow( + wallet, + fromPrivate: true, + unshieldAmount: deficitDecimal, + ); + } + } else { + await showDialog( + context: context, + builder: (ctx) => StackOkDialog( + title: "Not enough FIRO to create the collateral", + message: + "A masternode collateral is exactly 1000 FIRO on your " + "transparent balance, plus a " + "small network fee to send it. Your total balance is " + "below this amount.\n\n" + "Add more FIRO to your wallet, then click Create " + "Masternode again to continue.", + desktopPopRootNavigator: Util.isDesktop, + maxWidth: Util.isDesktop ? 420 : null, + ), + ); + } + return; + } + + final shouldOpenSend = await showDialog( + context: context, + builder: (ctx) => const _OpenSendDialog( + title: "Set up your 1000 FIRO masternode collateral?", + message: + "Registering a masternode requires a 1000 FIRO collateral: " + "a single confirmed amount sitting in your wallet. We didn't " + "find one, but you have enough FIRO to create it.\n\n" + "We can help by opening the Send window with a new address " + "you own pre-filled, ready for you to send 1000 FIRO to it. " + "This consolidates your smaller amounts into the single 1000 " + "FIRO collateral you need. The network fee is paid from your " + "remaining balance.\n\n" + "Once you have sent it, wait for the transaction to confirm, " + "then click Create Masternode again to continue.", + ), + ); + if (shouldOpenSend == true && mounted) { + await _openCreateCollateralSendFlow(wallet); + } + return; + } + + await _openCreateMasternode(collateral); + } finally { + _createMasternodeLock = false; + } + } + + Future _openCreateMasternode( + ({String txid, int vout, String address}) collateral, + ) async { + final Object? txid; + if (Util.isDesktop) { + txid = await showDialog( + context: context, + barrierDismissible: true, + builder: (context) => SDialog( + child: CreateMasternodeView( + firoWalletId: widget.walletId, + collateralTxid: collateral.txid, + collateralVout: collateral.vout, + collateralAddress: collateral.address, + ), + ), + ); + } else { + txid = await Navigator.of(context).pushNamed( + CreateMasternodeView.routeName, + arguments: { + 'walletId': widget.walletId, + 'collateralTxid': collateral.txid, + 'collateralVout': collateral.vout, + 'collateralAddress': collateral.address, + }, + ); + } + _handleSuccessTxid(txid); + } + + Future _openCreateCollateralSendFlow( + FiroWallet wallet, { + bool fromPrivate = false, + Decimal? unshieldAmount, + }) async { + var selfAddress = await wallet.getCurrentReceivingAddress(); + if (selfAddress == null) { + await wallet.generateNewReceivingAddress(); + selfAddress = await wallet.getCurrentReceivingAddress(); + } + if (!mounted || selfAddress == null) { + return; + } + + ref.read(publicPrivateBalanceStateProvider.state).state = fromPrivate + ? BalanceType.private + : BalanceType.public; + + final ticker = wallet.cryptoCurrency.ticker; + final autoFillData = SendViewAutoFillData( + address: selfAddress.value, + contactLabel: selfAddress.value, + amount: fromPrivate + ? (unshieldAmount ?? kMasterNodeValue) + : kMasterNodeValue, + ); + + if (Util.isDesktop) { + await showDialog( + context: context, + builder: (context) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + children: [ + Row( + mainAxisAlignment: .spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Send $ticker", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: DesktopSend( + walletId: widget.walletId, + autoFillData: autoFillData, + ), + ), + ], + ), + ), + ); + } else { + await Navigator.of(context).pushNamed( + SendView.routeName, + arguments: Tuple3(widget.walletId, wallet.cryptoCurrency, autoFillData), + ); + } + } + + Future _maybePromptForExistingCollateral() async { + if (_hasPromptedForCollateral || _isCheckingForCollateral || !mounted) { + return; + } + _isCheckingForCollateral = true; + + try { + final collateral = await _findCollateralUtxo(); + if (collateral == null || !mounted) { + return; + } + + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as FiroWallet; + final dismissed = _dismissedCollateral(wallet); + final collateralKey = "${collateral.txid}:${collateral.vout}"; + if (dismissed.contains(collateralKey)) { + return; + } + + _hasPromptedForCollateral = true; + + final wantsMN = await showDialog( + context: context, + barrierDismissible: true, + builder: (ctx) => StackDialog( + title: "Register Masternode?", + message: + "A 1000 FIRO collateral UTXO was found in your wallet. " + "Would you like to register a masternode now?", + width: Util.isDesktop ? 580 : null, + padding: .all(Util.isDesktop ? 32 : 24), + leftButton: TextButton( + style: Theme.of( + ctx, + ).extension()!.getSecondaryEnabledButtonStyle(ctx), + child: Text( + "Later", + style: STextStyles.button(ctx).copyWith( + color: Theme.of(ctx).extension()!.accentColorDark, + ), + ), + onPressed: () => Navigator.of(ctx).pop(false), + ), + rightButton: TextButton( + style: Theme.of( + ctx, + ).extension()!.getPrimaryEnabledButtonStyle(ctx), + child: Text("Register", style: STextStyles.button(ctx)), + onPressed: () => Navigator.of(ctx).pop(true), + ), + ), + ); + + if (wantsMN != true) { + await _persistDismissedCollateral( + wallet, + collateral.txid, + collateral.vout, + ); + return; + } + + if (!mounted) { + return; + } + + await _openCreateMasternode(collateral); + } finally { + _isCheckingForCollateral = false; + } + } + + Future> _fetchMasternodes() => + (ref.read(pWallets).getWallet(widget.walletId) as FiroWallet) + .getMyMasternodes(); + + void _handleSuccessTxid(Object? txid) { + Logging.instance.i( + "$runtimeType _handleSuccessTxid($txid) called where mounted=$mounted", + ); + if (mounted && txid is String) { + setState(() { + _masternodesFuture = _fetchMasternodes(); + }); + + unawaited( + showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Masternode Registration Submitted", + message: + "Masternode registration submitted, your masternode will " + "appear in the list after the tx is confirmed.\n\nTransaction" + " ID: $txid", + desktopPopRootNavigator: Util.isDesktop, + maxWidth: Util.isDesktop ? 400 : null, + ), + ), + ); + } + } + + @override + void initState() { + super.initState(); + + _masternodesFuture = _fetchMasternodes(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_maybePromptForExistingCollateral()); + }); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + return MasterScaffold( + isDesktop: isDesktop, + appBar: isDesktop + ? DesktopAppBar( + isCompactHeight: true, + background: Theme.of(context).extension()!.popupBG, + leading: Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 24, right: 20), + child: AppBarIconButton( + size: 32, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + shadows: const [], + icon: SvgPicture.asset( + Assets.svg.arrowLeft, + width: 18, + height: 18, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.topNavIconPrimary, + BlendMode.srcIn, + ), + ), + onPressed: Navigator.of(context).pop, + ), + ), + SvgPicture.asset( + Assets.svg.robotHead, + width: 32, + height: 32, + colorFilter: ColorFilter.mode( + Theme.of(context).extension()!.textDark, + BlendMode.srcIn, + ), + ), + const SizedBox(width: 10), + Text("Masternodes", style: STextStyles.desktopH3(context)), + ], + ), + trailing: Padding( + padding: const EdgeInsets.only(right: 24), + child: PrimaryButton( + label: "Create Masternode", + buttonHeight: .l, + horizontalContentPadding: 10, + icon: SvgPicture.asset( + Assets.svg.circlePlus, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.buttonTextPrimary, + .srcIn, + ), + ), + onPressed: _createMasternode, + ), + ), + ) + : AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + titleSpacing: 0, + title: Text( + "Masternodes", + style: STextStyles.navBarTitle(context), + overflow: TextOverflow.ellipsis, + ), + actions: [ + Padding( + padding: const EdgeInsets.only( + top: 10, + bottom: 10, + right: 10, + ), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + key: const Key("createNewMasterNodeButton"), + size: 36, + shadows: const [], + color: Theme.of( + context, + ).extension()!.background, + icon: SvgPicture.asset( + Assets.svg.plus, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.accentColorDark, + .srcIn, + ), + width: 20, + height: 20, + ), + onPressed: _createMasternode, + ), + ), + ), + ], + ), + body: FutureBuilder>( + future: _masternodesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: LoadingIndicator(height: 50, width: 50)); + } + if (snapshot.hasError) { + return _CenteredMessage( + message: "Failed to load masternodes", + buttonLabel: "Retry", + onPressed: () => + setState(() => _masternodesFuture = _fetchMasternodes()), + ); + } + final nodes = snapshot.data ?? const []; + if (nodes.isEmpty) { + return _CenteredMessage( + message: "No masternodes found", + buttonLabel: "Create Your First Masternode", + onPressed: _createMasternode, + ); + } + + return MasternodesList(nodes: nodes); + }, + ), + ); + } +} + +class _CenteredMessage extends StatelessWidget { + const _CenteredMessage({ + required this.message, + required this.buttonLabel, + required this.onPressed, + }); + + final String message, buttonLabel; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: .center, + children: [ + Text(message, style: STextStyles.w600_14(context)), + const SizedBox(height: 24), + Row( + mainAxisSize: .min, + mainAxisAlignment: .center, + children: [ + PrimaryButton( + label: buttonLabel, + horizontalContentPadding: 16, + buttonHeight: Util.isDesktop ? .l : null, + onPressed: onPressed, + ), + ], + ), + ], + ), + ); + } +} + +class _OpenSendDialog extends StatelessWidget { + const _OpenSendDialog({required this.title, required this.message}); + + final String title, message; + + @override + Widget build(BuildContext context) { + return StackDialog( + title: title, + message: message, + width: Util.isDesktop ? 580 : null, + padding: .all(Util.isDesktop ? 32 : 24), + leftButton: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + buttonHeight: Util.isDesktop ? .l : null, + ), + rightButton: PrimaryButton( + label: "Open Send", + onPressed: () => Navigator.of(context).pop(true), + buttonHeight: Util.isDesktop ? .l : null, + ), + ); + } +} diff --git a/lib/pages/masternodes/sub_widgets/masternode_info_widget.dart b/lib/pages/masternodes/sub_widgets/masternode_info_widget.dart new file mode 100644 index 0000000000..1838476c89 --- /dev/null +++ b/lib/pages/masternodes/sub_widgets/masternode_info_widget.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/stack_colors.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/wallet/impl/firo_wallet.dart'; +import '../../../widgets/conditional_parent.dart'; +import '../../../widgets/detail_item.dart'; +import '../../../widgets/rounded_white_container.dart'; + +class MasternodeInfoWidget extends StatelessWidget { + const MasternodeInfoWidget({super.key, required this.info}); + + final MasternodeInfo info; + + @override + Widget build(BuildContext context) { + final map = info.pretty(); + final keys = map.keys.toList(growable: false); + + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => Padding( + padding: const EdgeInsets.only(left: 32, bottom: 32, right: 32), + child: RoundedWhiteContainer( + padding: .zero, + borderColor: Theme.of( + context, + ).extension()!.backgroundAppBar, + child: child, + ), + ), + child: Column( + mainAxisSize: .min, + children: [ + for (int i = 0; i < keys.length; i++) + Builder( + builder: (context) { + final title = keys[i]; + final detail = map[title]!; + + return Column( + mainAxisSize: .min, + children: [ + if (i > 0) const DetailDivider(), + DetailItem( + title: title, + detail: detail, + horizontal: detail.length < 22, + ), + ], + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/pages/masternodes/sub_widgets/masternodes_list.dart b/lib/pages/masternodes/sub_widgets/masternodes_list.dart new file mode 100644 index 0000000000..45446b8e15 --- /dev/null +++ b/lib/pages/masternodes/sub_widgets/masternodes_list.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/stack_colors.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/wallet/impl/firo_wallet.dart'; +import '../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../widgets/dialogs/s_dialog.dart'; +import '../../../widgets/rounded_white_container.dart'; +import '../masternode_details_view.dart'; +import 'masternode_info_widget.dart'; + +class MasternodesList extends StatelessWidget { + const MasternodesList({super.key, required this.nodes}); + + final List nodes; + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: EdgeInsets.all(Util.isDesktop ? 24 : 16), + itemCount: nodes.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (_, index) => _MasternodeCard(node: nodes[index]), + ); + } +} + +class _MasternodeCard extends StatelessWidget { + const _MasternodeCard({required this.node}); + + final MasternodeInfo node; + + Future _showDetails(BuildContext context) async { + if (Util.isDesktop) { + await showDialog( + context: context, + barrierDismissible: true, + builder: (context) => SDialog( + contentCanScroll: false, + child: SizedBox( + width: 600, + child: Column( + crossAxisAlignment: .stretch, + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: .spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Masternode details", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: SingleChildScrollView( + child: MasternodeInfoWidget(info: node), + ), + ), + ], + ), + ), + ), + ); + } else { + await Navigator.of( + context, + ).pushNamed(MasternodeDetailsView.routeName, arguments: node); + } + } + + @override + Widget build(BuildContext context) { + final stack = Theme.of(context).extension()!; + final isActive = node.revocationReason == 0; + + return RoundedWhiteContainer( + padding: const EdgeInsets.all(16), + onPressed: () => _showDetails(context), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: .start, + mainAxisSize: .min, + children: [ + Text( + "${node.serviceAddr}:${node.servicePort}", + style: STextStyles.titleBold12(context), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + "Last paid height: ${node.lastPaidHeight}", + style: STextStyles.baseXS( + context, + ).copyWith(color: stack.textSubtitle1), + ), + ], + ), + ), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isActive ? stack.accentColorGreen : stack.accentColorRed, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + isActive ? "ACTIVE" : "REVOKED", + style: STextStyles.w600_12( + context, + ).copyWith(color: stack.textWhite), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/masternodes/sub_widgets/register_masternode_form.dart b/lib/pages/masternodes/sub_widgets/register_masternode_form.dart new file mode 100644 index 0000000000..6bd79d17a2 --- /dev/null +++ b/lib/pages/masternodes/sub_widgets/register_masternode_form.dart @@ -0,0 +1,300 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/global/wallets_provider.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/if_not_already.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/show_loading.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/wallet/impl/firo_wallet.dart'; +import '../../../widgets/conditional_parent.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/rounded_container.dart'; +import '../../../widgets/stack_dialog.dart'; +import '../../../widgets/textfields/adaptive_text_field.dart'; + +class RegisterMasternodeForm extends ConsumerStatefulWidget { + const RegisterMasternodeForm({ + super.key, + required this.firoWalletId, + required this.collateralTxid, + required this.collateralVout, + required this.collateralAddress, + required this.onRegistrationSuccess, + }); + + final String firoWalletId; + final String collateralTxid; + final int collateralVout; + final String collateralAddress; + + final void Function(String) onRegistrationSuccess; + + @override + ConsumerState createState() => + _RegisterMasternodeFormState(); +} + +class _RegisterMasternodeFormState + extends ConsumerState { + final _ipAndPortController = TextEditingController(); + final _operatorPubKeyController = TextEditingController(); + final _votingAddressController = TextEditingController(); + final _operatorRewardController = TextEditingController(text: "0"); + final _payoutAddressController = TextEditingController(); + + TextStyle _getStyle(BuildContext context) { + return Util.isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ) + : STextStyles.smallMed12(context); + } + + late final VoidCallback _register; + + bool _enableCreateButton = false; + + void _validate() { + if (mounted) { + final percent = double.tryParse(_operatorRewardController.text); + setState(() { + _enableCreateButton = [ + _ipAndPortController.text + .trim() + .split(":") + .where((e) => e.isNotEmpty) + .length == + 2, + _operatorPubKeyController.text.trim().isNotEmpty, + percent != null && !percent.isNegative, + percent != null && percent <= 100.0, + _payoutAddressController.text.trim().isNotEmpty, + ].every((e) => e); + }); + } + } + + Future _registerMasternode() async { + final parts = _ipAndPortController.text.trim().split(':'); + final ip = parts[0]; + final port = int.parse(parts[1]); + final operatorPubKey = _operatorPubKeyController.text.trim(); + final votingAddress = _votingAddressController.text.trim(); + final payoutAddress = _payoutAddressController.text.trim(); + + // 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 wallet = + ref.read(pWallets).getWallet(widget.firoWalletId) as FiroWallet; + + final txId = await wallet.registerMasternode( + ip, + port, + operatorPubKey, + votingAddress, + operatorReward, + payoutAddress, + collateralTxid: widget.collateralTxid, + collateralVout: widget.collateralVout, + collateralAddress: widget.collateralAddress, + ); + + Logging.instance.i('Masternode registration submitted: $txId'); + + return txId; + } + + @override + void initState() { + super.initState(); + + _register = IfNotAlreadyAsync(() async { + Exception? ex; + + final txId = await showLoading( + whileFutureAlt: _registerMasternode, + context: context, + rootNavigator: Util.isDesktop, + message: "Creating and submitting masternode registration...", + delay: const Duration(seconds: 1), + onException: (e) => ex = e, + ); + + if (mounted) { + if (ex != null || txId == null) { + String message = ex?.toString().trim() ?? "Unknown error: txId=$txId"; + const exceptionPrefix = "Exception:"; + while (message.startsWith(exceptionPrefix) && + message.length > exceptionPrefix.length) { + message = message.substring(exceptionPrefix.length).trim(); + } + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Registration failed", + message: message, + desktopPopRootNavigator: Util.isDesktop, + maxWidth: Util.isDesktop ? 400 : null, + ), + ); + } else { + widget.onRegistrationSuccess.call(txId); + } + } + }).execute; + } + + @override + void dispose() { + _ipAndPortController.dispose(); + _operatorPubKeyController.dispose(); + _votingAddressController.dispose(); + _operatorRewardController.dispose(); + _payoutAddressController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final stack = Theme.of(context).extension()!; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: RoundedContainer( + color: stack.textFieldDefaultBG, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Masternode collateral", + 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), + ), + const SizedBox(height: 4), + SelectableText( + "${widget.collateralTxid}:${widget.collateralVout}", + style: STextStyles.w500_12( + context, + ).copyWith(color: stack.textSubtitle1), + ), + ], + ), + ), + ), + ), + ], + ), + + SizedBox(height: Util.isDesktop ? 24 : 16), + + SelectableText("IP:Port", style: _getStyle(context)), + SizedBox(height: Util.isDesktop ? 10 : 8), + AdaptiveTextField( + controller: _ipAndPortController, + showPasteClearButton: true, + maxLines: 1, + onChangedComprehensive: (_) => _validate(), + ), + SizedBox(height: Util.isDesktop ? 24 : 16), + + SelectableText("Operator public key (BLS)", style: _getStyle(context)), + SizedBox(height: Util.isDesktop ? 10 : 8), + AdaptiveTextField( + controller: _operatorPubKeyController, + showPasteClearButton: true, + maxLines: 1, + onChangedComprehensive: (_) => _validate(), + ), + SizedBox(height: Util.isDesktop ? 24 : 16), + + SelectableText("Voting address (optional)", style: _getStyle(context)), + SizedBox(height: Util.isDesktop ? 10 : 8), + AdaptiveTextField( + controller: _votingAddressController, + showPasteClearButton: true, + maxLines: 1, + labelText: "Defaults to owner address", + onChangedComprehensive: (_) => _validate(), + ), + SizedBox(height: Util.isDesktop ? 24 : 16), + + SelectableText("Operator reward (%)", style: _getStyle(context)), + SizedBox(height: Util.isDesktop ? 10 : 8), + AdaptiveTextField( + controller: _operatorRewardController, + showPasteClearButton: true, + maxLines: 1, + onChangedComprehensive: (_) => _validate(), + ), + SizedBox(height: Util.isDesktop ? 24 : 16), + + SelectableText("Payout address", style: _getStyle(context)), + SizedBox(height: Util.isDesktop ? 10 : 8), + AdaptiveTextField( + controller: _payoutAddressController, + showPasteClearButton: true, + maxLines: 1, + onChangedComprehensive: (_) => _validate(), + ), + + Util.isDesktop + ? const SizedBox(height: 32) + : const SizedBox(height: 16), + if (!Util.isDesktop) const Spacer(), + + ConditionalParent( + condition: Util.isDesktop, + builder: (child) => Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + buttonHeight: .l, + ), + ), + const SizedBox(width: 24), + Expanded(child: child), + ], + ), + child: PrimaryButton( + label: "Create", + enabled: _enableCreateButton, + onPressed: _enableCreateButton ? _register : null, + buttonHeight: Util.isDesktop ? .l : null, + ), + ), + ], + ); + } +} diff --git a/lib/pages/monkey/monkey_view.dart b/lib/pages/monkey/monkey_view.dart index 49329cc986..e16f8d6353 100644 --- a/lib/pages/monkey/monkey_view.dart +++ b/lib/pages/monkey/monkey_view.dart @@ -6,6 +6,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; +import 'package:saf_stream/saf_stream.dart'; +import 'package:saf_util/saf_util.dart'; import '../../notifications/show_flush_bar.dart'; import '../../providers/global/wallets_provider.dart'; @@ -13,8 +15,8 @@ import '../../services/monkey_service.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; +import '../../utilities/fs.dart'; import '../../utilities/show_loading.dart'; -import '../../utilities/stack_file_system.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; @@ -51,13 +53,13 @@ class _MonkeyViewState extends ConsumerState { .updateMonkeyImageBytes(monKeyBytes.toList()); } - Future _getDocsDir() async { + Future _getDocsDir() async { try { if (Platform.isAndroid) { - return await StackFileSystem.wtfAndroidDocumentsPath(); + return await FS.pickDirectory(); } - return await getApplicationDocumentsDirectory(); + return (await getApplicationDocumentsDirectory()).path; } catch (_) { return null; } @@ -70,27 +72,40 @@ class _MonkeyViewState extends ConsumerState { bool isPNG = false, bool overwrite = false, }) async { - final dir = await _getDocsDir(); - if (dir == null) { - throw Exception("Failed to get documents directory to save monKey image"); + final dirPath = await _getDocsDir(); + if (dirPath == null) { + throw Exception("Failed to get directory path to save monKey image"); } - final address = - await ref - .read(pWallets) - .getWallet(walletId) - .getCurrentReceivingAddress(); - String filePath = path.join(dir.path, "monkey_${address?.value}"); + final address = await ref + .read(pWallets) + .getWallet(walletId) + .getCurrentReceivingAddress(); - filePath += isPNG ? ".png" : ".svg"; + final fileName = "monkey_${address?.value}${isPNG ? ".png" : ".svg"}"; + final filePath = path.join(dirPath, fileName); - final File imgFile = File(filePath); + if (Platform.isAndroid) { + if (!overwrite && await SafUtil().exists(filePath, false)) { + throw Exception("File already exists"); + } + + await SafStream().writeFileBytes( + dirPath, + fileName, + isPNG ? "png" : "svg", + bytes, + ); + } else { + final File imgFile = File(filePath); + + if (imgFile.existsSync() && !overwrite) { + throw Exception("File already exists"); + } - if (imgFile.existsSync() && !overwrite) { - throw Exception("File already exists"); + await imgFile.writeAsBytes(bytes); } - await imgFile.writeAsBytes(bytes); _monkeyPath = filePath; } @@ -113,313 +128,296 @@ class _MonkeyViewState extends ConsumerState { return Background( child: ConditionalParent( condition: isDesktop, - builder: - (child) => DesktopScaffold( - appBar: DesktopAppBar( - background: Theme.of(context).extension()!.popupBG, - leading: Expanded( - child: Row( - children: [ - const SizedBox(width: 32), - AppBarIconButton( - size: 32, - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - shadows: const [], - icon: SvgPicture.asset( - Assets.svg.arrowLeft, - width: 18, - height: 18, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, - ), - onPressed: Navigator.of(context).pop, - ), - const SizedBox(width: 15), - SvgPicture.asset( - Assets.svg.monkey, - width: 32, - height: 32, - color: - Theme.of( - context, - ).extension()!.textSubtitle1, - ), - const SizedBox(width: 12), - Text("MonKey", style: STextStyles.desktopH3(context)), - ], + builder: (child) => DesktopScaffold( + appBar: DesktopAppBar( + background: Theme.of(context).extension()!.popupBG, + leading: Expanded( + child: Row( + children: [ + const SizedBox(width: 32), + AppBarIconButton( + size: 32, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + shadows: const [], + icon: SvgPicture.asset( + Assets.svg.arrowLeft, + width: 18, + height: 18, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + ), + onPressed: Navigator.of(context).pop, ), - ), - trailing: RawMaterialButton( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(1000), + const SizedBox(width: 15), + SvgPicture.asset( + Assets.svg.monkey, + width: 32, + height: 32, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), - onPressed: () { - showDialog( - context: context, - useSafeArea: false, - barrierDismissible: true, - builder: (context) { - return DesktopDialog( - maxHeight: double.infinity, - child: Column( + const SizedBox(width: 12), + Text("MonKey", style: STextStyles.desktopH3(context)), + ], + ), + ), + trailing: RawMaterialButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(1000), + ), + onPressed: () { + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return DesktopDialog( + maxHeight: double.infinity, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "About MonKeys", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Text( - "A MonKey is a visual representation of your Banano address.", - style: STextStyles.desktopTextMedium( - context, - ).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark3, + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "About MonKeys", + style: STextStyles.desktopH3(context), ), ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Padding( - padding: const EdgeInsets.all(32), - child: PrimaryButton( - width: 272.5, - label: "OK", - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ), - ], + const DesktopDialogCloseButton(), + ], + ), + Text( + "A MonKey is a visual representation of your Banano address.", + style: STextStyles.desktopTextMedium(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.all(32), + child: PrimaryButton( + width: 272.5, + label: "OK", + onPressed: () { + Navigator.of(context).pop(); + }, + ), ), ], ), - ); - }, + ], + ), ); }, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 19, - horizontal: 32, + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 19, + horizontal: 32, + ), + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.circleQuestion, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.customTextButtonEnabledText, ), - child: Row( - children: [ - SvgPicture.asset( - Assets.svg.circleQuestion, - width: 20, - height: 20, - color: - Theme.of(context) - .extension()! - .customTextButtonEnabledText, - ), - const SizedBox(width: 8), - Text( - "What is MonKey?", - style: STextStyles.desktopMenuItemSelected( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .customTextButtonEnabledText, + const SizedBox(width: 8), + Text( + "What is MonKey?", + style: STextStyles.desktopMenuItemSelected(context) + .copyWith( + color: Theme.of(context) + .extension()! + .customTextButtonEnabledText, ), - ), - ], ), - ), + ], ), - useSpacers: false, - isCompactHeight: true, ), - body: child, ), + useSpacers: false, + isCompactHeight: true, + ), + body: child, + ), child: ConditionalParent( condition: !isDesktop, - builder: - (child) => Scaffold( - appBar: AppBar( - leading: AppBarBackButton( + builder: (child) => Scaffold( + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text("MonKey", style: STextStyles.navBarTitle(context)), + actions: [ + AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + icon: SvgPicture.asset(Assets.svg.circleQuestion), onPressed: () { - Navigator.of(context).pop(); - }, - ), - title: Text( - "MonKey", - style: STextStyles.navBarTitle(context), - ), - actions: [ - AspectRatio( - aspectRatio: 1, - child: AppBarIconButton( - icon: SvgPicture.asset(Assets.svg.circleQuestion), - onPressed: () { - showDialog( - context: context, - useSafeArea: false, - barrierDismissible: true, - builder: (context) { - return const StackOkDialog( - title: "About MonKeys", - message: - "A MonKey is a visual representation of your Banano address.", - ); - }, + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return const StackOkDialog( + title: "About MonKeys", + message: + "A MonKey is a visual representation of your Banano address.", ); }, - ), - ), - ], + ); + }, + ), ), - body: SafeArea(child: child), - ), + ], + ), + body: SafeArea(child: child), + ), child: ConditionalParent( condition: isDesktop, builder: (child) => SizedBox(width: 318, child: child), child: ConditionalParent( condition: imageBytes != null, - builder: - (_) => Column( - children: [ - isDesktop - ? const SizedBox(height: 50) - : const Spacer(flex: 1), - if (imageBytes != null) - SizedBox( - width: 300, - height: 300, - child: SvgPicture.memory( - Uint8List.fromList(imageBytes!), - ), - ), - isDesktop - ? const SizedBox(height: 50) - : const Spacer(flex: 1), - Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - SecondaryButton( - label: "Save as SVG", - onPressed: () async { - bool didError = false; - await showLoading( - whileFuture: Future.wait([ - _saveMonKeyToFile( - bytes: Uint8List.fromList( - (wallet as BananoWallet) - .getMonkeyImageBytes()!, - ), - ), - Future.delayed( - const Duration(seconds: 2), - ), - ]), + builder: (_) => Column( + children: [ + isDesktop + ? const SizedBox(height: 50) + : const Spacer(flex: 1), + if (imageBytes != null) + SizedBox( + width: 300, + height: 300, + child: SvgPicture.memory(Uint8List.fromList(imageBytes!)), + ), + isDesktop + ? const SizedBox(height: 50) + : const Spacer(flex: 1), + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + SecondaryButton( + label: "Save as SVG", + onPressed: () async { + bool didError = false; + await showLoading( + whileFuture: Future.wait([ + _saveMonKeyToFile( + bytes: Uint8List.fromList( + (wallet as BananoWallet) + .getMonkeyImageBytes()!, + ), + ), + Future.delayed( + const Duration(seconds: 2), + ), + ]), + context: context, + rootNavigator: Util.isDesktop, + message: "Saving MonKey svg", + onException: (e) { + didError = true; + String msg = e.toString(); + while (msg.isNotEmpty && + msg.startsWith("Exception:")) { + msg = msg.substring(10).trim(); + } + showFloatingFlushBar( + type: FlushBarType.warning, + message: msg, context: context, - rootNavigator: Util.isDesktop, - message: "Saving MonKey svg", - onException: (e) { - didError = true; - String msg = e.toString(); - while (msg.isNotEmpty && - msg.startsWith("Exception:")) { - msg = msg.substring(10).trim(); - } - showFloatingFlushBar( - type: FlushBarType.warning, - message: msg, - context: context, - ); - }, ); + }, + ); - if (!didError && mounted) { - await showFloatingFlushBar( - type: FlushBarType.success, - message: - "SVG MonKey image saved to $_monkeyPath", - context: context, - ); + if (!didError && mounted) { + await showFloatingFlushBar( + type: FlushBarType.success, + message: + "SVG MonKey image saved to $_monkeyPath", + context: context, + ); + } + }, + ), + const SizedBox(height: 12), + SecondaryButton( + label: "Download as PNG", + onPressed: () async { + bool didError = false; + await showLoading( + whileFuture: Future.wait([ + wallet.getCurrentReceivingAddress().then( + (address) async => await ref + .read(pMonKeyService) + .fetchMonKey( + address: address!.value, + png: true, + ) + .then( + (monKeyBytes) async => + await _saveMonKeyToFile( + bytes: monKeyBytes, + isPNG: true, + ), + ), + ), + Future.delayed( + const Duration(seconds: 2), + ), + ]), + context: context, + rootNavigator: Util.isDesktop, + message: "Downloading MonKey png", + onException: (e) { + didError = true; + String msg = e.toString(); + while (msg.isNotEmpty && + msg.startsWith("Exception:")) { + msg = msg.substring(10).trim(); } - }, - ), - const SizedBox(height: 12), - SecondaryButton( - label: "Download as PNG", - onPressed: () async { - bool didError = false; - await showLoading( - whileFuture: Future.wait([ - wallet.getCurrentReceivingAddress().then( - (address) async => await ref - .read(pMonKeyService) - .fetchMonKey( - address: address!.value, - png: true, - ) - .then( - (monKeyBytes) async => - await _saveMonKeyToFile( - bytes: monKeyBytes, - isPNG: true, - ), - ), - ), - Future.delayed( - const Duration(seconds: 2), - ), - ]), + showFloatingFlushBar( + type: FlushBarType.warning, + message: msg, context: context, - rootNavigator: Util.isDesktop, - message: "Downloading MonKey png", - onException: (e) { - didError = true; - String msg = e.toString(); - while (msg.isNotEmpty && - msg.startsWith("Exception:")) { - msg = msg.substring(10).trim(); - } - showFloatingFlushBar( - type: FlushBarType.warning, - message: msg, - context: context, - ); - }, ); - - if (!didError && mounted) { - await showFloatingFlushBar( - type: FlushBarType.success, - message: - "PNG MonKey image saved to $_monkeyPath", - context: context, - ); - } }, - ), - ], + ); + + if (!didError && mounted) { + await showFloatingFlushBar( + type: FlushBarType.success, + message: + "PNG MonKey image saved to $_monkeyPath", + context: context, + ); + } + }, ), - ), - // child, - ], + ], + ), ), + // child, + ], + ), child: Column( children: [ isDesktop @@ -440,10 +438,9 @@ class _MonkeyViewState extends ConsumerState { Text( "You do not have a MonKey yet. \nFetch yours now!", style: STextStyles.smallMed14(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of( + context, + ).extension()!.textDark3, ), textAlign: TextAlign.center, ), @@ -489,8 +486,8 @@ class _MonkeyViewState extends ConsumerState { }, ); - imageBytes = - (wallet as BananoWallet).getMonkeyImageBytes(); + imageBytes = (wallet as BananoWallet) + .getMonkeyImageBytes(); if (imageBytes != null) { setState(() {}); diff --git a/lib/pages/more_view/gift_cards_view.dart b/lib/pages/more_view/gift_cards_view.dart new file mode 100644 index 0000000000..db0ecb48e0 --- /dev/null +++ b/lib/pages/more_view/gift_cards_view.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app_config.dart'; +import '../../services/event_bus/events/global/tor_connection_status_changed_event.dart'; +import '../../services/tor_service.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/text_styles.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/icon_widgets/credit_card_icon.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/tor_subscription.dart'; +import '../cakepay/cakepay_orders_view.dart'; +import '../cakepay/cakepay_vendors_view.dart'; + +class GiftCardsView extends ConsumerStatefulWidget { + const GiftCardsView({super.key}); + + @override + ConsumerState createState() => _GiftCardsViewState(); +} + +class _GiftCardsViewState extends ConsumerState { + late bool _torEnabled; + + @override + void initState() { + _torEnabled = AppConfig.hasFeature(AppFeature.tor) + ? ref.read(pTorService).status != TorConnectionStatus.disconnected + : false; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return TorSubscription( + onTorStatusChanged: (status) { + setState(() { + _torEnabled = status != TorConnectionStatus.disconnected; + }); + }, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const CreditCardIcon(width: 32, height: 32), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "CakePay", + style: STextStyles.titleBold12(context), + ), + const SizedBox(height: 2), + Text( + "Purchase gift cards with cryptocurrency", + style: STextStyles.itemSubtitle12(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + if (_torEnabled) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + "CakePay is not available while Tor is enabled", + style: STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "My Orders", + enabled: !_torEnabled, + onPressed: () { + Navigator.of( + context, + ).pushNamed(CakePayOrdersView.routeName); + }, + ), + ), + + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Browse", + enabled: !_torEnabled, + onPressed: () { + Navigator.of( + context, + ).pushNamed(CakePayVendorsView.routeName); + }, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/more_view/services_view.dart b/lib/pages/more_view/services_view.dart new file mode 100644 index 0000000000..6c220ce16c --- /dev/null +++ b/lib/pages/more_view/services_view.dart @@ -0,0 +1,247 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../db/drift/shared_db/shared_database.dart'; +import '../../providers/providers.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/text_styles.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/dialogs/request_external_link_navigation_dialog.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../shopinbit/shopinbit_setup_view.dart'; +import '../shopinbit/shopinbit_step_2.dart'; +import '../shopinbit/shopinbit_tickets_view.dart'; + +class ServicesView extends ConsumerStatefulWidget { + const ServicesView({super.key}); + + @override + ConsumerState createState() => _ServicesViewState(); +} + +class _ServicesViewState extends ConsumerState { + Future _showShopDialog() async { + final result = + await showDialog<({ShopInBitSetting? settings, bool continuePressed})>( + context: context, + barrierDismissible: true, + builder: (context) => StackDialogBase( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("ShopinBit", style: STextStyles.pageTitleH2(context)), + const SizedBox(height: 8), + RichText( + text: TextSpan( + style: STextStyles.smallMed14(context), + children: [ + const TextSpan( + text: + "Please note the following before proceeding:" + "\n\n\u2022 Minimum order amount: 1,000 EUR" + "\n\u2022 Service fee: 10% of the order total" + "\n\nBy continuing, you agree to the ShopinBit ", + ), + TextSpan( + text: "Privacy Policy", + style: STextStyles.richLink( + context, + ).copyWith(fontSize: 16), + recognizer: TapGestureRecognizer() + ..onTap = () async { + const url = + "https://api.shopinbit.com/static/policy/privacy.html"; + + await showRequestExternalLinkAndMaybeLaunch( + context, + uri: Uri.parse(url), + ); + }, + ), + const TextSpan(text: "."), + ], + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextButton( + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + onPressed: () async { + final settings = await ref + .read(pSharedDrift) + .shopInBitSettingsDao + .getCurrentSettings(); + + if (!context.mounted) return; + + Navigator.of( + context, + ).pop((settings: settings, continuePressed: true)); + }, + child: Text( + "Continue", + style: STextStyles.button(context), + ), + ), + ), + ], + ), + ], + ), + ), + ); + + if (mounted && result != null && result.continuePressed == true) { + final settings = result.settings; + if (settings != null && settings.setupComplete) { + // Returning user: straight to category selection. + await Navigator.of(context).pushNamed(ShopInBitStep2.routeName); + } else { + // First-time (or incomplete) setup: show the key-backup screen. + await Navigator.of(context).pushNamed(ShopInBitSetupView.routeName); + } + } + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + decoration: BoxDecoration( + color: const Color(0xFFE0E3E3), + borderRadius: .circular(20), + ), + width: 40, + height: 40, + child: Center( + child: SizedBox( + width: 27, + height: 27, + child: SvgPicture.asset( + Assets.svg.sib, + colorFilter: const .mode(Colors.black, .srcIn), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + "ShopinBit", + style: STextStyles.titleBold12(context), + ), + ), + ], + ), + const SizedBox(height: 24), + Text( + "Spend crypto privately in the real world.\n" + "A global concierge service, handled by real humans, built " + "around your privacy. Turn crypto into flights, cars, " + "electronics or almost anything else, legally.", + style: STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + const SizedBox(height: 12), + RichText( + text: TextSpan( + style: STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + children: [ + const TextSpan( + text: + "Minimum order value of 1,000 EUR. " + "A 10% service fee applies to all orders.\n\n" + "By using ShopinBit, you agree to their ", + ), + TextSpan( + text: "Terms & Conditions", + style: STextStyles.richLink( + context, + ).copyWith(fontSize: 14), + recognizer: TapGestureRecognizer() + ..onTap = () async { + const url = + "https://api.shopinbit.com/static/policy/terms.html"; + + await showRequestExternalLinkAndMaybeLaunch( + context, + uri: Uri.parse(url), + ); + }, + ), + const TextSpan(text: " and "), + TextSpan( + text: "Privacy Policy", + style: STextStyles.richLink( + context, + ).copyWith(fontSize: 14), + recognizer: TapGestureRecognizer() + ..onTap = () async { + const url = + "https://api.shopinbit.com/static/policy/privacy.html"; + + await showRequestExternalLinkAndMaybeLaunch( + context, + uri: Uri.parse(url), + ); + }, + ), + const TextSpan(text: "."), + ], + ), + ), + const SizedBox(height: 24), + PrimaryButton( + label: "Shop with ShopinBit", + enabled: true, + onPressed: _showShopDialog, + ), + const SizedBox(height: 12), + SecondaryButton( + label: "My requests", + onPressed: () async { + await Navigator.of( + context, + ).pushNamed(ShopInBitTicketsView.routeName); + }, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/namecoin_names/sub_widgets/transfer_option_widget.dart b/lib/pages/namecoin_names/sub_widgets/transfer_option_widget.dart index 89be6129cb..5dbc649bcd 100644 --- a/lib/pages/namecoin_names/sub_widgets/transfer_option_widget.dart +++ b/lib/pages/namecoin_names/sub_widgets/transfer_option_widget.dart @@ -169,16 +169,15 @@ class _TransferOptionWidgetState extends ConsumerState { if (Util.isDesktop) { await showDialog( context: context, - builder: - (context) => SDialog( - child: SizedBox( - width: 580, - child: ConfirmNameTransactionView( - txData: txData, - walletId: widget.walletId, - ), - ), + builder: (context) => SDialog( + child: SizedBox( + width: 580, + child: ConfirmNameTransactionView( + txData: txData, + walletId: widget.walletId, ), + ), + ), ); } else { await Navigator.of(context).pushNamed( @@ -203,13 +202,12 @@ class _TransferOptionWidgetState extends ConsumerState { await showDialog( context: context, - builder: - (_) => StackOkDialog( - title: "Error", - message: err, - desktopPopRootNavigator: Util.isDesktop, - maxWidth: Util.isDesktop ? 600 : null, - ), + builder: (_) => StackOkDialog( + title: "Error", + message: err, + desktopPopRootNavigator: Util.isDesktop, + maxWidth: Util.isDesktop ? 600 : null, + ), ); } } finally { @@ -238,12 +236,14 @@ class _TransferOptionWidgetState extends ConsumerState { } final qrResult = await ref.read(pBarcodeScanner).scan(context: context); + if (qrResult.rawContent == null) return; + final coin = ref.read(pWalletCoin(walletId)); Logging.instance.d("qrResult content: ${qrResult.rawContent}"); final paymentData = AddressUtils.parsePaymentUri( - qrResult.rawContent, + qrResult.rawContent!, logging: Logging.instance, ); @@ -257,7 +257,7 @@ class _TransferOptionWidgetState extends ConsumerState { // now check for non standard encoded basic address } else { - _address = qrResult.rawContent.split("\n").first.trim(); + _address = qrResult.rawContent!.split("\n").first.trim(); _addressController.text = _address ?? ""; _setValidAddressProviders(_address); @@ -313,8 +313,9 @@ class _TransferOptionWidgetState extends ConsumerState { Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - Util.isDesktop ? CrossAxisAlignment.start : CrossAxisAlignment.center, + crossAxisAlignment: Util.isDesktop + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, children: [ ClipRRect( borderRadius: BorderRadius.circular( @@ -338,121 +339,120 @@ class _TransferOptionWidgetState extends ConsumerState { }, focusNode: _addressFocusNode, style: STextStyles.field(context), - decoration: standardInputDecoration( - "Enter ${ref.watch(pWalletCoin(walletId)).ticker} address", - _addressFocusNode, - context, - ).copyWith( - contentPadding: const EdgeInsets.only( - left: 16, - top: 6, - bottom: 8, - right: 5, - ), - suffixIcon: Padding( - padding: - _addressController.text.isEmpty + decoration: + standardInputDecoration( + "Enter ${ref.watch(pWalletCoin(walletId)).ticker} address", + _addressFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _addressController.text.isEmpty ? const EdgeInsets.only(right: 8) : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _addressController.text.isNotEmpty - ? TextFieldIconButton( - semanticsLabel: - "Clear Button. Clears The Address Field Input.", - key: const Key( - "nameTransferClearAddressFieldButtonKey", + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _addressController.text.isNotEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Address Field Input.", + key: const Key( + "nameTransferClearAddressFieldButtonKey", + ), + onTap: () { + _addressController.text = ""; + _address = ""; + _setValidAddressProviders(_address); + setState(() {}); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Address Field Input.", + key: const Key( + "nameTransferPasteAddressFieldButtonKey", + ), + onTap: () async { + final ClipboardData? data = await clipboard + .getData(Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + String content = data.text!.trim(); + if (content.contains("\n")) { + content = content.substring( + 0, + content.indexOf("\n"), + ); + } + + _addressController.text = content.trim(); + _address = content.trim(); + + _setValidAddressProviders(_address); + } + }, + child: _addressController.text.isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_addressController.text.isEmpty) + TextFieldIconButton( + semanticsLabel: + "Address Book Button. Opens Address Book For Address Field.", + key: const Key( + "nameTransferAddressBookButtonKey", + ), + onTap: () { + Navigator.of(context).pushNamed( + AddressBookView.routeName, + arguments: ref.read(pWalletCoin(walletId)), + ); + }, + child: const AddressBookIcon(), ), - onTap: () { - _addressController.text = ""; - _address = ""; - _setValidAddressProviders(_address); - setState(() {}); - }, - child: const XIcon(), - ) - : TextFieldIconButton( - semanticsLabel: - "Paste Button. Pastes From Clipboard To Address Field Input.", - key: const Key( - "nameTransferPasteAddressFieldButtonKey", + if (_addressController.text.isEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("nameTransferScanQrButtonKey"), + onTap: _scanQr, + child: const QrCodeIcon(), ), - onTap: () async { - final ClipboardData? data = await clipboard - .getData(Clipboard.kTextPlain); - if (data?.text != null && - data!.text!.isNotEmpty) { - String content = data.text!.trim(); - if (content.contains("\n")) { - content = content.substring( - 0, - content.indexOf("\n"), - ); - } - - _addressController.text = content.trim(); - _address = content.trim(); - - _setValidAddressProviders(_address); - } - }, - child: - _addressController.text.isEmpty - ? const ClipboardIcon() - : const XIcon(), - ), - if (_addressController.text.isEmpty) - TextFieldIconButton( - semanticsLabel: - "Address Book Button. Opens Address Book For Address Field.", - key: const Key("nameTransferAddressBookButtonKey"), - onTap: () { - Navigator.of(context).pushNamed( - AddressBookView.routeName, - arguments: ref.read(pWalletCoin(walletId)), - ); - }, - child: const AddressBookIcon(), - ), - if (_addressController.text.isEmpty) - TextFieldIconButton( - semanticsLabel: - "Scan QR Button. Opens Camera For Scanning QR Code.", - key: const Key("nameTransferScanQrButtonKey"), - onTap: _scanQr, - child: const QrCodeIcon(), - ), - ], + ], + ), + ), ), ), - ), - ), ), ), SizedBox(height: Util.isDesktop ? 42 : 16), if (!Util.isDesktop) const Spacer(), ConditionalParent( condition: Util.isDesktop, - builder: - (child) => Row( - children: [ - Expanded( - child: SecondaryButton( - label: "Cancel", - buttonHeight: ButtonHeight.l, - onPressed: - Navigator.of( - context, - rootNavigator: Util.isDesktop, - ).pop, - ), - ), - const SizedBox(width: 16), - Expanded(child: child), - ], + builder: (child) => Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: ButtonHeight.l, + onPressed: Navigator.of( + context, + rootNavigator: Util.isDesktop, + ).pop, + ), ), + const SizedBox(width: 16), + Expanded(child: child), + ], + ), child: PrimaryButton( label: "Transfer", enabled: _enableButton, diff --git a/lib/pages/notification_views/notifications_view.dart b/lib/pages/notification_views/notifications_view.dart index 417d10bae6..053980a2be 100644 --- a/lib/pages/notification_views/notifications_view.dart +++ b/lib/pages/notification_views/notifications_view.dart @@ -8,12 +8,15 @@ * */ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../notifications/notification_card.dart'; -import '../../providers/providers.dart'; -import '../../providers/ui/unread_notifications_provider.dart'; +import '../../notifications/notification_feed_entry_card.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../providers/ui/notification_feed_provider.dart'; +import '../../services/shopinbit/shopinbit_service.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; import '../../widgets/background.dart'; @@ -32,29 +35,25 @@ class NotificationsView extends ConsumerStatefulWidget { } class _NotificationsViewState extends ConsumerState { + late final ShopInBitService _shopInBitService; + @override void initState() { super.initState(); + _shopInBitService = ref.read(pShopinBitService); } @override void dispose() { + if (widget.walletId == null) { + unawaited(_shopInBitService.markAllNotificationsRead()); + } super.dispose(); } @override Widget build(BuildContext context) { - final notifications = - widget.walletId == null - ? ref.watch( - notificationsProvider.select((value) => value.notifications), - ) - : ref - .watch( - notificationsProvider.select((value) => value.notifications), - ) - .where((element) => element.walletId == widget.walletId) - .toList(growable: false); + final entries = ref.watch(pNotificationFeed(widget.walletId)); return Background( child: Scaffold( @@ -70,54 +69,44 @@ class _NotificationsViewState extends ConsumerState { body: SafeArea( child: Padding( padding: const EdgeInsets.all(12), - child: - notifications.isNotEmpty - ? Column( - children: [ - Expanded( - child: ListView.builder( - shrinkWrap: true, - itemCount: notifications.length, - itemBuilder: (builderContext, index) { - final notification = notifications[index]; - if (notification.read == false) { - ref - .read( - unreadNotificationsStateProvider.state, - ) - .state - .add(notification.id); - } - return Padding( - padding: const EdgeInsets.all(4), - child: NotificationCard( - notification: notifications[index], - ), - ); - }, - ), + child: entries.isNotEmpty + ? Column( + children: [ + Expanded( + child: ListView.builder( + shrinkWrap: true, + itemCount: entries.length, + itemBuilder: (builderContext, index) { + return Padding( + padding: const EdgeInsets.all(4), + child: NotificationFeedEntryCard( + entry: entries[index], + ), + ); + }, ), - ], - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.all(4), - child: RoundedWhiteContainer( - child: Center( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - "Notifications will appear here", - style: STextStyles.itemSubtitle(context), - ), + ), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(4), + child: RoundedWhiteContainer( + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + "Notifications will appear here", + style: STextStyles.itemSubtitle(context), ), ), ), ), - ], - ), + ), + ], + ), ), ), ), diff --git a/lib/pages/ordinals/ordinal_details_view.dart b/lib/pages/ordinals/ordinal_details_view.dart index 996f20db67..958aa8f37d 100644 --- a/lib/pages/ordinals/ordinal_details_view.dart +++ b/lib/pages/ordinals/ordinal_details_view.dart @@ -7,28 +7,37 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; +import 'package:saf_stream/saf_stream.dart'; +import 'package:saf_util/saf_util.dart'; import '../../app_config.dart'; import '../../models/isar/models/blockchain_data/utxo.dart'; import '../../models/isar/ordinal.dart'; import '../../networking/http.dart'; import '../../notifications/show_flush_bar.dart'; +import '../../pages/send_view/confirm_transaction_view.dart'; import '../../providers/db/main_db_provider.dart'; import '../../providers/global/prefs_provider.dart'; +import '../../providers/global/wallets_provider.dart'; +import '../../route_generator.dart'; import '../../services/tor_service.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; +import '../../utilities/fs.dart'; import '../../utilities/show_loading.dart'; -import '../../utilities/stack_file_system.dart'; import '../../utilities/text_styles.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/ordinal_image.dart'; import '../../widgets/rounded_white_container.dart'; +import 'widgets/dialogs.dart'; class OrdinalDetailsView extends ConsumerStatefulWidget { const OrdinalDetailsView({ @@ -210,6 +219,18 @@ class _OrdinalImageGroup extends ConsumerWidget { static const _spacing = 12.0; + Future _getDocsDir() async { + try { + if (Platform.isAndroid) { + return await FS.pickDirectory(); + } + + return (await getApplicationDocumentsDirectory()).path; + } catch (_) { + return null; + } + } + Future _savePngToFile(WidgetRef ref) async { final HTTP client = HTTP(); @@ -230,21 +251,36 @@ class _OrdinalImageGroup extends ConsumerWidget { final bytes = response.bodyBytes; - final dir = Platform.isAndroid - ? await StackFileSystem.wtfAndroidDocumentsPath() - : await getApplicationDocumentsDirectory(); - final filePath = path.join( - dir.path, - "ordinal_${ordinal.inscriptionNumber}.png", - ); + final dirPath = await _getDocsDir(); + if (dirPath == null) { + throw Exception("Failed to get directory path to save ordinal image"); + } + + final fileName = "ordinal_${ordinal.inscriptionNumber}.png"; + + final filePath = path.join(dirPath, fileName); + + if (Platform.isAndroid) { + if (await SafUtil().exists(filePath, false)) { + throw Exception("File already exists"); + } - final File imgFile = File(filePath); + await SafStream().writeFileBytes( + dirPath, + fileName, + "png", + Uint8List.fromList(bytes), + ); + } else { + final File imgFile = File(filePath); + + if (imgFile.existsSync()) { + throw Exception("File already exists"); + } - if (imgFile.existsSync()) { - throw Exception("File already exists"); + await imgFile.writeAsBytes(bytes); } - await imgFile.writeAsBytes(bytes); return filePath; } @@ -269,12 +305,7 @@ class _OrdinalImageGroup extends ConsumerWidget { aspectRatio: 1, child: Container( color: Colors.transparent, - child: Image.network( - ordinal.content, // Use the preview URL as the image source - fit: BoxFit.cover, - filterQuality: - FilterQuality.none, // Set the filter mode to nearest - ), + child: OrdinalImage(url: ordinal.content), ), ), ), @@ -325,33 +356,129 @@ class _OrdinalImageGroup extends ConsumerWidget { }, ), ), - // const SizedBox( - // width: _spacing, - // ), - // Expanded( - // child: PrimaryButton( - // label: "Send", - // icon: SvgPicture.asset( - // Assets.svg.send, - // width: 10, - // height: 10, - // color: Theme.of(context) - // .extension()! - // .buttonTextPrimary, - // ), - // buttonHeight: ButtonHeight.l, - // iconSpacing: 4, - // onPressed: () async { - // final response = await showDialog( - // context: context, - // builder: (_) => const SendOrdinalUnfreezeDialog(), - // ); - // if (response == "unfreeze") { - // // TODO: unfreeze and go to send ord screen - // } - // }, - // ), - // ), + const SizedBox(width: _spacing), + Expanded( + child: PrimaryButton( + label: "Send", + icon: SvgPicture.asset( + Assets.svg.send, + width: 10, + height: 10, + color: Theme.of( + context, + ).extension()!.buttonTextPrimary, + ), + buttonHeight: ButtonHeight.l, + iconSpacing: 4, + onPressed: () async { + final utxo = ordinal.getUTXO(ref.read(mainDBProvider)); + if (utxo == null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Could not find ordinal UTXO", + context: context, + ), + ); + return; + } + + // Step 1: Confirm unfreeze + if (utxo.isBlocked) { + final unfreezeResponse = await showDialog( + context: context, + builder: (_) => const SendOrdinalUnfreezeDialog(), + ); + if (unfreezeResponse != "unfreeze") return; + } + + if (!context.mounted) return; + + // Step 2: Get recipient address + final address = await showDialog( + context: context, + builder: (_) => OrdinalRecipientAddressDialog( + inscriptionNumber: ordinal.inscriptionNumber, + ), + ); + if (address == null || address.isEmpty) return; + + // Validate address + final wallet = ref.read(pWallets).getWallet(walletId); + if (!wallet.cryptoCurrency.validateAddress(address)) { + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid address", + context: context, + ), + ); + } + return; + } + + if (!context.mounted) return; + + // Step 3: Prepare the transaction + final OrdinalsInterface? ordinalsWallet = + wallet is OrdinalsInterface ? wallet : null; + if (ordinalsWallet == null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Wallet does not support ordinals", + context: context, + ), + ); + return; + } + + bool didError = false; + final txData = await showLoading( + whileFuture: ordinalsWallet.prepareOrdinalSend( + ordinalUtxo: utxo, + recipientAddress: address, + ), + context: context, + rootNavigator: true, + message: "Preparing transaction...", + onException: (e) { + didError = true; + String msg = e.toString(); + while (msg.isNotEmpty && msg.startsWith("Exception:")) { + msg = msg.substring(10).trim(); + } + if (context.mounted) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: msg, + context: context, + ); + } + }, + ); + + if (didError || txData == null || !context.mounted) return; + + // Step 4: Navigate to confirm transaction view + await Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: + RouteGenerator.useMaterialPageRoute, + builder: (_) => ConfirmTransactionView( + walletId: walletId, + txData: txData, + onSuccess: () {}, + ), + settings: const RouteSettings( + name: ConfirmTransactionView.routeName, + ), + ), + ); + }, + ), + ), ], ), ], diff --git a/lib/pages/ordinals/ordinals_filter_view.dart b/lib/pages/ordinals/ordinals_filter_view.dart index 93c7e0dd70..660a0f8178 100644 --- a/lib/pages/ordinals/ordinals_filter_view.dart +++ b/lib/pages/ordinals/ordinals_filter_view.dart @@ -125,10 +125,9 @@ class _OrdinalsFilterViewState extends ConsumerState { return Text( isDateSelected ? "From..." : _fromDateString, style: STextStyles.fieldLabel(context).copyWith( - color: - isDateSelected - ? Theme.of(context).extension()!.textSubtitle2 - : Theme.of(context).extension()!.accentColorDark, + color: isDateSelected + ? Theme.of(context).extension()!.textSubtitle2 + : Theme.of(context).extension()!.accentColorDark, ), ); } @@ -138,10 +137,9 @@ class _OrdinalsFilterViewState extends ConsumerState { return Text( isDateSelected ? "To..." : _toDateString, style: STextStyles.fieldLabel(context).copyWith( - color: - isDateSelected - ? Theme.of(context).extension()!.textSubtitle2 - : Theme.of(context).extension()!.accentColorDark, + color: isDateSelected + ? Theme.of(context).extension()!.textSubtitle2 + : Theme.of(context).extension()!.accentColorDark, ), ); } @@ -154,14 +152,13 @@ class _OrdinalsFilterViewState extends ConsumerState { const middleSeparatorWidth = 12.0; final isDesktop = Util.isDesktop; - final width = - isDesktop - ? null - : (MediaQuery.of(context).size.width - - (middleSeparatorWidth + - (2 * middleSeparatorPadding) + - (2 * Constants.size.standardPadding))) / - 2; + final width = isDesktop + ? null + : (MediaQuery.of(context).size.width - + (middleSeparatorWidth + + (2 * middleSeparatorPadding) + + (2 * Constants.size.standardPadding))) / + 2; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -177,7 +174,7 @@ class _OrdinalsFilterViewState extends ConsumerState { } if (mounted) { - final date = await showSWDatePicker(context); + final date = (await showSWDatePicker(context))?.first; if (date != null) { _selectedFromDate = date; @@ -193,15 +190,13 @@ class _OrdinalsFilterViewState extends ConsumerState { setState(() { if (flag) { - _toDateString = - _selectedToDate == null - ? "" - : Format.formatDate(_selectedToDate!); + _toDateString = _selectedToDate == null + ? "" + : Format.formatDate(_selectedToDate!); } - _fromDateString = - _selectedFromDate == null - ? "" - : Format.formatDate(_selectedFromDate!); + _fromDateString = _selectedFromDate == null + ? "" + : Format.formatDate(_selectedFromDate!); }); } } @@ -209,18 +204,16 @@ class _OrdinalsFilterViewState extends ConsumerState { child: Container( width: width, decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), border: Border.all( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, width: 1, ), ), @@ -235,10 +228,9 @@ class _OrdinalsFilterViewState extends ConsumerState { Assets.svg.calendar, height: 20, width: 20, - color: - Theme.of( - context, - ).extension()!.textSubtitle2, + color: Theme.of( + context, + ).extension()!.textSubtitle2, ), const SizedBox(width: 10), Align( @@ -272,7 +264,7 @@ class _OrdinalsFilterViewState extends ConsumerState { } if (mounted) { - final date = await showSWDatePicker(context); + final date = (await showSWDatePicker(context))?.first; if (date != null) { _selectedToDate = date; @@ -288,15 +280,13 @@ class _OrdinalsFilterViewState extends ConsumerState { setState(() { if (flag) { - _fromDateString = - _selectedFromDate == null - ? "" - : Format.formatDate(_selectedFromDate!); + _fromDateString = _selectedFromDate == null + ? "" + : Format.formatDate(_selectedFromDate!); } - _toDateString = - _selectedToDate == null - ? "" - : Format.formatDate(_selectedToDate!); + _toDateString = _selectedToDate == null + ? "" + : Format.formatDate(_selectedToDate!); }); } } @@ -304,18 +294,16 @@ class _OrdinalsFilterViewState extends ConsumerState { child: Container( width: width, decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), border: Border.all( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, width: 1, ), ), @@ -330,10 +318,9 @@ class _OrdinalsFilterViewState extends ConsumerState { Assets.svg.calendar, height: 20, width: 20, - color: - Theme.of( - context, - ).extension()!.textSubtitle2, + color: Theme.of( + context, + ).extension()!.textSubtitle2, ), const SizedBox(width: 10), Align( @@ -365,11 +352,13 @@ class _OrdinalsFilterViewState extends ConsumerState { } else { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, leading: AppBarBackButton( onPressed: () async { if (FocusScope.of(context).hasFocus) { @@ -573,10 +562,9 @@ class _OrdinalsFilterViewState extends ConsumerState { child: FittedBox( child: Text( "Date", - style: - isDesktop - ? STextStyles.labelExtraExtraSmall(context) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.labelExtraExtraSmall(context) + : STextStyles.smallMed12(context), ), ), ), @@ -588,10 +576,9 @@ class _OrdinalsFilterViewState extends ConsumerState { child: FittedBox( child: Text( "Inscription", - style: - isDesktop - ? STextStyles.labelExtraExtraSmall(context) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.labelExtraExtraSmall(context) + : STextStyles.smallMed12(context), ), ), ), @@ -609,51 +596,49 @@ class _OrdinalsFilterViewState extends ConsumerState { controller: _inscriptionTextEditingController, focusNode: inscriptionTextFieldFocusNode, onChanged: (_) => setState(() {}), - style: - isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark, - height: 1.8, - ) - : STextStyles.field(context), - decoration: standardInputDecoration( - "Enter inscription number...", - keywordTextFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - contentPadding: - isDesktop + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + height: 1.8, + ) + : STextStyles.field(context), + decoration: + standardInputDecoration( + "Enter inscription number...", + keywordTextFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + contentPadding: isDesktop ? const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ) + vertical: 10, + horizontal: 16, + ) : null, - suffixIcon: - _inscriptionTextEditingController.text.isNotEmpty + suffixIcon: + _inscriptionTextEditingController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _inscriptionTextEditingController.text = - ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _inscriptionTextEditingController.text = + ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), @@ -663,10 +648,9 @@ class _OrdinalsFilterViewState extends ConsumerState { child: FittedBox( child: Text( "Keyword", - style: - isDesktop - ? STextStyles.labelExtraExtraSmall(context) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.labelExtraExtraSmall(context) + : STextStyles.smallMed12(context), ), ), ), @@ -683,51 +667,48 @@ class _OrdinalsFilterViewState extends ConsumerState { key: const Key("OrdinalsViewKeywordFieldKey"), controller: _keywordTextEditingController, focusNode: keywordTextFieldFocusNode, - style: - isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark, - height: 1.8, - ) - : STextStyles.field(context), + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + height: 1.8, + ) + : STextStyles.field(context), onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Type keyword...", - keywordTextFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - contentPadding: - isDesktop + decoration: + standardInputDecoration( + "Type keyword...", + keywordTextFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + contentPadding: isDesktop ? const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ) + vertical: 10, + horizontal: 16, + ) : null, - suffixIcon: - _keywordTextEditingController.text.isNotEmpty + suffixIcon: _keywordTextEditingController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _keywordTextEditingController.text = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _keywordTextEditingController.text = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), diff --git a/lib/pages/ordinals/widgets/dialogs.dart b/lib/pages/ordinals/widgets/dialogs.dart index fca607961d..cb51fca1a8 100644 --- a/lib/pages/ordinals/widgets/dialogs.dart +++ b/lib/pages/ordinals/widgets/dialogs.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; + import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../widgets/desktop/desktop_dialog.dart'; import '../../../widgets/desktop/primary_button.dart'; import '../../../widgets/desktop/secondary_button.dart'; import '../../../widgets/stack_dialog.dart'; @@ -11,6 +16,61 @@ class SendOrdinalUnfreezeDialog extends StatelessWidget { @override Widget build(BuildContext context) { + if (Util.isDesktop) { + return DesktopDialog( + maxWidth: 450, + maxHeight: 220, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "This ordinal is frozen", + style: STextStyles.desktopH3(context), + ), + SvgPicture.asset( + Assets.svg.coinControl.blocked, + width: 24, + height: 24, + color: Theme.of(context).extension()!.textDark, + ), + ], + ), + const SizedBox(height: 12), + Text( + "To send this ordinal, you must unfreeze it first.", + style: STextStyles.desktopTextMedium(context), + ), + const Spacer(), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Unfreeze", + onPressed: () { + Navigator.of(context).pop("unfreeze"); + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + return StackDialog( title: "This ordinal is frozen", icon: SvgPicture.asset( @@ -39,6 +99,56 @@ class UnfreezeOrdinalDialog extends StatelessWidget { @override Widget build(BuildContext context) { + if (Util.isDesktop) { + return DesktopDialog( + maxWidth: 450, + maxHeight: 200, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Unfreeze ordinal?", + style: STextStyles.desktopH3(context), + ), + SvgPicture.asset( + Assets.svg.coinControl.blocked, + width: 24, + height: 24, + color: Theme.of(context).extension()!.textDark, + ), + ], + ), + const Spacer(), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Unfreeze", + onPressed: () { + Navigator.of(context).pop("unfreeze"); + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + return StackDialog( title: "Are you sure you want to unfreeze this ordinal?", icon: SvgPicture.asset( @@ -60,3 +170,158 @@ class UnfreezeOrdinalDialog extends StatelessWidget { ); } } + +class OrdinalRecipientAddressDialog extends StatefulWidget { + const OrdinalRecipientAddressDialog({ + super.key, + required this.inscriptionNumber, + }); + + final int inscriptionNumber; + + @override + State createState() => + _OrdinalRecipientAddressDialogState(); +} + +class _OrdinalRecipientAddressDialogState + extends State { + late final TextEditingController _controller; + + @override + void initState() { + _controller = TextEditingController(); + super.initState(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Widget _buildTextField(BuildContext context) { + return TextField( + controller: _controller, + decoration: InputDecoration( + hintText: "Paste address", + hintStyle: STextStyles.fieldLabel(context), + suffixIcon: IconButton( + icon: SvgPicture.asset( + Assets.svg.clipboard, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.textFieldDefaultSearchIconLeft, + ), + onPressed: () async { + final data = await Clipboard.getData("text/plain"); + if (data?.text != null) { + _controller.text = data!.text!; + setState(() {}); + } + }, + ), + ), + style: STextStyles.field(context), + autofocus: true, + ); + } + + @override + Widget build(BuildContext context) { + if (Util.isDesktop) { + return DesktopDialog( + maxWidth: 500, + maxHeight: 300, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Send ordinal #${widget.inscriptionNumber}", + style: STextStyles.desktopH3(context), + ), + const SizedBox(height: 12), + Text( + "Enter the recipient address", + style: STextStyles.desktopTextMedium(context), + ), + const SizedBox(height: 8), + _buildTextField(context), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Continue", + onPressed: () { + final address = _controller.text.trim(); + if (address.isNotEmpty) { + Navigator.of(context).pop(address); + } + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + + return StackDialogBase( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Send ordinal #${widget.inscriptionNumber}", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 12), + Text( + "Enter the recipient address", + style: STextStyles.smallMed12(context), + ), + const SizedBox(height: 8), + _buildTextField(context), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Continue", + onPressed: () { + final address = _controller.text.trim(); + if (address.isNotEmpty) { + Navigator.of(context).pop(address); + } + }, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/pages/ordinals/widgets/ordinal_card.dart b/lib/pages/ordinals/widgets/ordinal_card.dart index 31eeb57337..8662e7dddf 100644 --- a/lib/pages/ordinals/widgets/ordinal_card.dart +++ b/lib/pages/ordinals/widgets/ordinal_card.dart @@ -5,14 +5,11 @@ import '../../../pages_desktop_specific/ordinals/desktop_ordinal_details_view.da import '../../../utilities/constants.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; +import '../../../widgets/ordinal_image.dart'; import '../../../widgets/rounded_white_container.dart'; class OrdinalCard extends StatelessWidget { - const OrdinalCard({ - super.key, - required this.walletId, - required this.ordinal, - }); + const OrdinalCard({super.key, required this.walletId, required this.ordinal}); final String walletId; final Ordinal ordinal; @@ -38,12 +35,7 @@ class OrdinalCard extends StatelessWidget { borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), - child: Image.network( - ordinal.content, // Use the preview URL as the image source - fit: BoxFit.cover, - filterQuality: - FilterQuality.none, // Set the filter mode to nearest - ), + child: OrdinalImage(url: ordinal.content), ), ), const Spacer(), diff --git a/lib/pages/paynym/add_new_paynym_follow_view.dart b/lib/pages/paynym/add_new_paynym_follow_view.dart index 85e4c3ac73..37c5b1a8cf 100644 --- a/lib/pages/paynym/add_new_paynym_follow_view.dart +++ b/lib/pages/paynym/add_new_paynym_follow_view.dart @@ -122,8 +122,9 @@ class _AddNewPaynymFollowViewState } final qrResult = await ref.read(pBarcodeScanner).scan(context: context); + if (qrResult.rawContent == null) return; - final pCodeString = qrResult.rawContent; + final pCodeString = qrResult.rawContent!; _searchString = pCodeString; @@ -173,93 +174,82 @@ class _AddNewPaynymFollowViewState return ConditionalParent( condition: !isDesktop, - builder: - (child) => MasterScaffold( - isDesktop: isDesktop, - appBar: AppBar( - leading: AppBarBackButton( - onPressed: () { - Navigator.of(context).pop(); - }, - ), - titleSpacing: 0, - title: Text( - "New follow", - style: STextStyles.navBarTitle(context), - overflow: TextOverflow.ellipsis, - ), - ), - body: SafeArea( - child: LayoutBuilder( - builder: - (context, constraints) => SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - child: IntrinsicHeight( - child: Padding( - padding: const EdgeInsets.all(16), - child: child, - ), - ), - ), - ), + builder: (child) => MasterScaffold( + isDesktop: isDesktop, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + titleSpacing: 0, + title: Text( + "New follow", + style: STextStyles.navBarTitle(context), + overflow: TextOverflow.ellipsis, + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), ), ), ), + ), + ), child: ConditionalParent( condition: isDesktop, - builder: - (child) => DesktopDialog( - maxWidth: 580, - maxHeight: double.infinity, - child: Column( + builder: (child) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "New follow", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, + padding: const EdgeInsets.only(left: 32), + child: Text( + "New follow", + style: STextStyles.desktopH3(context), ), - child: child, ), + const DesktopDialogCloseButton(), ], ), - ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: child, + ), + ], + ), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 10), Text( "Featured PayNyms", - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.sectionLabelMedium12(context), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.sectionLabelMedium12(context), ), const SizedBox(height: 12), FeaturedPaynymsWidget(walletId: widget.walletId), const SizedBox(height: 24), Text( "Add new", - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.sectionLabelMedium12(context), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.sectionLabelMedium12(context), ), const SizedBox(height: 12), if (isDesktop) @@ -270,10 +260,9 @@ class _AddNewPaynymFollowViewState children: [ RoundedContainer( padding: const EdgeInsets.all(0), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, height: 56, child: Center( child: TextField( @@ -286,15 +275,15 @@ class _AddNewPaynymFollowViewState _searchString = value; }); }, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) .extension()! .textFieldActiveText, - // height: 1.8, - ), + // height: 1.8, + ), decoration: InputDecoration( hintText: "Paste payment code", hoverColor: Colors.transparent, @@ -315,38 +304,32 @@ class _AddNewPaynymFollowViewState children: [ _searchController.text.isNotEmpty ? TextFieldIconButton( - onTap: _clear, - child: RoundedContainer( - padding: const EdgeInsets.all( - 8, + onTap: _clear, + child: RoundedContainer( + padding: const EdgeInsets.all( + 8, + ), + color: Theme.of(context) + .extension()! + .buttonBackSecondary, + child: const XIcon(), ), - color: - Theme.of(context) - .extension< - StackColors - >()! - .buttonBackSecondary, - child: const XIcon(), - ), - ) + ) : TextFieldIconButton( - key: const Key( - "paynymPasteAddressFieldButtonKey", - ), - onTap: _paste, - child: RoundedContainer( - padding: const EdgeInsets.all( - 8, + key: const Key( + "paynymPasteAddressFieldButtonKey", + ), + onTap: _paste, + child: RoundedContainer( + padding: const EdgeInsets.all( + 8, + ), + color: Theme.of(context) + .extension()! + .buttonBackSecondary, + child: const ClipboardIcon(), ), - color: - Theme.of(context) - .extension< - StackColors - >()! - .buttonBackSecondary, - child: const ClipboardIcon(), ), - ), TextFieldIconButton( key: const Key( "paynymScanQrButtonKey", @@ -354,10 +337,9 @@ class _AddNewPaynymFollowViewState onTap: _scanQr, child: RoundedContainer( padding: const EdgeInsets.all(8), - color: - Theme.of(context) - .extension()! - .buttonBackSecondary, + color: Theme.of(context) + .extension()! + .buttonBackSecondary, child: const QrCodeIcon(), ), ), @@ -392,39 +374,40 @@ class _AddNewPaynymFollowViewState }); }, style: STextStyles.field(context), - decoration: standardInputDecoration( - "Paste payment code", - searchFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - suffixIcon: Padding( - padding: const EdgeInsets.only(right: 8), - child: UnconstrainedBox( - child: Row( - children: [ - _searchController.text.isNotEmpty - ? TextFieldIconButton( - onTap: _clear, - child: const XIcon(), - ) - : TextFieldIconButton( - key: const Key( - "paynymPasteAddressFieldButtonKey", - ), - onTap: _paste, - child: const ClipboardIcon(), + decoration: + standardInputDecoration( + "Paste payment code", + searchFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + suffixIcon: Padding( + padding: const EdgeInsets.only(right: 8), + child: UnconstrainedBox( + child: Row( + children: [ + _searchController.text.isNotEmpty + ? TextFieldIconButton( + onTap: _clear, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "paynymPasteAddressFieldButtonKey", + ), + onTap: _paste, + child: const ClipboardIcon(), + ), + TextFieldIconButton( + key: const Key("paynymScanQrButtonKey"), + onTap: _scanQr, + child: const QrCodeIcon(), ), - TextFieldIconButton( - key: const Key("paynymScanQrButtonKey"), - onTap: _scanQr, - child: const QrCodeIcon(), + ], ), - ], + ), ), ), - ), - ), ), ), if (!isDesktop) const SizedBox(height: 12), @@ -433,21 +416,19 @@ class _AddNewPaynymFollowViewState if (_didSearch) const SizedBox(height: 20), if (_didSearch && _searchResult == null) RoundedWhiteContainer( - borderColor: - isDesktop - ? Theme.of( - context, - ).extension()!.backgroundAppBar - : null, + borderColor: isDesktop + ? Theme.of( + context, + ).extension()!.backgroundAppBar + : null, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( "Nothing found. Please check the payment code.", - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.label(context), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.label(context), ), ], ), @@ -455,12 +436,11 @@ class _AddNewPaynymFollowViewState if (_didSearch && _searchResult != null) RoundedWhiteContainer( padding: const EdgeInsets.all(0), - borderColor: - isDesktop - ? Theme.of( - context, - ).extension()!.backgroundAppBar - : null, + borderColor: isDesktop + ? Theme.of( + context, + ).extension()!.backgroundAppBar + : null, child: PaynymCard( key: UniqueKey(), label: _searchResult!.nymName, diff --git a/lib/pages/paynym/paynym_claim_view.dart b/lib/pages/paynym/paynym_claim_view.dart index 8d61e139c5..f29c50eaa0 100644 --- a/lib/pages/paynym/paynym_claim_view.dart +++ b/lib/pages/paynym/paynym_claim_view.dart @@ -32,10 +32,7 @@ import 'dialogs/claiming_paynym_dialog.dart'; import 'paynym_home_view.dart'; class PaynymClaimView extends ConsumerStatefulWidget { - const PaynymClaimView({ - super.key, - required this.walletId, - }); + const PaynymClaimView({super.key, required this.walletId}); final String walletId; @@ -80,23 +77,20 @@ class _PaynymClaimViewState extends ConsumerState { 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) - .extension()! - .textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, shadows: const [], icon: SvgPicture.asset( Assets.svg.arrowLeft, width: 18, height: 18, - color: Theme.of(context) - .extension()! - .topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), onPressed: Navigator.of(context).pop, ), @@ -107,13 +101,8 @@ class _PaynymClaimViewState extends ConsumerState { height: 42, color: Theme.of(context).extension()!.textDark, ), - const SizedBox( - width: 10, - ), - Text( - "PayNym", - style: STextStyles.desktopH3(context), - ), + const SizedBox(width: 10), + Text("PayNym", style: STextStyles.desktopH3(context)), ], ), ) @@ -129,52 +118,36 @@ class _PaynymClaimViewState extends ConsumerState { 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: ConditionalParent( condition: isDesktop, - builder: (child) => SizedBox( - width: 328, - child: child, - ), + builder: (child) => SizedBox(width: 328, child: child), child: Column( children: [ - const Spacer( - flex: 1, - ), + const Spacer(flex: 1), SvgPicture.asset( Assets.svg.unclaimedPaynym, width: MediaQuery.of(context).size.width / 2, ), - const SizedBox( - height: 20, - ), + const SizedBox(height: 20), Text( "You do not have a PayNym yet.\nClaim yours now!", style: isDesktop ? STextStyles.desktopSubtitleH2(context).copyWith( - color: Theme.of(context) - .extension()! - .textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ) : STextStyles.baseXS(context).copyWith( - color: Theme.of(context) - .extension()! - .textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), textAlign: TextAlign.center, ), - if (isDesktop) - const SizedBox( - height: 30, - ), - if (!isDesktop) - const Spacer( - flex: 2, - ), + if (isDesktop) const SizedBox(height: 30), + if (!isDesktop) const Spacer(flex: 2), PrimaryButton( label: "Claim", onPressed: () async { @@ -187,13 +160,17 @@ class _PaynymClaimViewState extends ConsumerState { ).then((value) => shouldCancel = value == true), ); - final wallet = ref.read(pWallets).getWallet(widget.walletId) - as PaynymInterface; + final wallet = + ref.read(pWallets).getWallet(widget.walletId) + as PaynymInterface; if (shouldCancel) return; - // get payment code - final pCode = await wallet.getPaymentCode(isSegwit: false); + // get payment code with taproot + segwit feature bits + final pCode = await wallet.getPaymentCode( + isSegwit: true, + isTaproot: true, + ); if (shouldCancel) return; @@ -206,30 +183,38 @@ class _PaynymClaimViewState extends ConsumerState { if (shouldCancel) return; if (created.value!.claimed) { - // payment code already claimed + // payment code already claimed — load account and navigate debugPrint("pcode already claimed!!"); - // final account = - // await ref.read(paynymAPIProvider).nym(pCode.toString()); - // if (!account.value!.segwit) { - // for (int i = 0; i < 100; i++) { - // final result = await _addSegwitCode(account.value!); - // if (result == true) { - // break; - // } - // } - // } + final account = await ref + .read(paynymAPIProvider) + .nym(pCode.toString()); - if (mounted) { + if (shouldCancel) return; + + if (account.value != null && mounted) { + ref.read(myPaynymAccountStateProvider.state).state = + account.value!; if (isDesktop) { Navigator.of(context, rootNavigator: true).pop(); Navigator.of(context).pop(); } else { - Navigator.of(context).popUntil( - ModalRoute.withName( - WalletView.routeName, - ), - ); + Navigator.of( + context, + ).popUntil(ModalRoute.withName(WalletView.routeName)); + } + await Navigator.of(context).pushNamed( + PaynymHomeView.routeName, + arguments: widget.walletId, + ); + } else if (mounted) { + if (isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + Navigator.of(context).pop(); + } else { + Navigator.of( + context, + ).popUntil(ModalRoute.withName(WalletView.routeName)); } } return; @@ -237,14 +222,28 @@ class _PaynymClaimViewState extends ConsumerState { if (shouldCancel) return; - final token = - await ref.read(paynymAPIProvider).token(pCode.toString()); + final token = await ref + .read(paynymAPIProvider) + .token(pCode.toString()); + + debugPrint("token result: $token"); if (shouldCancel) return; + if (token.value == null) { + debugPrint("token fetch failed: ${token.message}"); + if (mounted) { + Navigator.of(context, rootNavigator: isDesktop).pop(); + } + return; + } + // sign token with notification private key - final signature = - await wallet.signStringWithNotificationKey(token.value!); + final signature = await wallet.signStringWithNotificationKey( + token.value!, + ); + + debugPrint("signature: $signature"); if (shouldCancel) return; @@ -253,11 +252,16 @@ class _PaynymClaimViewState extends ConsumerState { .read(paynymAPIProvider) .claim(token.value!, signature); + debugPrint("claim result: $claim"); + if (shouldCancel) return; - if (claim.value?.claimed == pCode.toString()) { - final account = - await ref.read(paynymAPIProvider).nym(pCode.toString()); + if (claim.statusCode == 200 || + claim.value?.claimed == pCode.toString() || + claim.value?.claimed == "true") { + final account = await ref + .read(paynymAPIProvider) + .nym(pCode.toString()); // if (!account.value!.segwit) { // for (int i = 0; i < 100; i++) { // final result = await _addSegwitCode(account.value!); @@ -274,11 +278,9 @@ class _PaynymClaimViewState extends ConsumerState { Navigator.of(context, rootNavigator: true).pop(); Navigator.of(context).pop(); } else { - Navigator.of(context).popUntil( - ModalRoute.withName( - WalletView.routeName, - ), - ); + Navigator.of( + context, + ).popUntil(ModalRoute.withName(WalletView.routeName)); } await Navigator.of(context).pushNamed( PaynymHomeView.routeName, @@ -286,14 +288,18 @@ class _PaynymClaimViewState extends ConsumerState { ); } } else if (mounted && !shouldCancel) { + debugPrint( + "claim failed or mismatch: " + "claimed=${claim.value?.claimed}, " + "expected=${pCode.toString()}, " + "statusCode=${claim.statusCode}, " + "message=${claim.message}", + ); Navigator.of(context, rootNavigator: isDesktop).pop(); } }, ), - if (isDesktop) - const Spacer( - flex: 2, - ), + if (isDesktop) const Spacer(flex: 2), ], ), ), diff --git a/lib/pages/receive_view/addresses/address_card.dart b/lib/pages/receive_view/addresses/address_card.dart index 901cd578be..f8b6ec8067 100644 --- a/lib/pages/receive_view/addresses/address_card.dart +++ b/lib/pages/receive_view/addresses/address_card.dart @@ -50,6 +50,7 @@ class AddressCard extends ConsumerStatefulWidget { required this.coin, this.onPressed, this.clipboard = const ClipboardWrapper(), + this.compact = false, }); final int addressId; @@ -57,6 +58,7 @@ class AddressCard extends ConsumerStatefulWidget { final CryptoCurrency coin; final ClipboardInterface clipboard; final VoidCallback? onPressed; + final bool compact; @override ConsumerState createState() => _AddressCardState(); @@ -142,11 +144,10 @@ class _AddressCardState extends ConsumerState { @override void initState() { - address = - MainDB.instance.isar.addresses - .where() - .idEqualTo(widget.addressId) - .findFirstSync()!; + address = MainDB.instance.isar.addresses + .where() + .idEqualTo(widget.addressId) + .findFirstSync()!; label = MainDB.instance.getAddressLabelSync(widget.walletId, address.value); Id? id = label?.id; @@ -155,12 +156,11 @@ class _AddressCardState extends ConsumerState { walletId: widget.walletId, addressString: address.value, value: "", - tags: - address.subType == AddressSubType.receiving - ? ["receiving"] - : address.subType == AddressSubType.change - ? ["change"] - : null, + tags: address.subType == AddressSubType.receiving + ? ["receiving"] + : address.subType == AddressSubType.change + ? ["change"] + : null, ); id = MainDB.instance.putAddressLabelSync(label!); } @@ -181,20 +181,19 @@ class _AddressCardState extends ConsumerState { } return ConditionalParent( - condition: isDesktop, - builder: - (child) => Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SvgPicture.file( - File(ref.watch(coinIconProvider(widget.coin))), - width: 32, - height: 32, - ), - const SizedBox(width: 12), - Expanded(child: child), - ], + condition: isDesktop && !widget.compact, + builder: (child) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SvgPicture.file( + File(ref.watch(coinIconProvider(widget.coin))), + width: 32, + height: 32, ), + const SizedBox(width: 12), + Expanded(child: child), + ], + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -230,129 +229,124 @@ class _AddressCardState extends ConsumerState { ), ], ), - const SizedBox(height: 10), - Row( - children: [ - CustomTextButton( - text: "Copy address", - onTap: () { - widget.clipboard - .setData(ClipboardData(text: address.value)) - .then((value) { - if (context.mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.info, - message: "Copied to clipboard", - context: context, - ), - ); - } - }); - }, - ), - const SizedBox(width: 16), - CustomTextButton( - text: "Show QR code", - onTap: () async { - await showDialog( - context: context, - builder: (_) { - return StackDialogBase( - child: Column( - children: [ - if (label!.value.isNotEmpty) - Text( - label!.value, - style: STextStyles.w600_18(context), + if (!widget.compact) const SizedBox(height: 10), + if (!widget.compact) + Row( + children: [ + CustomTextButton( + text: "Copy address", + onTap: () { + widget.clipboard + .setData(ClipboardData(text: address.value)) + .then((value) { + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + context: context, ), - if (label!.value.isNotEmpty) - const SizedBox(height: 8), - Text( - address.value, - style: STextStyles.w500_16( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textSubtitle1, + ); + } + }); + }, + ), + const SizedBox(width: 16), + CustomTextButton( + text: "Show QR code", + onTap: () async { + await showDialog( + context: context, + builder: (_) { + return StackDialogBase( + child: Column( + children: [ + if (label!.value.isNotEmpty) + Text( + label!.value, + style: STextStyles.w600_18(context), + ), + if (label!.value.isNotEmpty) + const SizedBox(height: 8), + Text( + address.value, + style: STextStyles.w500_16(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ), ), - ), - const SizedBox(height: 16), - Center( - child: RepaintBoundary( - key: _qrKey, - child: QR( - data: AddressUtils.buildUriString( - widget.coin.uriScheme, - address.value, - {}, + const SizedBox(height: 16), + Center( + child: RepaintBoundary( + key: _qrKey, + child: QR( + data: AddressUtils.buildUriString( + widget.coin.uriScheme, + address.value, + {}, + ), + size: 220, ), - size: 220, ), ), - ), - const SizedBox(height: 16), - Row( - children: [ - if (!isDesktop) - Expanded( - child: SecondaryButton( - label: "Share", - buttonHeight: - isDesktop - ? ButtonHeight.l - : null, - icon: SvgPicture.asset( - Assets.svg.share, - width: 14, - height: 14, - color: - Theme.of(context) - .extension()! - .buttonTextSecondary, + const SizedBox(height: 16), + Row( + children: [ + if (!isDesktop) + Expanded( + child: SecondaryButton( + label: "Share", + buttonHeight: isDesktop + ? ButtonHeight.l + : null, + icon: SvgPicture.asset( + Assets.svg.share, + width: 14, + height: 14, + color: Theme.of(context) + .extension()! + .buttonTextSecondary, + ), + onPressed: () async { + await _capturePng(false); + }, ), - onPressed: () async { - await _capturePng(false); - }, ), - ), - if (isDesktop) - Expanded( - child: PrimaryButton( - buttonHeight: - isDesktop - ? ButtonHeight.l - : null, - onPressed: () async { - // TODO: add save functionality instead of share - // save works on linux at the moment - await _capturePng(true); - }, - label: "Save", - icon: SvgPicture.asset( - Assets.svg.arrowDown, - width: 20, - height: 20, - color: - Theme.of(context) - .extension()! - .buttonTextPrimary, + if (isDesktop) + Expanded( + child: PrimaryButton( + buttonHeight: isDesktop + ? ButtonHeight.l + : null, + onPressed: () async { + // TODO: add save functionality instead of share + // save works on linux at the moment + await _capturePng(true); + }, + label: "Save", + icon: SvgPicture.asset( + Assets.svg.arrowDown, + width: 20, + height: 20, + color: Theme.of(context) + .extension()! + .buttonTextPrimary, + ), ), ), - ), - ], - ), - ], - ), - ); - }, - ); - }, - ), - ], - ), + ], + ), + ], + ), + ); + }, + ); + }, + ), + ], + ), // if (label!.tags != null && label!.tags!.isNotEmpty) // Wrap( // spacing: 10, diff --git a/lib/pages/receive_view/receive_view.dart b/lib/pages/receive_view/receive_view.dart index 5fa78b359a..61f8ae5fbe 100644 --- a/lib/pages/receive_view/receive_view.dart +++ b/lib/pages/receive_view/receive_view.dart @@ -33,6 +33,7 @@ import '../../utilities/text_styles.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/wallet/impl/bitcoin_wallet.dart'; +import '../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; import '../../wallets/wallet/intermediate/bip39_hd_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/bcash_interface.dart'; @@ -54,6 +55,8 @@ import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; import 'addresses/wallet_addresses_view.dart'; import 'generate_receiving_uri_qr_code_view.dart'; +import 'sub_widgets/epic_slatepack_entry_dialog.dart'; +import 'sub_widgets/epic_slatepack_import_dialog.dart'; import 'sub_widgets/mwc_slatepack_import_dialog.dart'; import 'sub_widgets/slatepack_entry_dialog.dart'; @@ -112,12 +115,10 @@ class _ReceiveViewState extends ConsumerState { if (mounted) { await showDialog( context: context, - builder: - (context) => StackOkDialog( - title: "Slatepack receive error", - message: - ex?.toString() ?? "Unexpected result without exception", - ), + builder: (context) => StackOkDialog( + title: "Slatepack receive error", + message: ex?.toString() ?? "Unexpected result without exception", + ), ); } return; @@ -127,27 +128,86 @@ class _ReceiveViewState extends ConsumerState { final response = await showDialog<({String responseSlatepack, bool wasEncrypted})>( context: context, - builder: - (context) => SDialog( - child: MwcSlatepackImportDialog( - walletId: widget.walletId, - clipboard: widget.clipboard, - rawSlatepack: result.raw, - decoded: result.result, - slatepackType: result.type, - ), - ), + builder: (context) => SDialog( + child: MwcSlatepackImportDialog( + walletId: widget.walletId, + clipboard: widget.clipboard, + rawSlatepack: result.raw, + decoded: result.result, + slatepackType: result.type, + ), + ), ); if (mounted && response != null) { await showDialog( context: context, barrierDismissible: false, - builder: - (context) => SlatepackResponseDialog( - responseSlatepack: response.responseSlatepack, - wasEncrypted: response.wasEncrypted, + builder: (context) => SlatepackResponseDialog( + responseSlatepack: response.responseSlatepack, + wasEncrypted: response.wasEncrypted, + ), + ); + } + } + } + } + + Future _importEpicSlatepack() async { + final slatepackString = await showDialog( + context: context, + builder: (context) => const EpicSlatepackEntryDialog(), + ); + + if (slatepackString == null) return; + if (mounted) { + final wallet = + ref.read(pWallets).getWallet(walletId) as EpiccashWallet; + + Exception? ex; + final result = await showLoading( + whileFuture: wallet.fullDecodeSlatepack(slatepackString), + context: context, + message: "Decoding slate...", + onException: (e) => ex = e, + ); + + if (result == null || ex != null) { + if (mounted) { + await showDialog( + context: context, + builder: (context) => StackOkDialog( + title: "Slate receive error", + message: ex?.toString() ?? "Unexpected result without exception", + ), + ); + } + return; + } + + if (mounted) { + final response = + await showDialog<({String responseSlatepack, bool wasEncrypted})>( + context: context, + builder: (context) => SDialog( + child: EpicSlatepackImportDialog( + walletId: widget.walletId, + clipboard: widget.clipboard, + rawSlatepack: result.raw, + decoded: result.result, + slatepackType: result.type, ), + ), + ); + + if (mounted && response != null) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => EpicSlatepackResponseDialog( + responseSlatepack: response.responseSlatepack, + wasEncrypted: response.wasEncrypted, + ), ); } } @@ -182,7 +242,9 @@ class _ReceiveViewState extends ConsumerState { final Address? address; if (wallet is Bip39HDWallet && wallet is! BCashInterface) { DerivePathType? type; - if (wallet.isViewOnly && wallet is ExtendedKeysInterface) { + if (wallet.isViewOnly && + wallet is ExtendedKeysInterface && + wallet.viewOnlyType != .spark) { final voData = await wallet.getViewOnlyWalletData() as ExtendedKeysViewOnlyWalletData; @@ -259,10 +321,7 @@ class _ReceiveViewState extends ConsumerState { ), ); - final address = await wallet.generateNextSparkAddress(); - await ref.read(mainDBProvider).isar.writeTxn(() async { - await ref.read(mainDBProvider).isar.addresses.put(address); - }); + final address = await wallet.generateNextSparkAddress(saveToDB: true); shouldPop = true; @@ -300,6 +359,32 @@ class _ReceiveViewState extends ConsumerState { } } + StreamSubscription _sub(AddressType type) { + return ref + .read(mainDBProvider) + .isar + .addresses + .where() + .walletIdEqualTo(walletId) + .filter() + .typeEqualTo(type) + .and() + .subTypeEqualTo(AddressSubType.receiving) + .sortByDerivationIndexDesc() + .findFirst() + .asStream() + .listen((event) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _addressMap[type] = + event?.value ?? _addressMap[type] ?? "[No address yet]"; + }); + } + }); + }); + } + @override void initState() { walletId = widget.walletId; @@ -314,6 +399,9 @@ class _ReceiveViewState extends ConsumerState { if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { _showMultiType = false; + if (wallet.viewOnlyType == .spark) { + _walletAddressTypes.add(.spark); + } } else { _showMultiType = _supportsSpark || @@ -323,7 +411,9 @@ class _ReceiveViewState extends ConsumerState { wallet.supportedAddressTypes.length > 1); } - _walletAddressTypes.add(wallet.info.mainAddressType); + if (_walletAddressTypes.isEmpty) { + _walletAddressTypes.add(wallet.info.mainAddressType); + } if (_showMultiType) { if (_supportsSpark) { @@ -341,7 +431,9 @@ class _ReceiveViewState extends ConsumerState { } } - if (_walletAddressTypes.length > 1 && wallet is BitcoinWallet) { + if (_walletAddressTypes.length > 1 && + wallet is BitcoinWallet && + !wallet.info.isLegacyAddressesEnabled) { _walletAddressTypes.removeWhere((e) => e == AddressType.p2pkh); } @@ -351,30 +443,7 @@ class _ReceiveViewState extends ConsumerState { if (_showMultiType) { for (final type in _walletAddressTypes) { - _addressSubMap[type] = ref - .read(mainDBProvider) - .isar - .addresses - .where() - .walletIdEqualTo(walletId) - .filter() - .typeEqualTo(type) - .and() - .not() - .subTypeEqualTo(AddressSubType.change) - .sortByDerivationIndexDesc() - .findFirst() - .asStream() - .listen((event) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _addressMap[type] = - event?.value ?? _addressMap[type] ?? "[No address yet]"; - }); - } - }); - }); + _addressSubMap[type] = _sub(type); } } @@ -399,42 +468,40 @@ class _ReceiveViewState extends ConsumerState { if (prev?.isMwebEnabled != next.isMwebEnabled) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { + const type = AddressType.mweb; setState(() { supportsMweb = next.isMwebEnabled; - if (supportsMweb && - !_walletAddressTypes.contains(AddressType.mweb)) { - _walletAddressTypes.insert(0, AddressType.mweb); - _addressSubMap[AddressType.mweb] = ref - .read(mainDBProvider) - .isar - .addresses - .where() - .walletIdEqualTo(walletId) - .filter() - .typeEqualTo(AddressType.mweb) - .and() - .not() - .subTypeEqualTo(AddressSubType.change) - .sortByDerivationIndexDesc() - .findFirst() - .asStream() - .listen((event) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _addressMap[AddressType.mweb] = - event?.value ?? - _addressMap[AddressType.mweb] ?? - "[No address yet]"; - }); - } - }); - }); + if (supportsMweb && !_walletAddressTypes.contains(type)) { + _walletAddressTypes.insert(0, type); + + _addressSubMap[type] = _sub(type); } else { - _walletAddressTypes.remove(AddressType.mweb); - _addressSubMap[AddressType.mweb]?.cancel(); - _addressSubMap.remove(AddressType.mweb); + _walletAddressTypes.remove(type); + _addressSubMap[type]?.cancel(); + _addressSubMap.remove(type); + } + + if (_currentIndex >= _walletAddressTypes.length) { + _currentIndex = _walletAddressTypes.length - 1; + } + }); + } + }); + } + + if (prev?.isLegacyAddressesEnabled != next.isLegacyAddressesEnabled) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + const type = AddressType.p2pkh; + setState(() { + if (!_walletAddressTypes.contains(type)) { + _walletAddressTypes.insert(0, type); + _addressSubMap[type] = _sub(type); + } else { + _walletAddressTypes.remove(type); + _addressSubMap[type]?.cancel(); + _addressSubMap.remove(type); } if (_currentIndex >= _walletAddressTypes.length) { @@ -494,10 +561,9 @@ class _ReceiveViewState extends ConsumerState { color: Theme.of(context).extension()!.background, icon: SvgPicture.asset( Assets.svg.verticalEllipsis, - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, width: 20, height: 20, ), @@ -514,10 +580,9 @@ class _ReceiveViewState extends ConsumerState { right: 10, child: Container( decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.popupBG, + color: Theme.of( + context, + ).extension()!.popupBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -580,100 +645,94 @@ class _ReceiveViewState extends ConsumerState { children: [ ConditionalParent( condition: _showMultiType, - builder: - (child) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - "Address type", - style: STextStyles.w500_14(context).copyWith( - color: - Theme.of( - context, - ).extension()!.infoItemLabel, - ), - ), - const SizedBox(height: 10), - DropdownButtonHideUnderline( - child: DropdownButton2( - value: _currentIndex, - items: [ - for ( - int i = 0; - i < _walletAddressTypes.length; - i++ - ) - DropdownMenuItem( - value: i, - child: Text( - _supportsSpark && - _walletAddressTypes[i] == - AddressType.p2pkh - ? "Transparent address" - : "${_walletAddressTypes[i].readableName} address", - style: STextStyles.w500_14(context), - ), - ), - ], - onChanged: (value) { - if (value != null && - value != _currentIndex) { - setState(() { - _currentIndex = value; - }); - } - }, - isExpanded: true, - iconStyleData: IconStyleData( - icon: Padding( - padding: const EdgeInsets.only(right: 10), - child: SvgPicture.asset( - Assets.svg.chevronDown, - width: 12, - height: 6, - color: - Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, - ), + builder: (child) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Address type", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of( + context, + ).extension()!.infoItemLabel, + ), + ), + const SizedBox(height: 10), + DropdownButtonHideUnderline( + child: DropdownButton2( + value: _currentIndex, + items: [ + for ( + int i = 0; + i < _walletAddressTypes.length; + i++ + ) + DropdownMenuItem( + value: i, + child: Text( + _supportsSpark && + _walletAddressTypes[i] == + AddressType.p2pkh + ? "Transparent address" + : "${_walletAddressTypes[i].readableName} address", + style: STextStyles.w500_14(context), ), ), - buttonStyleData: ButtonStyleData( - decoration: BoxDecoration( - color: - Theme.of(context) - .extension()! - .textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), + ], + onChanged: (value) { + if (value != null && value != _currentIndex) { + setState(() { + _currentIndex = value; + }); + } + }, + isExpanded: true, + iconStyleData: IconStyleData( + icon: Padding( + padding: const EdgeInsets.only(right: 10), + child: SvgPicture.asset( + Assets.svg.chevronDown, + width: 12, + height: 6, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, ), - dropdownStyleData: DropdownStyleData( - offset: const Offset(0, -10), - elevation: 0, - decoration: BoxDecoration( - color: - Theme.of(context) - .extension()! - .textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), + ), + ), + buttonStyleData: ButtonStyleData( + decoration: BoxDecoration( + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, ), - menuItemStyleData: const MenuItemStyleData( - padding: EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), + ), + ), + dropdownStyleData: DropdownStyleData( + offset: const Offset(0, -10), + elevation: 0, + decoration: BoxDecoration( + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, ), ), ), - const SizedBox(height: 12), - child, - ], + menuItemStyleData: const MenuItemStyleData( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + ), + ), ), + const SizedBox(height: 12), + child, + ], + ), child: GestureDetector( onTap: () { HapticFeedback.lightImpact(); @@ -701,10 +760,9 @@ class _ReceiveViewState extends ConsumerState { Assets.svg.copy, width: 10, height: 10, - color: - Theme.of(context) - .extension()! - .infoItemIcons, + color: Theme.of(context) + .extension()! + .infoItemIcons, ), const SizedBox(width: 4), Text( @@ -753,14 +811,14 @@ class _ReceiveViewState extends ConsumerState { label: "Generate new address", onPressed: supportsMweb && - _walletAddressTypes[_currentIndex] == - AddressType.mweb - ? generateNewMwebAddress - : _supportsSpark && - _walletAddressTypes[_currentIndex] == - AddressType.spark - ? generateNewSparkAddress - : generateNewAddress, + _walletAddressTypes[_currentIndex] == + AddressType.mweb + ? generateNewMwebAddress + : _supportsSpark && + _walletAddressTypes[_currentIndex] == + AddressType.spark + ? generateNewSparkAddress + : generateNewAddress, ), // MWC Slatepack import button. if (coin is Mimblewimblecoin) ...[ @@ -770,6 +828,14 @@ class _ReceiveViewState extends ConsumerState { onPressed: _importSlatepack, ), ], + // Epic Cash Slate import button. + if (coin is Epiccash) ...[ + const SizedBox(height: 12), + SecondaryButton( + label: "Import Slate", + onPressed: _importEpicSlatepack, + ), + ], const SizedBox(height: 30), RoundedWhiteContainer( child: Padding( @@ -794,11 +860,10 @@ class _ReceiveViewState extends ConsumerState { RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: - (_) => GenerateUriQrCodeView( - coin: coin, - receivingAddress: address, - ), + builder: (_) => GenerateUriQrCodeView( + coin: coin, + receivingAddress: address, + ), settings: const RouteSettings( name: GenerateUriQrCodeView.routeName, ), diff --git a/lib/pages/receive_view/sol_token_receive_view.dart b/lib/pages/receive_view/sol_token_receive_view.dart new file mode 100644 index 0000000000..084335b008 --- /dev/null +++ b/lib/pages/receive_view/sol_token_receive_view.dart @@ -0,0 +1,262 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../models/isar/models/isar_models.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../providers/providers.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/clipboard_interface.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/text_styles.dart'; +import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/icon_widgets/sol_token_icon.dart'; +import '../../widgets/qr.dart'; +import '../../widgets/rounded_white_container.dart'; + +class SolTokenReceiveView extends ConsumerStatefulWidget { + const SolTokenReceiveView({ + super.key, + required this.walletId, + required this.tokenMint, + this.clipboard = const ClipboardWrapper(), + }); + + static const String routeName = "/solTokenReceiveView"; + + final String walletId; + final String tokenMint; + final ClipboardInterface clipboard; + + @override + ConsumerState createState() => + _SolTokenReceiveViewState(); +} + +class _SolTokenReceiveViewState extends ConsumerState { + late final String walletId; + late final String tokenMint; + late final ClipboardInterface clipboard; + + @override + void initState() { + walletId = widget.walletId; + tokenMint = widget.tokenMint; + clipboard = widget.clipboard; + super.initState(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + final walletName = ref.watch(pWalletName(walletId)); + final receivingAddress = ref.watch(pWalletReceivingAddress(walletId)); + + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + tokenWallet != null + ? "Receive ${tokenWallet.tokenSymbol}" + : "Receive Token", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 12), + Text( + "Your Solana address", + style: STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 12), + RoundedWhiteContainer( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: SizedBox( + width: 200, + height: 200, + child: QR( + data: receivingAddress, + size: 200, + ), + ), + ), + const SizedBox(height: 24), + Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.popupBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + if (tokenWallet != null) + SolTokenIcon( + mintAddress: tokenMint, + ) + else + SizedBox.square(dimension: 32), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + walletName, + style: STextStyles.titleBold12( + context, + ).copyWith(fontSize: 14), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + Text( + "Solana wallet", + style: STextStyles.label( + context, + ).copyWith(fontSize: 10), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () async { + await clipboard.setData( + ClipboardData(text: receivingAddress), + ); + if (mounted) { + showFloatingFlushBar( + type: FlushBarType.info, + message: "Address copied", + context: context, + ); + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.highlight, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + Assets.svg.copy, + width: 16, + height: 16, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.textDark, + BlendMode.srcIn, + ), + ), + const SizedBox(width: 8), + Text( + "Copy", + style: + STextStyles.smallMed12(context) + .copyWith( + color: Theme.of( + context, + ).extension()! + .textDark, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + Text( + "Address", + style: STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.popupBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: SelectableText( + receivingAddress, + style: STextStyles.label(context), + ), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/receive_view/sub_widgets/epic_slatepack_entry_dialog.dart b/lib/pages/receive_view/sub_widgets/epic_slatepack_entry_dialog.dart new file mode 100644 index 0000000000..c3583e7c49 --- /dev/null +++ b/lib/pages/receive_view/sub_widgets/epic_slatepack_entry_dialog.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/global/barcode_scanner_provider.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/barcode_scanner_interface.dart'; +import '../../../utilities/clipboard_interface.dart'; +import '../../../utilities/constants.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/icon_widgets/clipboard_icon.dart'; +import '../../../widgets/icon_widgets/qrcode_icon.dart'; +import '../../../widgets/icon_widgets/x_icon.dart'; +import '../../../widgets/stack_dialog.dart'; +import '../../../widgets/stack_text_field.dart'; +import '../../../widgets/textfield_icon_button.dart'; + +class EpicSlatepackEntryDialog extends ConsumerStatefulWidget { + const EpicSlatepackEntryDialog({ + super.key, + this.clipboard = const ClipboardWrapper(), + }); + + final ClipboardInterface clipboard; + + @override + ConsumerState createState() => + _EpicSlatepackEntryDialogState(); +} + +class _EpicSlatepackEntryDialogState extends ConsumerState { + final _receiveSlateController = TextEditingController(); + final _slateFocusNode = FocusNode(); + + bool _slateToggleFlag = false; + + Future _pasteSlatepack() async { + final ClipboardData? data = await widget.clipboard.getData( + Clipboard.kTextPlain, + ); + if (data?.text != null && data!.text!.isNotEmpty) { + _receiveSlateController.text = data.text!; + setState(() { + _slateToggleFlag = _receiveSlateController.text.isNotEmpty; + }); + } + } + + Future _scanQr() async { + try { + if (_slateFocusNode.hasFocus) { + _slateFocusNode.unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + + if (mounted) { + final qrResult = await ref.read(pBarcodeScanner).scan(context: context); + if (qrResult.rawContent != null && qrResult.rawContent!.isNotEmpty) { + _receiveSlateController.text = qrResult.rawContent!; + setState(() { + _slateToggleFlag = _receiveSlateController.text.isNotEmpty; + }); + } + } + } on PlatformException catch (e, s) { + if (mounted) { + try { + await checkCamPermDeniedMobileAndOpenAppSettings( + context, + logging: Logging.instance, + ); + } catch (e, s) { + Logging.instance.e( + "Failed to check cam permissions", + error: e, + stackTrace: s, + ); + } + } else { + Logging.instance.e( + "Failed to get camera permissions while trying to scan qr code in SendView: ", + error: e, + stackTrace: s, + ); + } + } + } + + @override + void dispose() { + _receiveSlateController.dispose(); + _slateFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StackDialogBase( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Receive Slate", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + textAlign: TextAlign.left, + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + minLines: 1, + maxLines: 5, + key: const Key("receiveViewEpicSlateFieldKey"), + controller: _receiveSlateController, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + toolbarOptions: const ToolbarOptions( + copy: false, + cut: false, + paste: true, + selectAll: false, + ), + onChanged: (newValue) { + setState(() { + _slateToggleFlag = newValue.isNotEmpty; + }); + }, + focusNode: _slateFocusNode, + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + height: 1.8, + ), + decoration: + standardInputDecoration( + "Enter Slate JSON", + _slateFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: + 12, // Adjust vertical padding for better alignment + ), + suffixIcon: Padding( + padding: _receiveSlateController.text.isEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _slateToggleFlag + ? TextFieldIconButton( + key: const Key( + "receiveViewClearEpicSlateFieldButtonKey", + ), + onTap: () { + _receiveSlateController.text = ""; + setState(() { + _slateToggleFlag = false; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "receiveViewPasteEpicSlateFieldButtonKey", + ), + onTap: _pasteSlatepack, + child: _receiveSlateController.text.isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_receiveSlateController.text.isEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("sendViewScanQrButtonKey"), + onTap: _scanQr, + child: const QrCodeIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox(height: 16), + PrimaryButton( + label: "Import", + enabled: _slateToggleFlag, + onPressed: !_slateToggleFlag + ? null + : () => Navigator.of(context).pop(_receiveSlateController.text), + ), + const SizedBox(height: 16), + SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ], + ), + ); + } +} diff --git a/lib/pages/receive_view/sub_widgets/epic_slatepack_import_dialog.dart b/lib/pages/receive_view/sub_widgets/epic_slatepack_import_dialog.dart new file mode 100644 index 0000000000..769a58a94f --- /dev/null +++ b/lib/pages/receive_view/sub_widgets/epic_slatepack_import_dialog.dart @@ -0,0 +1,316 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/epic_slatepack_models.dart'; +import '../../../providers/global/wallets_provider.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/amount/amount_formatter.dart'; +import '../../../utilities/clipboard_interface.dart'; +import '../../../utilities/show_loading.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../wallets/wallet/impl/epiccash_wallet.dart'; +import '../../../widgets/conditional_parent.dart'; +import '../../../widgets/custom_buttons/simple_copy_button.dart'; +import '../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/detail_item.dart'; +import '../../../widgets/dialogs/s_dialog.dart'; +import '../../../widgets/rounded_white_container.dart'; +import '../../../widgets/stack_dialog.dart'; + +class EpicSlatepackImportDialog extends ConsumerStatefulWidget { + const EpicSlatepackImportDialog({ + super.key, + required this.walletId, + required this.rawSlatepack, + required this.decoded, + required this.slatepackType, + this.clipboard = const ClipboardWrapper(), + }); + + final String walletId; + final String rawSlatepack; + final EpicSlatepackDecodeResult decoded; + final String slatepackType; + final ClipboardInterface clipboard; + + @override + ConsumerState createState() => + _EpicSlatepackImportDialogState(); +} + +class _EpicSlatepackImportDialogState + extends ConsumerState { + Future<({String responseSlatepack, bool wasEncrypted})> + _processSlatepack() async { + // add delay for showloading exception catching hack fix + await Future.delayed(const Duration(seconds: 1)); + + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as EpiccashWallet; + + // Determine action based on slatepack type. + if (widget.slatepackType.contains("S1")) { + // This is an initial slatepack - receive it and create response. + final result = await wallet.receiveSlatepack(widget.rawSlatepack); + + if (result.success && result.responseSlatepack != null) { + return ( + responseSlatepack: result.responseSlatepack!, + wasEncrypted: result.wasEncrypted ?? false, + ); + } else { + throw Exception(result.error ?? 'Failed to process slatepack'); + } + } else { + throw Exception('Unsupported slatepack type: ${widget.slatepackType}'); + } + } + + Future _processPressed() async { + Exception? ex; + final result = await showLoading( + whileFuture: _processSlatepack(), + context: context, + message: "Processing slate...", + onException: (e) => ex = e, + ); + + if (result == null || ex != null) { + if (mounted) { + await showDialog( + context: context, + useRootNavigator: true, + builder: + (context) => StackOkDialog( + desktopPopRootNavigator: true, + maxWidth: Util.isDesktop ? 400 : null, + title: "Slate receive error", + message: + ex?.toString() ?? "Unexpected result without exception", + ), + ); + } + return; + } + + if (mounted) { + Navigator.of(context).pop(result); + } + } + + late final Amount? _amount; + + @override + void initState() { + final map = jsonDecode(widget.decoded.slateJson!) as Map; + + final rawAmount = BigInt.tryParse(map["amount"].toString()); + _amount = + rawAmount == null + ? null + : Amount( + rawValue: rawAmount, + fractionDigits: + ref.read(pWalletCoin(widget.walletId)).fractionDigits, + ); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isDesktop) + // Header with title and close button. + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Import Slate", + style: STextStyles.pageTitleH2(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: isDesktop ? 32 : 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ConditionalParent( + condition: isDesktop, + builder: + (child) => RoundedWhiteContainer( + borderColor: + isDesktop + ? Theme.of( + context, + ).extension()!.backgroundAppBar + : null, + padding: const EdgeInsets.all(0), + child: child, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!isDesktop) + Padding( + padding: const EdgeInsets.only(top: 24, bottom: 24), + child: Text( + "Import slate", + style: STextStyles.pageTitleH2(context), + ), + ), + + if (_amount != null) + DetailItem( + title: "Amount", + detail: ref + .watch( + pAmountFormatter( + ref.watch(pWalletCoin(widget.walletId)), + ), + ) + .format(_amount), + ), + ], + ), + ), + const SizedBox(height: 24), + ConditionalParent( + condition: isDesktop, + builder: + (child) => Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [child], + ), + child: PrimaryButton( + width: isDesktop ? 220 : null, + + buttonHeight: isDesktop ? ButtonHeight.l : null, + label: "Process", + onPressed: _processPressed, + ), + ), + if (!isDesktop) const SizedBox(height: 12), + if (!isDesktop) + SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ], + ), + ), + isDesktop ? const SizedBox(height: 32) : const SizedBox(height: 24), + ], + ); + } +} + +class EpicSlatepackResponseDialog extends StatelessWidget { + const EpicSlatepackResponseDialog({ + super.key, + required this.responseSlatepack, + required this.wasEncrypted, + }); + + final String responseSlatepack; + final bool wasEncrypted; + + @override + Widget build(BuildContext context) { + return SDialog( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with title and close button. + if (Util.isDesktop) + Padding( + padding: const EdgeInsets.only(left: 32), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Response Slate", + style: STextStyles.pageTitleH2(context), + ), + const DesktopDialogCloseButton(), + ], + ), + ), + Padding( + padding: + Util.isDesktop + ? const EdgeInsets.only(left: 32, right: 32, bottom: 32) + : const EdgeInsets.only(left: 24, right: 24, bottom: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!Util.isDesktop) const SizedBox(height: 24), + Text( + "Return this slate to the sender to complete the transaction.", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Response slate", + style: STextStyles.itemSubtitle(context), + ), + SimpleCopyButton(data: responseSlatepack), + ], + ), + const SizedBox(height: 8), + ConditionalParent( + condition: !Util.isDesktop, + builder: + (child) => SizedBox( + height: 220, + child: SingleChildScrollView(child: child), + ), + child: SelectableText( + responseSlatepack, + style: STextStyles.w500_14(context), + ), + ), + const SizedBox(height: 24), + ConditionalParent( + condition: Util.isDesktop, + builder: + (child) => Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [child], + ), + child: PrimaryButton( + label: "Done", + width: Util.isDesktop ? 220 : null, + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: Navigator.of(context).pop, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/receive_view/sub_widgets/slatepack_entry_dialog.dart b/lib/pages/receive_view/sub_widgets/slatepack_entry_dialog.dart index ec9aee0c36..941e7755f0 100644 --- a/lib/pages/receive_view/sub_widgets/slatepack_entry_dialog.dart +++ b/lib/pages/receive_view/sub_widgets/slatepack_entry_dialog.dart @@ -58,8 +58,8 @@ class _SlatepackEntryDialogState extends ConsumerState { if (mounted) { final qrResult = await ref.read(pBarcodeScanner).scan(context: context); - if (qrResult.rawContent.isNotEmpty && qrResult.rawContent != "null") { - _receiveSlateController.text = qrResult.rawContent; + if (qrResult.rawContent != null && qrResult.rawContent!.isNotEmpty) { + _receiveSlateController.text = qrResult.rawContent!; setState(() { _slateToggleFlag = _receiveSlateController.text.isNotEmpty; }); @@ -105,10 +105,9 @@ class _SlatepackEntryDialogState extends ConsumerState { Text( "Receive Slatepack", style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, ), textAlign: TextAlign.left, ), @@ -138,78 +137,75 @@ class _SlatepackEntryDialogState extends ConsumerState { }, focusNode: _slateFocusNode, style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveText, + color: Theme.of( + context, + ).extension()!.textFieldActiveText, height: 1.8, ), - decoration: standardInputDecoration( - "Enter Slatepack Message", - _slateFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, // Adjust vertical padding for better alignment - ), - suffixIcon: Padding( - padding: - _receiveSlateController.text.isEmpty + decoration: + standardInputDecoration( + "Enter Slatepack Message", + _slateFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: + 12, // Adjust vertical padding for better alignment + ), + suffixIcon: Padding( + padding: _receiveSlateController.text.isEmpty ? const EdgeInsets.only(right: 8) : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _slateToggleFlag - ? TextFieldIconButton( - key: const Key( - "receiveViewClearSlatepackFieldButtonKey", - ), - onTap: () { - _receiveSlateController.text = ""; - setState(() { - _slateToggleFlag = false; - }); - }, - child: const XIcon(), - ) - : TextFieldIconButton( - key: const Key( - "receiveViewPasteSlatepackFieldButtonKey", + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _slateToggleFlag + ? TextFieldIconButton( + key: const Key( + "receiveViewClearSlatepackFieldButtonKey", + ), + onTap: () { + _receiveSlateController.text = ""; + setState(() { + _slateToggleFlag = false; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "receiveViewPasteSlatepackFieldButtonKey", + ), + onTap: _pasteSlatepack, + child: _receiveSlateController.text.isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_receiveSlateController.text.isEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("sendViewScanQrButtonKey"), + onTap: _scanQr, + child: const QrCodeIcon(), ), - onTap: _pasteSlatepack, - child: - _receiveSlateController.text.isEmpty - ? const ClipboardIcon() - : const XIcon(), - ), - if (_receiveSlateController.text.isEmpty) - TextFieldIconButton( - semanticsLabel: - "Scan QR Button. Opens Camera For Scanning QR Code.", - key: const Key("sendViewScanQrButtonKey"), - onTap: _scanQr, - child: const QrCodeIcon(), - ), - ], + ], + ), + ), ), ), - ), - ), ), ), const SizedBox(height: 16), PrimaryButton( label: "Import", enabled: _slateToggleFlag, - onPressed: - !_slateToggleFlag - ? null - : () => - Navigator.of(context).pop(_receiveSlateController.text), + onPressed: !_slateToggleFlag + ? null + : () => Navigator.of(context).pop(_receiveSlateController.text), ), const SizedBox(height: 16), SecondaryButton( diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 5cb12e02a6..ed4e967792 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -16,10 +16,15 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:isar_community/isar.dart'; +import '../../models/input.dart'; +import '../../models/isar/models/isar_models.dart'; import '../../models/isar/models/transaction_note.dart'; +import '../../models/isar/ordinal.dart'; import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart'; +import '../../pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; import '../../providers/providers.dart'; import '../../providers/wallet/public_private_balance_state_provider.dart'; @@ -37,10 +42,14 @@ import '../../wallets/crypto_currency/coins/ethereum.dart'; import '../../wallets/crypto_currency/coins/mimblewimblecoin.dart'; import '../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; +import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/models/tx_data.dart'; +import '../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../wallets/wallet/impl/solana_wallet.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; @@ -57,6 +66,7 @@ import '../../widgets/textfield_icon_button.dart'; import '../../wl_gen/interfaces/libepiccash_interface.dart'; import '../pinpad_views/lock_screen_view.dart'; import '../wallet_view/wallet_view.dart'; +import 'sub_widgets/epic_slatepack_dialog.dart'; import 'sub_widgets/mwc_slatepack_dialog.dart'; import 'sub_widgets/sending_transaction_dialog.dart'; @@ -66,7 +76,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { required this.txData, required this.walletId, required this.onSuccess, - this.routeOnSuccessName = WalletView.routeName, + this.routeOnSuccessName, this.isTradeTransaction = false, this.isPaynymTransaction = false, this.isPaynymNotificationTransaction = false, @@ -78,7 +88,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { final TxData txData; final String walletId; - final String routeOnSuccessName; + final String? routeOnSuccessName; final bool isTradeTransaction; final bool isPaynymTransaction; final bool isPaynymNotificationTransaction; @@ -103,6 +113,42 @@ class _ConfirmTransactionViewState late final FocusNode _onChainNoteFocusNode; late final TextEditingController onChainNoteController; + bool _spendsOrdinal = false; + + Future _checkForOrdinalSpend( + bool updateStateInPostFrameCallback, + ) async { + final db = ref.read(mainDBProvider); + final wallet = ref.read(pWallets).getWallet(walletId); + if (wallet is! OrdinalsInterface) return; + + final usedUtxos = widget.txData.usedUTXOs; + if (usedUtxos == null || usedUtxos.isEmpty) return; + + for (final input in usedUtxos) { + if (input is! StandardInput) continue; + final ordinal = await db.isar.ordinals + .where() + .filter() + .walletIdEqualTo(walletId) + .and() + .utxoTXIDEqualTo(input.utxo.txid) + .and() + .utxoVOUTEqualTo(input.utxo.vout) + .findFirst(); + if (ordinal != null) { + if (updateStateInPostFrameCallback) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _spendsOrdinal = true); + }); + } else { + if (mounted) setState(() => _spendsOrdinal = true); + } + return; + } + } + } + /// Handle MWC slatepack creation for manual exchange. Future _handleMwcSlatepackCreation( BuildContext context, @@ -172,7 +218,86 @@ class _ConfirmTransactionViewState context: context, builder: (context) => AlertDialog( title: const Text('Slatepack Creation Failed'), - content: Text('Failed to create slatepack: $e'), + content: Text(errorMessage), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + } + } + + /// Handle Epic Cash slate creation for manual exchange. + Future _handleEpicSlatepackCreation( + BuildContext context, + EpiccashWallet wallet, + ) async { + try { + // Close the progress dialog first. + Navigator.of(context).pop(); + + // Get recipient information from txData. + final recipient = widget.txData.recipients?.first; + if (recipient == null) { + throw Exception('No recipient found in transaction data'); + } + + // Create slatepack. + final slatepackResult = await wallet.createSlatepack( + amount: recipient.amount, + recipientAddress: recipient.address.isNotEmpty + ? recipient.address + : null, + message: onChainNoteController.text.isNotEmpty + ? onChainNoteController.text + : null, + ); + + if (!slatepackResult.success || slatepackResult.slatepack == null) { + throw Exception(slatepackResult.error ?? 'Failed to create slate'); + } + + // Show slatepack dialog. + if (context.mounted) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => + EpicSlatepackDialog(slatepackResult: slatepackResult), + ); + + // After slatepack dialog is closed, navigate back to wallet. + if (context.mounted) { + widget.onSuccess.call(); + if (widget.onSuccessInsteadOfRouteOnSuccess == null) { + Navigator.of( + context, + ).popUntil(ModalRoute.withName(routeOnSuccessName)); + } else { + widget.onSuccessInsteadOfRouteOnSuccess!.call(); + } + } + } + } catch (e, s) { + Logging.instance.e('Failed to create Epic Cash slate: $e\n$s'); + + if (context.mounted) { + // Show user-friendly error message. + final errorMessage = e.toString().contains('insufficient funds') + ? 'Insufficient funds for this transaction' + : e.toString().contains('wallet not open') + ? 'Wallet not accessible. Please restart the app.' + : 'Failed to create slate: ${e.toString()}'; + + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Slate Creation Failed'), + content: Text(errorMessage), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), @@ -190,10 +315,23 @@ class _ConfirmTransactionViewState final coin = wallet.info.coin; final sendProgressController = ProgressAndSuccessController(); + var isSendingDialogOpen = true; + + void closeSendingDialog() { + if (!context.mounted || !isSendingDialogOpen) { + return; + } + final navigator = Navigator.of(context, rootNavigator: true); + if (navigator.canPop()) { + navigator.pop(); + } + isSendingDialogOpen = false; + } unawaited( showDialog( context: context, + useRootNavigator: true, useSafeArea: false, barrierDismissible: false, builder: (context) { @@ -202,7 +340,7 @@ class _ConfirmTransactionViewState controller: sendProgressController, ); }, - ), + ).whenComplete(() => isSendingDialogOpen = false), ); final time = Future.delayed(const Duration(milliseconds: 2500)); @@ -214,9 +352,17 @@ class _ConfirmTransactionViewState try { if (widget.isTokenTx) { - txDataFuture = ref - .read(pCurrentTokenWallet)! - .confirmSend(txData: widget.txData); + if (wallet is SolanaWallet) { + // For Solana tokens, use the Solana token wallet. + txDataFuture = ref + .read(pCurrentSolanaTokenWallet)! + .confirmSend(txData: widget.txData); + } else { + // For Ethereum tokens, use the Ethereum token wallet. + txDataFuture = ref + .read(pCurrentTokenWallet)! + .confirmSend(txData: widget.txData); + } } else if (widget.isPaynymNotificationTransaction) { txDataFuture = (wallet as PaynymInterface).broadcastNotificationTx( txData: widget.txData, @@ -255,6 +401,7 @@ class _ConfirmTransactionViewState context, wallet as MimblewimblecoinWallet, ); + closeSendingDialog(); return; // Exit early, don't continue with normal transaction flow. } else { // Handle MWCMQS or HTTP transactions normally. @@ -265,11 +412,29 @@ class _ConfirmTransactionViewState ); } } else if (coin is Epiccash) { - txDataFuture = wallet.confirmSend( - txData: widget.txData.copyWith( - noteOnChain: onChainNoteController.text, - ), - ); + // Check if this is a slatepack transaction (manual exchange). + final epicOtherDataMap = widget.txData.otherData != null + ? jsonDecode(widget.txData.otherData!) + : null; + final epicTransactionMethod = + epicOtherDataMap?['transactionMethod'] as String?; + + if (epicTransactionMethod == 'slatepack') { + // Handle slatepack creation instead of direct send. + await _handleEpicSlatepackCreation( + context, + wallet as EpiccashWallet, + ); + closeSendingDialog(); + return; // Exit early, don't continue with normal transaction flow. + } else { + // Handle Epicbox transactions normally. + txDataFuture = wallet.confirmSend( + txData: widget.txData.copyWith( + noteOnChain: onChainNoteController.text, + ), + ); + } } else { txDataFuture = wallet.confirmSend(txData: widget.txData); } @@ -277,15 +442,17 @@ class _ConfirmTransactionViewState } final results = await Future.wait([txDataFuture, time]); + final confirmedTx = results.first as TxData; sendProgressController.triggerSuccess?.call(); await Future.delayed(const Duration(seconds: 5)); - if (wallet is FiroWallet && - (results.first as TxData).sparkMints != null) { - txids.addAll((results.first as TxData).sparkMints!.map((e) => e.txid!)); + if (wallet is FiroWallet && confirmedTx.sparkMints != null) { + txids.addAll(confirmedTx.sparkMints!.map((e) => e.txid!)); + } else if (wallet is FiroWallet && confirmedTx.sparkSpends != null) { + txids.addAll(confirmedTx.sparkSpends!.map((e) => e.txid!)); } else { - txids.add((results.first as TxData).txid!); + txids.add(confirmedTx.txid!); } if (coin is! Ethereum) { ref.refresh(desktopUseUTXOs); @@ -301,14 +468,19 @@ class _ConfirmTransactionViewState } if (widget.isTokenTx) { - unawaited(ref.read(pCurrentTokenWallet)!.refresh()); + if (wallet is SolanaWallet) { + unawaited(ref.read(pCurrentSolanaTokenWallet)!.refresh()); + } else { + unawaited(ref.read(pCurrentTokenWallet)!.refresh()); + } } else { unawaited(wallet.refresh()); } + closeSendingDialog(); + widget.onSuccess.call(); - // pop back to wallet if (context.mounted) { if (widget.onSuccessInsteadOfRouteOnSuccess == null) { Navigator.of( @@ -321,7 +493,7 @@ class _ConfirmTransactionViewState } on BadHttpAddressException catch (_) { if (context.mounted) { // pop building dialog - Navigator.of(context).pop(); + closeSendingDialog(); unawaited( showFloatingFlushBar( type: FlushBarType.warning, @@ -337,7 +509,7 @@ class _ConfirmTransactionViewState Logging.instance.e(message, error: e, stackTrace: s); // pop sending dialog if (context.mounted) { - Navigator.of(context).pop(); + closeSendingDialog(); await showDialog( context: context, @@ -410,9 +582,13 @@ class _ConfirmTransactionViewState @override void initState() { + super.initState(); + isDesktop = Util.isDesktop; walletId = widget.walletId; - routeOnSuccessName = widget.routeOnSuccessName; + routeOnSuccessName = + widget.routeOnSuccessName ?? + (Util.isDesktop ? DesktopWalletView.routeName : WalletView.routeName); _noteFocusNode = FocusNode(); noteController = TextEditingController(); noteController.text = widget.txData.note ?? ""; @@ -421,7 +597,7 @@ class _ConfirmTransactionViewState onChainNoteController = TextEditingController(); onChainNoteController.text = widget.txData.noteOnChain ?? ""; - super.initState(); + _checkForOrdinalSpend(true); } @override @@ -439,10 +615,19 @@ class _ConfirmTransactionViewState final coin = ref.watch(pWalletCoin(walletId)); final String unit; + final wallet = ref.watch(pWallets).getWallet(walletId); if (widget.isTokenTx) { - unit = ref.watch( - pCurrentTokenWallet.select((value) => value!.tokenContract.symbol), - ); + if (wallet is SolanaWallet) { + // For Solana tokens, use the Solana token wallet provider or TxData as fallback. + unit = ref.watch( + pCurrentSolanaTokenWallet.select((value) => value!.tokenSymbol), + ); + } else { + // For Ethereum tokens, use the Ethereum token wallet provider. + unit = ref.watch( + pCurrentTokenWallet.select((value) => value!.tokenContract.symbol), + ); + } } else { unit = coin.ticker; } @@ -450,8 +635,6 @@ class _ConfirmTransactionViewState final Amount? fee; final Amount amountWithoutChange; - final wallet = ref.watch(pWallets).getWallet(walletId); - if (wallet is FiroWallet) { switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case BalanceType.public: @@ -546,8 +729,7 @@ class _ConfirmTransactionViewState AppBarBackButton( size: 40, iconSize: 24, - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(), + onPressed: () => Navigator.of(context).pop(), ), Text( "Confirm $unit transaction", @@ -582,7 +764,11 @@ class _ConfirmTransactionViewState Text( widget.isPaynymTransaction ? widget.txData.paynymAccountLite!.nymName - : widget.txData.recipients?.first.address ?? + : widget + .txData + .recipients + ?.firstOrNull + ?.address ?? widget .txData .sparkRecipients! @@ -604,10 +790,15 @@ class _ConfirmTransactionViewState .watch(pAmountFormatter(coin)) .format( amountWithoutChange, - ethContract: widget.isTokenTx + tokenContract: + widget.isTokenTx && wallet is! SolanaWallet ? ref .watch(pCurrentTokenWallet)! .tokenContract + : widget.isTokenTx && wallet is SolanaWallet + ? ref + .watch(pCurrentSolanaTokenWallet)! + .solContract : null, ), style: STextStyles.itemSubtitle12(context), @@ -794,17 +985,34 @@ class _ConfirmTransactionViewState if (externalCalls) { final price = widget.isTokenTx - ? ref - .read( - priceAnd24hChangeNotifierProvider, - ) - .getTokenPrice( + ? (wallet is SolanaWallet + ? // For Solana tokens, use tokenMint from provider or TxData. ref - .read(pCurrentTokenWallet)! - .tokenContract - .address, - ) - ?.value + .read( + priceAnd24hChangeNotifierProvider, + ) + .getTokenPrice( + ref + .read( + pCurrentSolanaTokenWallet, + )! + .tokenMint, + ) + ?.value + : // For Ethereum tokens, use contract address. + ref + .read( + priceAnd24hChangeNotifierProvider, + ) + .getTokenPrice( + ref + .read( + pCurrentTokenWallet, + )! + .tokenContract + .address, + ) + ?.value) : ref .read( priceAnd24hChangeNotifierProvider, @@ -832,12 +1040,21 @@ class _ConfirmTransactionViewState .watch(pAmountFormatter(coin)) .format( amountWithoutChange, - ethContract: widget.isTokenTx + tokenContract: + widget.isTokenTx && + wallet is! SolanaWallet ? ref .watch( pCurrentTokenWallet, )! .tokenContract + : widget.isTokenTx && + wallet is SolanaWallet + ? ref + .watch( + pCurrentSolanaTokenWallet, + )! + .solContract : null, ), style: @@ -897,7 +1114,11 @@ class _ConfirmTransactionViewState // TODO: [prio=med] spark transaction specifics - better handling widget.isPaynymTransaction ? widget.txData.paynymAccountLite!.nymName - : widget.txData.recipients?.first.address ?? + : widget + .txData + .recipients + ?.firstOrNull + ?.address ?? widget .txData .sparkRecipients! @@ -1031,7 +1252,7 @@ class _ConfirmTransactionViewState children: [ if (coin is Epiccash || coin is Mimblewimblecoin) Text( - "On chain Note (optional)", + "On chain Note", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), @@ -1268,6 +1489,40 @@ class _ConfirmTransactionViewState ), ), ), + if (_spendsOrdinal) + Padding( + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 32, vertical: 8) + : const EdgeInsets.symmetric(vertical: 8), + child: RoundedContainer( + color: Theme.of( + context, + ).extension()!.warningBackground, + child: Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: Theme.of( + context, + ).extension()!.warningForeground, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "This transaction spends a UTXO containing " + "an ordinal inscription.", + style: STextStyles.smallMed12(context).copyWith( + color: Theme.of( + context, + ).extension()!.warningForeground, + ), + ), + ), + ], + ), + ), + ), SizedBox(height: isDesktop ? 28 : 16), Padding( padding: isDesktop @@ -1296,7 +1551,10 @@ class _ConfirmTransactionViewState right: 32, bottom: 32, ), - child: DesktopAuthSend(coin: coin), + child: DesktopAuthSend( + coin: coin, + tokenTicker: widget.isTokenTx ? unit : null, + ), ), ], ), @@ -1316,9 +1574,9 @@ class _ConfirmTransactionViewState } } } else { - final unlocked = await Navigator.push( + final unlocked = await Navigator.push( context, - RouteGenerator.getRoute( + RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, builder: (_) => const LockscreenView( diff --git a/lib/pages/send_view/frost_ms/frost_send_view.dart b/lib/pages/send_view/frost_ms/frost_send_view.dart index 4b10141585..59bdc843ef 100644 --- a/lib/pages/send_view/frost_ms/frost_send_view.dart +++ b/lib/pages/send_view/frost_ms/frost_send_view.dart @@ -33,6 +33,7 @@ import '../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/models/tx_data.dart'; import '../../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import '../../../wallets/wallet/impl/salvium_wallet.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../../widgets/background.dart'; import '../../../widgets/conditional_parent.dart'; @@ -164,10 +165,9 @@ class _FrostSendViewState extends ConsumerState { child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), onPressed: () { @@ -231,6 +231,7 @@ class _FrostSendViewState extends ConsumerState { final showCoinControl = wallet is CoinControlInterface && + wallet is! SalviumWallet && ref.watch( prefsChangeNotifierProvider.select( (value) => value.enableCoinControl, @@ -242,59 +243,56 @@ class _FrostSendViewState extends ConsumerState { return ConditionalParent( condition: !Util.isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton( - onPressed: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 50), - ); - } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - ), - title: Text( - "Send ${coin.ticker}", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (builderContext, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - // subtract top and bottom padding set in parent - minHeight: constraints.maxHeight, - ), - child: IntrinsicHeight( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: child, - ), - ), + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 50)); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "Send ${coin.ticker}", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + // subtract top and bottom padding set in parent + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: child, ), - ); - }, - ), - ), + ), + ), + ); + }, ), ), + ), + ), child: ConditionalParent( condition: Util.isDesktop, - builder: - (child) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - child: child, - ), + builder: (child) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: child, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -375,11 +373,10 @@ class _FrostSendViewState extends ConsumerState { for (int i = 0; i < recipientWidgetIndexes.length; i++) ConditionalParent( condition: recipientWidgetIndexes.length > 1, - builder: - (child) => Padding( - padding: const EdgeInsets.only(top: 8), - child: child, - ), + builder: (child) => Padding( + padding: const EdgeInsets.only(top: 8), + child: child, + ), child: Recipient( key: Key("recipientKey_${recipientWidgetIndexes[i]}"), index: recipientWidgetIndexes[i], @@ -388,21 +385,21 @@ class _FrostSendViewState extends ConsumerState { onChanged: () { _validateRecipientFormStates(); }, - remove: - i == 0 && recipientWidgetIndexes.length == 1 - ? null - : () { - ref - .read( - pRecipient( - recipientWidgetIndexes[i], - ).notifier, - ) - .state = null; - recipientWidgetIndexes.removeAt(i); - setState(() {}); - _validateRecipientFormStates(); - }, + remove: i == 0 && recipientWidgetIndexes.length == 1 + ? null + : () { + ref + .read( + pRecipient( + recipientWidgetIndexes[i], + ).notifier, + ) + .state = + null; + recipientWidgetIndexes.removeAt(i); + setState(() {}); + _validateRecipientFormStates(); + }, addAnotherRecipientTapped: () { // used for tracking recipient forms _greatestWidgetIndex++; @@ -443,17 +440,15 @@ class _FrostSendViewState extends ConsumerState { Text( "Coin control", style: STextStyles.w500_14(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), ), CustomTextButton( - text: - selectedUTXOs.isEmpty - ? "Select coins" - : "Selected coins (${selectedUTXOs.length})", + text: selectedUTXOs.isEmpty + ? "Select coins" + : "Selected coins (${selectedUTXOs.length})", onTap: () async { if (FocusScope.of(context).hasFocus) { FocusScope.of(context).unfocus(); @@ -506,32 +501,32 @@ class _FrostSendViewState extends ConsumerState { focusNode: _noteFocusNode, style: STextStyles.field(context), onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Type something...", - _noteFocusNode, - context, - ).copyWith( - suffixIcon: - noteController.text.isNotEmpty + decoration: + standardInputDecoration( + "Type something...", + _noteFocusNode, + context, + ).copyWith( + 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, - ), + ), ), ), const SizedBox(height: 12), diff --git a/lib/pages/send_view/frost_ms/recipient.dart b/lib/pages/send_view/frost_ms/recipient.dart index 150eecb0b2..7e483726a2 100644 --- a/lib/pages/send_view/frost_ms/recipient.dart +++ b/lib/pages/send_view/frost_ms/recipient.dart @@ -125,8 +125,10 @@ class _RecipientState extends ConsumerState { Logging.instance.d("qrResult content: ${qrResult.rawContent}"); + if (qrResult.rawContent == null) return; + final paymentData = AddressUtils.parsePaymentUri( - qrResult.rawContent, + qrResult.rawContent!, logging: Logging.instance, ); @@ -148,7 +150,7 @@ class _RecipientState extends ConsumerState { .format(amount, withUnitName: false); } } else { - addressController.text = qrResult.rawContent.trim(); + addressController.text = qrResult.rawContent!.trim(); } setState(() { @@ -244,8 +246,9 @@ class _RecipientState extends ConsumerState { ), CustomTextButton( text: isSingle ? "Add another recipient" : "Remove", - onTap: - isSingle ? widget.addAnotherRecipientTapped : widget.remove, + onTap: isSingle + ? widget.addAnotherRecipientTapped + : widget.remove, ), ], ), @@ -268,93 +271,92 @@ class _RecipientState extends ConsumerState { _addressIsEmpty = addressController.text.isEmpty; }); }, - decoration: standardInputDecoration( - "Enter ${widget.coin.ticker} address", - addressFocusNode, - context, - ).copyWith( - contentPadding: const EdgeInsets.only( - left: 16, - top: 6, - bottom: 8, - right: 5, - ), - suffixIcon: Padding( - padding: - _addressIsEmpty + decoration: + standardInputDecoration( + "Enter ${widget.coin.ticker} address", + addressFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _addressIsEmpty ? const EdgeInsets.only(right: 8) : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - !_addressIsEmpty - ? TextFieldIconButton( - semanticsLabel: - "Clear Button. Clears The Address Field Input.", - key: const Key( - "sendViewClearAddressFieldButtonKey", - ), - onTap: () { - addressController.text = ""; - - setState(() { - _addressIsEmpty = true; - }); - - _updateRecipientData(); - }, - child: const XIcon(), - ) - : TextFieldIconButton( - semanticsLabel: - "Paste Button. Pastes From Clipboard To Address Field Input.", - key: const Key( - "sendViewPasteAddressFieldButtonKey", + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + !_addressIsEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Address Field Input.", + key: const Key( + "sendViewClearAddressFieldButtonKey", + ), + onTap: () { + addressController.text = ""; + + setState(() { + _addressIsEmpty = true; + }); + + _updateRecipientData(); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Address Field Input.", + key: const Key( + "sendViewPasteAddressFieldButtonKey", + ), + onTap: () async { + final ClipboardData? data = await ref + .read(pClipboard) + .getData(Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + String content = data.text!.trim(); + if (content.contains("\n")) { + content = content.substring( + 0, + content.indexOf("\n"), + ); + } + + addressController.text = content.trim(); + + setState(() { + _addressIsEmpty = + addressController.text.isEmpty; + }); + + _updateRecipientData(); + } + }, + child: _addressIsEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_addressIsEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. " + "Opens Camera For Scanning QR Code.", + key: const Key("sendViewScanQrButtonKey"), + onTap: _onQrTapped, + child: const QrCodeIcon(), ), - onTap: () async { - final ClipboardData? data = await ref - .read(pClipboard) - .getData(Clipboard.kTextPlain); - if (data?.text != null && - data!.text!.isNotEmpty) { - String content = data.text!.trim(); - if (content.contains("\n")) { - content = content.substring( - 0, - content.indexOf("\n"), - ); - } - - addressController.text = content.trim(); - - setState(() { - _addressIsEmpty = - addressController.text.isEmpty; - }); - - _updateRecipientData(); - } - }, - child: - _addressIsEmpty - ? const ClipboardIcon() - : const XIcon(), - ), - if (_addressIsEmpty) - TextFieldIconButton( - semanticsLabel: - "Scan QR Button. " - "Opens Camera For Scanning QR Code.", - key: const Key("sendViewScanQrButtonKey"), - onTap: _onQrTapped, - child: const QrCodeIcon(), - ), - ], + ], + ), + ), ), ), - ), - ), ), ), SizedBox(height: isSingle ? 12 : 8), @@ -391,13 +393,12 @@ class _RecipientState extends ConsumerState { onChanged: (_) { _updateRecipientData(); }, - keyboardType: - Util.isDesktop - ? null - : const TextInputType.numberWithOptions( - signed: false, - decimal: true, - ), + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( @@ -419,10 +420,9 @@ class _RecipientState extends ConsumerState { .watch(pAmountUnit(widget.coin)) .unitForCoin(widget.coin), style: STextStyles.smallMed14(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index e7fcea3336..18b8d5be2e 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -18,6 +18,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:tuple/tuple.dart'; +import '../../models/epic_slatepack_models.dart'; import '../../models/input.dart'; import '../../models/isar/models/isar_models.dart'; import '../../models/mwc_slatepack_models.dart'; @@ -49,11 +50,15 @@ import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; import '../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/models/tx_data.dart'; +import '../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../wallets/wallet/impl/salvium_wallet.dart'; +import '../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; @@ -63,6 +68,7 @@ import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/custom_buttons/blue_text_button.dart'; import '../../widgets/dialogs/firo_exchange_address_dialog.dart'; +import '../../widgets/epic_txs_method_toggle.dart'; import '../../widgets/eth_fee_form.dart'; import '../../widgets/fee_slider.dart'; import '../../widgets/icon_widgets/addressbook_icon.dart'; @@ -74,12 +80,12 @@ import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; -import '../../wl_gen/interfaces/cs_monero_interface.dart'; import '../address_book_views/address_book_view.dart'; import '../coin_control/coin_control_view.dart'; import 'confirm_transaction_view.dart'; import 'sub_widgets/building_transaction_dialog.dart'; import 'sub_widgets/dual_balance_selection_sheet.dart'; +import 'sub_widgets/epic_slatepack_dialog.dart'; import 'sub_widgets/mwc_slatepack_dialog.dart'; import 'sub_widgets/transaction_fee_selection_sheet.dart'; @@ -134,7 +140,7 @@ class _SendViewState extends ConsumerState { final _baseFocus = FocusNode(); final _memoFocus = FocusNode(); - late final bool isStellar; + late final bool hasOptionalMemo; late final bool isFiro; late final bool isEth; @@ -154,7 +160,6 @@ class _SendViewState extends ConsumerState { try { // auto fill address _address = paymentData.address.trim(); - sendToController.text = _address!; // autofill notes field if (paymentData.message != null) { @@ -174,7 +179,25 @@ class _SendViewState extends ConsumerState { ref.read(pSendAmount.notifier).state = amount; } + // Extract OP_RETURN data if present (for Rosen Bridge and other protocols) + // Must be set BEFORE sendToController.text to avoid re-entrant + // onChanged handler reading stale null value. + if (paymentData.additionalParams.containsKey('op_return')) { + final data = paymentData.additionalParams['op_return']; + _setOpReturnData(data); + Logging.instance.i( + "Extracted OP_RETURN data from URI, length: ${data!.length ~/ 2} bytes", + ); + } else { + _setOpReturnData(null); + } + _setValidAddressProviders(_address); + + // Assign controller.text last — it triggers onChanged which depends + // on pOpReturnData already being set above. + sendToController.text = _address!; + setState(() { _addressToggleFlag = sendToController.text.isNotEmpty; }); @@ -234,6 +257,7 @@ class _SendViewState extends ConsumerState { paymentData.coin?.uriScheme == coin.uriScheme) { _applyUri(paymentData); } else { + _setOpReturnData(null); if (coin is Epiccash) { content = AddressUtils().formatEpicCashAddress(content); } @@ -247,6 +271,7 @@ class _SendViewState extends ConsumerState { }); } } catch (e) { + _setOpReturnData(null); // strip http:// and https:// if content contains @ if (coin is Epiccash) { content = AddressUtils().formatEpicCashAddress(content); @@ -289,9 +314,10 @@ class _SendViewState extends ConsumerState { // ); Logging.instance.d("qrResult content: ${qrResult.rawContent}"); + if (qrResult.rawContent == null) return; final paymentData = AddressUtils.parsePaymentUri( - qrResult.rawContent, + qrResult.rawContent!, logging: Logging.instance, ); @@ -299,7 +325,8 @@ class _SendViewState extends ConsumerState { paymentData.coin?.uriScheme == coin.uriScheme) { _applyUri(paymentData); } else { - _address = qrResult.rawContent.split("\n").first.trim(); + _setOpReturnData(null); + _address = qrResult.rawContent!.split("\n").first.trim(); sendToController.text = _address ?? ""; _setValidAddressProviders(_address); @@ -517,15 +544,50 @@ class _SendViewState extends ConsumerState { Map cachedFiroSparkFees = {}; Map cachedFiroPublicFees = {}; - Future calculateFees(Amount amount) async { - if (amount <= Amount.zero) { - return "0"; + void _setOpReturnData(String? data) { + if (!mounted) { + return; } + ref.read(pOpReturnData.notifier).state = data; + } + + Amount _addOpReturnFeeIfNeeded({ + required Amount fee, + required BigInt feeRate, + required FiroWallet wallet, + }) { + final opReturnData = ref.read(pOpReturnData); + if (opReturnData == null || + opReturnData.isEmpty || + ref.read(publicPrivateBalanceStateProvider) != BalanceType.public) { + return fee; + } + + final extraOutputVSize = AddressUtils.opReturnOutputVSizeFromHex( + opReturnData, + ); + final extraFee = wallet.estimateTxFee( + vSize: extraOutputVSize, + feeRatePerKB: feeRate, + ); + + return fee + + Amount( + rawValue: BigInt.from(extraFee), + fractionDigits: coin.fractionDigits, + ); + } + + Future calculateFees(Amount amount) async { + final hasOpReturnData = + isFiro && + ref.read(publicPrivateBalanceStateProvider) == BalanceType.public && + (ref.read(pOpReturnData)?.isNotEmpty ?? false); if (isFiro) { switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case BalanceType.public: - if (cachedFiroPublicFees[amount] != null) { + if (!hasOpReturnData && cachedFiroPublicFees[amount] != null) { return cachedFiroPublicFees[amount]!; } break; @@ -559,17 +621,17 @@ class _SendViewState extends ConsumerState { } Amount fee; - if (coin is Monero) { + if (coin is CryptonoteCurrency) { final int specialMoneroId; switch (ref.read(feeRateTypeMobileStateProvider.state).state) { case FeeRateType.fast: - specialMoneroId = csMonero.getTxPriorityHigh(); + specialMoneroId = (wallet as CryptonoteWallet).getTxPriorityHigh(); break; case FeeRateType.average: - specialMoneroId = csMonero.getTxPriorityMedium(); + specialMoneroId = (wallet as CryptonoteWallet).getTxPriorityMedium(); break; case FeeRateType.slow: - specialMoneroId = csMonero.getTxPriorityNormal(); + specialMoneroId = (wallet as CryptonoteWallet).getTxPriorityNormal(); break; default: throw ArgumentError("custom fee not available for monero"); @@ -587,10 +649,18 @@ class _SendViewState extends ConsumerState { switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case BalanceType.public: fee = await firoWallet.estimateFeeFor(amount, feeRate); - cachedFiroPublicFees[amount] = ref + fee = _addOpReturnFeeIfNeeded( + fee: fee, + feeRate: feeRate, + wallet: firoWallet, + ); + final formatted = ref .read(pAmountFormatter(coin)) .format(fee, withUnitName: true, indicatePrecisionLoss: false); - return cachedFiroPublicFees[amount]!; + if (!hasOpReturnData) { + cachedFiroPublicFees[amount] = formatted; + } + return formatted; case BalanceType.private: fee = await firoWallet.estimateFeeForSpark(amount); @@ -697,6 +767,92 @@ class _SendViewState extends ConsumerState { } } + Future _createEpicSlatepack() async { + // wait for keyboard to disappear + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 100)); + + try { + if (mounted) { + final wallet = ref.read(pWallets).getWallet(walletId) as EpiccashWallet; + + final amount = ref.read(pSendAmount)!; + + Future wrappedFutureWithDelay() async { + await Future.delayed(const Duration(seconds: 1)); + return wallet.createSlatepack( + amount: amount, + recipientAddress: null, + // No specific recipient for manual slatepack. + message: onChainNoteController.text.isNotEmpty == true + ? onChainNoteController.text + : null, + ); + } + + // Create slatepack. + Exception? ex; + final slatepackResult = await showLoading( + whileFuture: wrappedFutureWithDelay(), + context: context, + message: "Building slate...", + delay: const Duration(seconds: 2), + onException: (e) => ex = e, + ); + + if (slatepackResult == null || + !slatepackResult.success || + slatepackResult.slatepack == null || + ex != null) { + String error = + ex?.toString() ?? + slatepackResult?.error ?? + 'Failed to create slate'; + if (error.startsWith("Exception:")) { + error = error.replaceFirst("Exception:", "").trim(); + } + throw Exception(error); + } + + // refresh asap to show the pending slate tx in history + unawaited(() async { + await Future.delayed(Duration.zero); + await wallet.refresh(); + }()); + + // Show slatepack dialog. + if (mounted) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => StackDialogBase( + child: EpicSlatepackDialog(slatepackResult: slatepackResult), + ), + ); + + // Clear form after slatepack dialog is closed. + clearSendForm(); + } + } + } catch (e, s) { + Logging.instance.e( + 'Failed to create Epic Cash slate on mobile', + error: e, + stackTrace: s, + ); + + if (mounted) { + await showDialog( + context: context, + builder: (context) => StackOkDialog( + title: "Slate Creation Failed", + message: e.toString(), + ), + ); + } + } + } + Future _previewTransaction() async { // wait for keyboard to disappear FocusScope.of(context).unfocus(); @@ -725,7 +881,9 @@ class _SendViewState extends ConsumerState { .enableCoinControl; if (coin is! Ethereum && - !(wallet is CoinControlInterface && coinControlEnabled) || + !(wallet is CoinControlInterface && + wallet is! SalviumWallet && + coinControlEnabled) || (wallet is CoinControlInterface && coinControlEnabled && selectedUTXOs.isEmpty)) { @@ -827,10 +985,12 @@ class _SendViewState extends ConsumerState { feeRateType: feeRate, utxos: (wallet is CoinControlInterface && + wallet is! SalviumWallet && coinControlEnabled && selectedUTXOs.isNotEmpty) ? selectedUTXOs : null, + opReturnData: ref.read(pOpReturnData), ), ); } else if (wallet is FiroWallet) { @@ -872,6 +1032,7 @@ class _SendViewState extends ConsumerState { utxos: (coinControlEnabled && selectedUTXOs.isNotEmpty) ? selectedUTXOs : null, + opReturnData: ref.read(pOpReturnData), ), ); } @@ -949,6 +1110,7 @@ class _SendViewState extends ConsumerState { ethEIP1559Fee: ethFee, utxos: (wallet is CoinControlInterface && + wallet is! SalviumWallet && coinControlEnabled && selectedUTXOs.isNotEmpty) ? selectedUTXOs @@ -975,7 +1137,7 @@ class _SendViewState extends ConsumerState { } // pop building dialog - Navigator.of(context).pop(); + Navigator.of(context, rootNavigator: true).pop(); unawaited( Navigator.of(context).push( @@ -985,7 +1147,11 @@ class _SendViewState extends ConsumerState { txData: txData, walletId: walletId, isPaynymTransaction: isPaynymSend, - onSuccess: clearSendForm, + onSuccess: () { + if (mounted) { + clearSendForm(); + } + }, ), settings: const RouteSettings( name: ConfirmTransactionView.routeName, @@ -998,7 +1164,7 @@ class _SendViewState extends ConsumerState { Logging.instance.e("$e\n$s", error: e, stackTrace: s); if (mounted) { // pop building dialog - Navigator.of(context).pop(); + Navigator.of(context, rootNavigator: true).pop(); unawaited( showDialog( @@ -1034,6 +1200,9 @@ class _SendViewState extends ConsumerState { } void clearSendForm() { + if (!mounted) { + return; + } sendToController.text = ""; cryptoAmountController.text = ""; baseAmountController.text = ""; @@ -1043,9 +1212,8 @@ class _SendViewState extends ConsumerState { memoController.text = ""; _address = ""; _addressToggleFlag = false; - if (mounted) { - setState(() {}); - } + _setOpReturnData(null); + setState(() {}); } String _getSendAllTitle( @@ -1099,39 +1267,6 @@ class _SendViewState extends ConsumerState { late final bool hasFees; - void _onSendToAddressPasteButtonPressed() async { - final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); - if (data?.text != null && data!.text!.isNotEmpty) { - String content = data.text!.trim(); - if (content.contains("\n")) { - content = content.substring(0, content.indexOf("\n")); - } - - if (coin is Epiccash) { - // strip http:// and https:// if content contains @ - content = AddressUtils().formatEpicCashAddress(content); - } - - final trimmed = content.trim(); - final parsed = AddressUtils.parsePaymentUri( - trimmed, - logging: Logging.instance, - ); - if (parsed != null) { - _applyUri(parsed); - } else { - sendToController.text = content; - _address = content; - - _setValidAddressProviders(_address); - - setState(() { - _addressToggleFlag = sendToController.text.isNotEmpty; - }); - } - } - } - void _onFeeSelectPressed() { showModalBottomSheet( backgroundColor: Colors.transparent, @@ -1171,6 +1306,14 @@ class _SendViewState extends ConsumerState { @override void initState() { coin = widget.coin; + isFiro = coin is Firo; + isEth = coin is Ethereum; + hasOptionalMemo = coin is Stellar || coin is Solana; + + _data = widget.autoFillData; + walletId = widget.walletId; + clipboard = widget.clipboard; + WidgetsBinding.instance.addPostFrameCallback((_) { ref.refresh(feeSheetSessionCacheProvider); ref.refresh(pIsExchangeAddress); @@ -1187,12 +1330,6 @@ class _SendViewState extends ConsumerState { _calculateFeesFuture = calculateFees( 0.toAmountAsRaw(fractionDigits: coin.fractionDigits), ); - _data = widget.autoFillData; - walletId = widget.walletId; - clipboard = widget.clipboard; - isStellar = coin is Stellar; - isFiro = coin is Firo; - isEth = coin is Ethereum; sendToController = TextEditingController(); cryptoAmountController = TextEditingController(); @@ -1207,21 +1344,27 @@ class _SendViewState extends ConsumerState { baseAmountController.addListener(_baseAmountChanged); if (_data != null) { - if (_data.amount != null) { + final hasAmount = _data.amount != null; + if (hasAmount) { final amount = Amount.fromDecimal( _data.amount!, fractionDigits: coin.fractionDigits, ); + _cryptoAmountChangeLock = true; cryptoAmountController.text = ref .read(pAmountFormatter(coin)) .format(amount, withUnitName: false); + _cryptoAmountChangeLock = false; } sendToController.text = _data.contactLabel; _address = _data.address.trim(); _addressToggleFlag = true; WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (hasAmount) { + _cryptoAmountChanged(); + } _setValidAddressProviders(_address); }); } @@ -1315,6 +1458,7 @@ class _SendViewState extends ConsumerState { ), ) && ref.watch(pWallets).getWallet(walletId) is CoinControlInterface && + ref.watch(pWallets).getWallet(walletId) is! SalviumWallet && (showPrivateBalance ? balType == BalanceType.public : true); final isExchangeAddress = ref.watch(pIsExchangeAddress); @@ -1377,21 +1521,16 @@ class _SendViewState extends ConsumerState { final isMwcSlatepack = coin is Mimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId)); + final isEpicSlatepack = + coin is Epiccash && ref.watch(pIsSlatepack(widget.walletId)); + final isSlatepackMode = isMwcSlatepack || isEpicSlatepack; return Background( child: Scaffold( backgroundColor: Theme.of(context).extension()!.background, appBar: AppBar( leading: AppBarBackButton( - onPressed: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed(const Duration(milliseconds: 50)); - } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, + onPressed: () => Navigator.of(context).pop(), ), title: Text( "Send ${coin.ticker}", @@ -1555,7 +1694,16 @@ class _SendViewState extends ConsumerState { const SizedBox(height: 16), ], - if (!isMwcSlatepack) + // Epic Cash Transaction Method Selector. + if (coin is Epiccash) ...[ + const SizedBox( + height: 40, + child: EpicTxsMethodToggle(), + ), + const SizedBox(height: 16), + ], + + if (!isSlatepackMode) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -1584,7 +1732,7 @@ class _SendViewState extends ConsumerState { // ), ], ), - if (!isMwcSlatepack) const SizedBox(height: 8), + if (!isSlatepackMode) const SizedBox(height: 8), if (isPaynymSend) TextField( key: const Key("sendViewPaynymAddressFieldKey"), @@ -1593,7 +1741,7 @@ class _SendViewState extends ConsumerState { readOnly: true, style: STextStyles.fieldLabel(context), ), - if (!isPaynymSend && !isMwcSlatepack) + if (!isPaynymSend && !isSlatepackMode) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -1618,9 +1766,10 @@ class _SendViewState extends ConsumerState { final trimmed = newValue.trim(); if ((trimmed.length - - (_address?.length ?? 0)) - .abs() > - 1) { + (_address?.length ?? 0)) + .abs() > + 1 || + trimmed.contains(':')) { final parsed = AddressUtils.parsePaymentUri( trimmed, @@ -1629,11 +1778,13 @@ class _SendViewState extends ConsumerState { if (parsed != null) { _applyUri(parsed); } else { + _setOpReturnData(null); await _checkSparkNameAndOrSetAddress( newValue, ); } } else { + _setOpReturnData(null); await _checkSparkNameAndOrSetAddress( newValue, setController: false, @@ -1650,7 +1801,7 @@ class _SendViewState extends ConsumerState { style: STextStyles.field(context), decoration: standardInputDecoration( - isMwcSlatepack + isSlatepackMode ? "Enter ${coin.ticker} address (optional)" : "Enter ${coin.ticker} address", _addressFocusNode, @@ -1683,6 +1834,9 @@ class _SendViewState extends ConsumerState { .text = ""; _address = ""; + _setOpReturnData( + null, + ); _setValidAddressProviders( _address, ); @@ -1748,7 +1902,7 @@ class _SendViewState extends ConsumerState { ), ), const SizedBox(height: 10), - if (isStellar || + if (hasOptionalMemo || ref.watch(pValidSparkSendToAddress)) ClipRRect( borderRadius: BorderRadius.circular( @@ -1841,6 +1995,38 @@ class _SendViewState extends ConsumerState { ), ), ), + if (ref.watch(pOpReturnData) != null && + _address != null && + _address!.isNotEmpty && + (ref.watch(pValidSendToAddress) || + ref.watch(pValidSparkSendToAddress)) && + balType == BalanceType.public) + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 12.0, + top: 4.0, + ), + child: Tooltip( + message: AddressUtils.formatOpReturnTooltip( + ref.watch(pOpReturnData)!, + ), + child: Text( + "Transaction includes metadata " + "(${ref.watch(pOpReturnData)!.length ~/ 2} bytes) " + "\u2014 tap for details", + textAlign: TextAlign.left, + style: STextStyles.label(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorGreen, + ), + ), + ), + ), + ), Builder( builder: (_) { final String? error; @@ -2251,7 +2437,7 @@ class _SendViewState extends ConsumerState { const SizedBox(height: 12), if (coin is Epiccash) Text( - "On chain Note (optional)", + "On chain Note", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), @@ -2558,14 +2744,42 @@ class _SendViewState extends ConsumerState { ), const Spacer(), const SizedBox(height: 12), + if (ref.watch(pOpReturnData) != null && + balType == BalanceType.private) + Padding( + padding: const EdgeInsets.only( + left: 12.0, + right: 12.0, + bottom: 12.0, + ), + child: Text( + "Bridge data detected but Spark (private) " + "transactions cannot carry OP_RETURN data. " + "Switch to public balance to complete the " + "bridge transaction.", + textAlign: TextAlign.left, + style: STextStyles.label(context).copyWith( + color: Theme.of( + context, + ).extension()!.textError, + ), + ), + ), TextButton( onPressed: - ref.watch(pPreviewTxButtonEnabled(coin)) - ? ref.watch(pIsSlatepack(widget.walletId)) + ref.watch(pPreviewTxButtonEnabled(coin)) && + (ref.watch(pOpReturnData) == null || + balType != BalanceType.private) + ? isMwcSlatepack ? _createSlatepack + : isEpicSlatepack + ? _createEpicSlatepack : _previewTransaction : null, - style: ref.watch(pPreviewTxButtonEnabled(coin)) + style: + ref.watch(pPreviewTxButtonEnabled(coin)) && + (ref.watch(pOpReturnData) == null || + balType != BalanceType.private) ? Theme.of(context) .extension()! .getPrimaryEnabledButtonStyle(context) @@ -2573,9 +2787,7 @@ class _SendViewState extends ConsumerState { .extension()! .getPrimaryDisabledButtonStyle(context), child: Text( - ref.watch(pIsSlatepack(widget.walletId)) - ? "Create slatepack" - : "Preview", + isSlatepackMode ? "Create slate" : "Preview", style: STextStyles.button(context), ), ), diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart new file mode 100644 index 0000000000..6187d4c53a --- /dev/null +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -0,0 +1,1391 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../models/isar/models/isar_models.dart'; +import '../../models/send_view_auto_fill_data.dart'; +import '../../providers/providers.dart'; +import '../../providers/ui/fee_rate_type_state_provider.dart'; +import '../../providers/ui/preview_tx_button_state_provider.dart'; +import '../../route_generator.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_input_formatter.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/barcode_scanner_interface.dart'; +import '../../utilities/clipboard_interface.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/enums/fee_rate_type_enum.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/prefs.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/models/tx_data.dart'; +import '../../widgets/animated_text.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/icon_widgets/clipboard_icon.dart'; +import '../../widgets/icon_widgets/qrcode_icon.dart'; +import '../../widgets/icon_widgets/sol_token_icon.dart'; +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 '../token_view/sol_token_view.dart'; +import 'confirm_transaction_view.dart'; +import 'sub_widgets/building_transaction_dialog.dart'; +import 'sub_widgets/transaction_fee_selection_sheet.dart'; + +class SolTokenSendView extends ConsumerStatefulWidget { + const SolTokenSendView({ + super.key, + required this.walletId, + required this.tokenMint, + this.autoFillData, + this.clipboard = const ClipboardWrapper(), + }); + + static const String routeName = "/solTokenSendView"; + + final String walletId; + final String tokenMint; + final SendViewAutoFillData? autoFillData; + final ClipboardInterface clipboard; + + @override + ConsumerState createState() => _SolTokenSendViewState(); +} + +class _SolTokenSendViewState extends ConsumerState { + late final String walletId; + late final String tokenMint; + late final ClipboardInterface clipboard; + + late TextEditingController sendToController; + late TextEditingController memoController; + late TextEditingController cryptoAmountController; + late TextEditingController baseAmountController; + late TextEditingController noteController; + late TextEditingController feeController; + + late final SendViewAutoFillData? _data; + + final _addressFocusNode = FocusNode(); + final _noteFocusNode = FocusNode(); + final _cryptoFocus = FocusNode(); + final _baseFocus = FocusNode(); + final _memoFocus = FocusNode(); + + Amount? _amountToSend; + Amount? _cachedAmountToSend; + String? _address; + + bool _addressToggleFlag = false; + + bool _cryptoAmountChangeLock = false; + late VoidCallback onCryptoAmountChanged; + + final updateFeesTimerDuration = const Duration(milliseconds: 500); + + Timer? _cryptoAmountChangedFeeUpdateTimer; + Timer? _baseAmountChangedFeeUpdateTimer; + late Future _calculateFeesFuture; + String cachedFees = ""; + + void _onTokenSendViewPasteAddressFieldButtonPressed() async { + final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null && data!.text!.isNotEmpty) { + String content = data.text!.trim(); + if (content.contains("\n")) { + content = content.substring(0, content.indexOf("\n")); + } + sendToController.text = content.trim(); + _address = content.trim(); + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + } + + void _onTokenSendViewScanQrButtonPressed() async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + + final qrResult = await ref.read(pBarcodeScanner).scan(context: context); + + Logging.instance.d("qrResult content: ${qrResult.rawContent}"); + if (qrResult.rawContent == null) return; + + final paymentData = AddressUtils.parsePaymentUri( + qrResult.rawContent!, + logging: Logging.instance, + ); + + Logging.instance.d("qrResult parsed: $paymentData"); + + if (paymentData != null) { + // auto fill address + _address = paymentData.address.trim(); + sendToController.text = _address!; + + // autofill notes field + if (paymentData.message != null) { + noteController.text = paymentData.message!; + } else if (paymentData.label != null) { + noteController.text = paymentData.label!; + } + + // autofill amount field + if (paymentData.amount != null) { + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + if (tokenWallet != null) { + final Amount amount = Decimal.parse( + paymentData.amount!, + ).toAmount(fractionDigits: tokenWallet.tokenDecimals); + cryptoAmountController.text = ref + .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main))) + .format( + amount, + withUnitName: false, + indicatePrecisionLoss: false, + ); + _amountToSend = amount; + } + } + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + + // now check for non standard encoded basic address + } else { + _address = qrResult.rawContent!.split("\n").first.trim(); + sendToController.text = _address ?? ""; + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + } on PlatformException catch (e, s) { + if (mounted) { + try { + await checkCamPermDeniedMobileAndOpenAppSettings( + context, + logging: Logging.instance, + ); + } catch (e, s) { + Logging.instance.e( + "Failed to check cam permissions", + error: e, + stackTrace: s, + ); + } + } else { + Logging.instance.w( + "Failed to get camera permissions while trying to scan qr code in SolTokenSendView: ", + error: e, + stackTrace: s, + ); + } + } + } + + void _onFiatAmountFieldChanged(String baseAmountString) { + final baseAmount = Amount.tryParseFiatString( + baseAmountString, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + if (baseAmount != null) { + final _price = ref + .read(priceAnd24hChangeNotifierProvider) + .getTokenPrice(tokenMint) + ?.value; + + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + if (tokenWallet == null) return; + + if (_price == null || _price == Decimal.zero) { + _amountToSend = Amount.zero; + } else { + _amountToSend = baseAmount <= Amount.zero + ? Amount.zero + : Amount.fromDecimal( + (baseAmount.decimal / _price).toDecimal( + scaleOnInfinitePrecision: tokenWallet.tokenDecimals, + ), + fractionDigits: tokenWallet.tokenDecimals, + ); + } + if (_cachedAmountToSend != null && _cachedAmountToSend == _amountToSend) { + return; + } + _cachedAmountToSend = _amountToSend; + + _cryptoAmountChangeLock = true; + cryptoAmountController.text = ref + .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main))) + .format(_amountToSend!, withUnitName: false); + _cryptoAmountChangeLock = false; + } else { + _amountToSend = Amount.zero; + _cryptoAmountChangeLock = true; + cryptoAmountController.text = ""; + _cryptoAmountChangeLock = false; + } + _updatePreviewButtonState(_address, _amountToSend); + } + + void _cryptoAmountChanged() async { + if (!_cryptoAmountChangeLock) { + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + if (tokenWallet == null) return; + + final cryptoAmount = Decimal.tryParse( + cryptoAmountController.text, + )?.toAmount(fractionDigits: tokenWallet.tokenDecimals); + if (cryptoAmount != null) { + _amountToSend = cryptoAmount; + if (_cachedAmountToSend != null && + _cachedAmountToSend == _amountToSend) { + return; + } + _cachedAmountToSend = _amountToSend; + + final price = ref + .read(priceAnd24hChangeNotifierProvider) + .getTokenPrice(tokenMint) + ?.value; + + if (price != null && price > Decimal.zero) { + baseAmountController.text = (_amountToSend!.decimal * price) + .toAmount(fractionDigits: 2) + .fiatString( + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + } + } else { + _amountToSend = null; + baseAmountController.text = ""; + } + + _updatePreviewButtonState(_address, _amountToSend); + + _cryptoAmountChangedFeeUpdateTimer?.cancel(); + _cryptoAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () { + if (mounted) { + setState(() { + _calculateFeesFuture = calculateFees(); + }); + } + }); + } + } + + void _baseAmountChanged() { + _baseAmountChangedFeeUpdateTimer?.cancel(); + _baseAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () { + if (mounted && !_cryptoFocus.hasFocus) { + setState(() { + _calculateFeesFuture = calculateFees(); + }); + } + }); + } + + String? _updateInvalidAddressText(String address) { + if (_data != null && _data.contactLabel == address) { + return null; + } + if (address.isNotEmpty) { + if (!Solana(CryptoCurrencyNetwork.main).validateAddress(address)) { + return "Invalid address"; + } + } + return null; + } + + void _updatePreviewButtonState(String? address, Amount? amount) { + final isValidAddress = + address != null && + address.isNotEmpty && + Solana(CryptoCurrencyNetwork.main).validateAddress(address); + ref.read(previewTokenTxButtonStateProvider.state).state = + (isValidAddress && amount != null && amount > Amount.zero); + } + + Future calculateFees() async { + try { + final wallet = ref.read(pCurrentSolanaTokenWallet); + if (wallet == null) { + return "0.000005 SOL"; + } + + final feeObject = await wallet.fees; + + late final BigInt feeRate; + + switch (ref.read(feeRateTypeMobileStateProvider.state).state) { + case FeeRateType.fast: + feeRate = feeObject.fast; + break; + case FeeRateType.average: + feeRate = feeObject.medium; + break; + case FeeRateType.slow: + feeRate = feeObject.slow; + break; + default: + feeRate = BigInt.from(-1); + } + + final Amount fee = await wallet.estimateFeeFor(Amount.zero, feeRate); + cachedFees = ref + .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main))) + .format(fee, withUnitName: true, indicatePrecisionLoss: false); + + return cachedFees; + } 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"; + } + } + + Future _previewTransaction() async { + // wait for keyboard to disappear + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 100)); + + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + if (tokenWallet == null) { + if (mounted) { + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Error", + message: "Token wallet not initialized", + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ); + }, + ); + } + return; + } + + final wallet = ref.read(pWallets).getWallet(walletId); + final Amount amount = _amountToSend!; + + try { + bool wasCancelled = false; + + if (mounted) { + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return BuildingTransactionDialog( + coin: wallet.info.coin, + isSpark: false, + onCancel: () { + wasCancelled = true; + + Navigator.of(context).pop(); + }, + ); + }, + ), + ); + } + + final time = Future.delayed(const Duration(milliseconds: 2500)); + + TxData txData; + Future txDataFuture; + + Logging.instance.i( + "SolTokenSendView: Preparing transaction - amount: ${amount.decimal} " + "(raw: ${amount.raw}), decimals: ${tokenWallet.tokenDecimals}, " + "tokenSymbol: ${tokenWallet.tokenSymbol}", + ); + + txDataFuture = tokenWallet.prepareSend( + txData: TxData( + recipients: [ + TxRecipient( + address: _address!, + amount: amount, + isChange: false, + addressType: AddressType.solana, + ), + ], + memo: memoController.text.isEmpty ? null : memoController.text, + feeRateType: ref.read(feeRateTypeMobileStateProvider), + note: noteController.text, + ), + ); + + final results = await Future.wait([txDataFuture, time]); + + txData = results.first as TxData; + + if (!wasCancelled && mounted) { + // pop building dialog + Navigator.of(context).pop(); + + unawaited( + Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => ConfirmTransactionView( + txData: txData, + walletId: walletId, + isTokenTx: true, + onSuccess: clearSendForm, + routeOnSuccessName: SolTokenView.routeName, + ), + settings: const RouteSettings( + name: ConfirmTransactionView.routeName, + ), + ), + ), + ); + } + } catch (e, s) { + Logging.instance.e("$e\n$s", error: e, stackTrace: s); + if (mounted) { + // pop building dialog + Navigator.of(context).pop(); + + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Transaction failed", + message: e.toString(), + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ); + }, + ), + ); + } + } + } + + void clearSendForm() { + sendToController.text = ""; + memoController.text = ""; + cryptoAmountController.text = ""; + baseAmountController.text = ""; + noteController.text = ""; + feeController.text = ""; + _address = ""; + _addressToggleFlag = false; + if (mounted) { + setState(() {}); + } + } + + @override + void initState() { + ref.refresh(feeSheetSessionCacheProvider); + + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(feeRateTypeMobileStateProvider.state).state = FeeRateType.slow; + }); + + _calculateFeesFuture = calculateFees(); + _data = widget.autoFillData; + walletId = widget.walletId; + tokenMint = widget.tokenMint; + clipboard = widget.clipboard; + + sendToController = TextEditingController(); + memoController = TextEditingController(); + cryptoAmountController = TextEditingController(); + baseAmountController = TextEditingController(); + noteController = TextEditingController(); + feeController = TextEditingController(); + + onCryptoAmountChanged = _cryptoAmountChanged; + cryptoAmountController.addListener(onCryptoAmountChanged); + baseAmountController.addListener(_baseAmountChanged); + + if (_data != null) { + if (_data.amount != null) { + cryptoAmountController.text = _data.amount!.toString(); + } + sendToController.text = _data.contactLabel; + _address = _data.address.trim(); + _addressToggleFlag = true; + } + + super.initState(); + } + + @override + void dispose() { + _cryptoAmountChangedFeeUpdateTimer?.cancel(); + _baseAmountChangedFeeUpdateTimer?.cancel(); + + cryptoAmountController.removeListener(onCryptoAmountChanged); + baseAmountController.removeListener(_baseAmountChanged); + + sendToController.dispose(); + memoController.dispose(); + cryptoAmountController.dispose(); + baseAmountController.dispose(); + noteController.dispose(); + feeController.dispose(); + + _noteFocusNode.dispose(); + _addressFocusNode.dispose(); + _cryptoFocus.dispose(); + _baseFocus.dispose(); + _memoFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + final String locale = ref.watch( + localeServiceChangeNotifierProvider.select((value) => value.locale), + ); + + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + + Decimal? price; + if (ref.watch(prefsChangeNotifierProvider.select((s) => s.externalCalls))) { + price = ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getTokenPrice(tokenMint)?.value, + ), + ); + } + + if (tokenWallet == null) { + return Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 50)); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: const Text("Send Token"), + ), + body: const SafeArea(child: Center(child: Text("Loading token..."))), + ), + ); + } + + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 50)); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "Send ${tokenWallet.tokenSymbol}", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return Padding( + padding: const EdgeInsets.only(left: 12, top: 12, right: 12), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 24, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.popupBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + SolTokenIcon(mintAddress: tokenMint), + const SizedBox(width: 6), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + ref.watch(pWalletName(walletId)), + style: STextStyles.titleBold12( + context, + ).copyWith(fontSize: 14), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + Text( + "Available balance", + style: STextStyles.label( + context, + ).copyWith(fontSize: 10), + ), + ], + ), + const Spacer(), + GestureDetector( + onTap: () { + cryptoAmountController.text = ref + .watch( + pAmountFormatter( + Solana( + CryptoCurrencyNetwork.main, + ), + ), + ) + .format( + ref + .read( + pSolanaTokenBalance(( + walletId: widget.walletId, + tokenMint: tokenMint, + )), + ) + .spendable, + withUnitName: false, + indicatePrecisionLoss: true, + ); + }, + child: Container( + color: Colors.transparent, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.end, + children: [ + Text( + ref + .watch( + pAmountFormatter( + Solana( + CryptoCurrencyNetwork + .main, + ), + ), + ) + .format( + ref + .watch( + pSolanaTokenBalance(( + walletId: + widget.walletId, + tokenMint: + tokenMint, + )), + ) + .spendable, + ), + style: STextStyles.titleBold12( + context, + ).copyWith(fontSize: 10), + textAlign: TextAlign.right, + ), + if (price != null) + Text( + "${(ref.watch(pSolanaTokenBalance((walletId: widget.walletId, tokenMint: tokenMint))).spendable.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: locale)} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", + style: STextStyles.subtitle( + context, + ).copyWith(fontSize: 8), + textAlign: TextAlign.right, + ), + ], + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Text( + "Send to", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key( + "solTokenSendViewAddressFieldKey", + ), + controller: sendToController, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + toolbarOptions: const ToolbarOptions( + copy: false, + cut: false, + paste: true, + selectAll: false, + ), + onChanged: (newValue) { + _address = newValue.trim(); + _updatePreviewButtonState( + _address, + _amountToSend, + ); + + setState(() { + _addressToggleFlag = newValue.isNotEmpty; + }); + }, + focusNode: _addressFocusNode, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Enter Solana address", + _addressFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: sendToController.text.isEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + _addressToggleFlag + ? TextFieldIconButton( + key: const Key( + "solTokenSendViewClearAddressFieldButtonKey", + ), + onTap: () { + sendToController.text = + ""; + _address = ""; + _updatePreviewButtonState( + _address, + _amountToSend, + ); + setState(() { + _addressToggleFlag = + false; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "solTokenSendViewPasteAddressFieldButtonKey", + ), + onTap: + _onTokenSendViewPasteAddressFieldButtonPressed, + child: + sendToController + .text + .isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (sendToController.text.isEmpty) + TextFieldIconButton( + key: const Key( + "solSendViewScanQrButtonKey", + ), + onTap: + _onTokenSendViewScanQrButtonPressed, + child: const QrCodeIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + Builder( + builder: (_) { + final error = _updateInvalidAddressText( + _address ?? "", + ); + + if (error == null || error.isEmpty) { + return Container(); + } else { + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 12.0, + top: 4.0, + ), + child: Text( + error, + textAlign: TextAlign.left, + style: STextStyles.label(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textError, + ), + ), + ), + ); + } + }, + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("sendViewMemoFieldKey"), + controller: memoController, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + focusNode: _memoFocus, + style: STextStyles.field(context), + onChanged: (_) { + setState(() {}); + }, + decoration: + standardInputDecoration( + "Enter memo (optional)", + _memoFocus, + context, + ).copyWith( + counterText: '', + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: memoController.text.isEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + memoController.text.isNotEmpty + ? TextFieldIconButton( + semanticsLabel: + "Clear Button. Clears The Memo Field Input.", + key: const Key( + "sendSolTokenViewClearMemoFieldButtonKey", + ), + onTap: () { + memoController.text = + ""; + setState(() {}); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + semanticsLabel: + "Paste Button. Pastes From Clipboard To Memo Field Input.", + key: const Key( + "sendSolTokenViewPasteMemoFieldButtonKey", + ), + onTap: () async { + final ClipboardData? + data = await clipboard + .getData( + Clipboard + .kTextPlain, + ); + if (data?.text != + null && + data! + .text! + .isNotEmpty) { + final String content = + data.text!.trim(); + + memoController.text = + content.trim(); + + setState(() {}); + } + }, + child: + const ClipboardIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Amount", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + ], + ), + const SizedBox(height: 8), + TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + key: const Key( + "solAmountInputFieldCryptoTextFieldKey", + ), + controller: cryptoAmountController, + focusNode: _cryptoFocus, + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + textAlign: TextAlign.right, + inputFormatters: [ + AmountInputFormatter( + decimals: tokenWallet.tokenDecimals, + // TODO: Implement token-specific unit lookup + // similar to Ethereum's pAmountUnit(coin).unitForContract(tokenContract) + unit: ref.watch( + pAmountUnit( + Solana(CryptoCurrencyNetwork.main), + ), + ), + locale: locale, + ), + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.only( + top: 12, + right: 12, + ), + hintText: "0", + hintStyle: STextStyles.fieldLabel( + context, + ).copyWith(fontSize: 14), + prefixIcon: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + tokenWallet.tokenSymbol, + style: STextStyles.smallMed14(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark, + ), + ), + ), + ), + ), + ), + if (Prefs.instance.externalCalls) + const SizedBox(height: 8), + if (Prefs.instance.externalCalls) + TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop + ? false + : true, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + key: const Key( + "solAmountInputFieldFiatTextFieldKey", + ), + controller: baseAmountController, + focusNode: _baseFocus, + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + textAlign: TextAlign.right, + inputFormatters: [ + AmountInputFormatter( + decimals: 2, + locale: locale, + ), + ], + onChanged: _onFiatAmountFieldChanged, + decoration: InputDecoration( + contentPadding: const EdgeInsets.only( + top: 12, + right: 12, + ), + hintText: "0", + hintStyle: STextStyles.fieldLabel( + context, + ).copyWith(fontSize: 14), + prefixIcon: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + ref.watch( + prefsChangeNotifierProvider.select( + (value) => value.currency, + ), + ), + style: STextStyles.smallMed14(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 12), + Text( + "Note (optional)", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop + ? false + : true, + controller: noteController, + focusNode: _noteFocusNode, + style: STextStyles.field(context), + onChanged: (_) => setState(() {}), + decoration: + standardInputDecoration( + "Type something...", + _noteFocusNode, + context, + ).copyWith( + 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 = + ""; + }); + }, + ), + ], + ), + ), + ) + : null, + ), + ), + ), + const SizedBox(height: 12), + Text( + "Transaction fee", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + const SizedBox(height: 8), + Stack( + children: [ + TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop + ? false + : true, + controller: feeController, + readOnly: true, + textInputAction: TextInputAction.none, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + ), + child: RawMaterialButton( + splashColor: Theme.of( + context, + ).extension()!.highlight, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: () { + showModalBottomSheet( + backgroundColor: Colors.transparent, + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + builder: (_) => + TransactionFeeSelectionSheet( + walletId: walletId, + isToken: true, + amount: + (Decimal.tryParse( + cryptoAmountController + .text, + ) ?? + Decimal.zero) + .toAmount( + fractionDigits: + tokenWallet + .tokenDecimals, + ), + updateChosen: (String fee) { + setState(() { + _calculateFeesFuture = Future( + () => fee, + ); + }); + }, + ), + ); + }, + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + ref + .watch( + feeRateTypeMobileStateProvider + .state, + ) + .state + .prettyName, + style: STextStyles.itemSubtitle12( + context, + ), + ), + const SizedBox(width: 10), + FutureBuilder( + future: _calculateFeesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.done && + snapshot.hasData) { + return Text( + "~${snapshot.data!}", + style: + STextStyles.itemSubtitle( + context, + ), + ); + } else { + return AnimatedText( + stringsToLoopThrough: + const [ + "Calculating", + "Calculating.", + "Calculating..", + "Calculating...", + ], + style: + STextStyles.itemSubtitle( + context, + ), + ); + } + }, + ), + ], + ), + SvgPicture.asset( + Assets.svg.chevronDown, + width: 8, + height: 4, + colorFilter: ColorFilter.mode( + Theme.of(context) + .extension()! + .textSubtitle2, + BlendMode.srcIn, + ), + ), + ], + ), + ), + ), + ], + ), + 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()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context), + child: Text( + "Preview", + style: STextStyles.button(context), + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart b/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart index f7dc832f89..0d1e9ef344 100644 --- a/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart +++ b/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart @@ -50,45 +50,24 @@ class _RestoringDialogState extends ConsumerState { @override Widget build(BuildContext context) { - final assetPath = ref.watch( - coinImageSecondaryProvider( - widget.coin, - ), - ); + final assetPath = ref.watch(coinImageSecondaryProvider(widget.coin)); if (Util.isDesktop) { return Column( mainAxisSize: MainAxisSize.min, children: [ - Text( - "Generating transaction", - style: STextStyles.desktopH3(context), - ), - if (widget.isSpark) - const SizedBox( - height: 16, - ), + Text("Generating transaction", style: STextStyles.desktopH3(context)), + if (widget.isSpark) const SizedBox(height: 16), if (widget.isSpark) Text( "This may take a few minutes...", style: STextStyles.desktopSubtitleH2(context), ), - const SizedBox( - height: 40, - ), + const SizedBox(height: 40), assetPath.endsWith(".gif") - ? Image.file( - File( - assetPath, - ), - ) - : const RotatingArrows( - width: 40, - height: 40, - ), - const SizedBox( - height: 40, - ), + ? Image.file(File(assetPath)) + : const RotatingArrows(width: 40, height: 40), + const SizedBox(height: 40), SecondaryButton( buttonHeight: ButtonHeight.l, label: "Cancel", @@ -109,29 +88,20 @@ class _RestoringDialogState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - Image.file( - File( - assetPath, - ), - ), + Image.file(File(assetPath)), Text( "Generating transaction", textAlign: TextAlign.center, style: STextStyles.pageTitleH2(context), ), - if (widget.isSpark) - const SizedBox( - height: 12, - ), + if (widget.isSpark) const SizedBox(height: 12), if (widget.isSpark) Text( "This may take a few minutes...", textAlign: TextAlign.center, style: STextStyles.w500_16(context), ), - const SizedBox( - height: 32, - ), + const SizedBox(height: 32), Row( children: [ const Spacer(), @@ -157,12 +127,10 @@ class _RestoringDialogState extends ConsumerState { ) : StackDialog( title: "Generating transaction", - message: - widget.isSpark ? "This may take a few minutes..." : null, - icon: const RotatingArrows( - width: 24, - height: 24, - ), + message: widget.isSpark + ? "This may take a few minutes..." + : null, + icon: const RotatingArrows(width: 24, height: 24), rightButton: TextButton( style: Theme.of(context) .extension()! diff --git a/lib/pages/send_view/sub_widgets/epic_slatepack_dialog.dart b/lib/pages/send_view/sub_widgets/epic_slatepack_dialog.dart new file mode 100644 index 0000000000..7bcf97022f --- /dev/null +++ b/lib/pages/send_view/sub_widgets/epic_slatepack_dialog.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../../models/epic_slatepack_models.dart'; +import '../../../notifications/show_flush_bar.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/assets.dart'; +import '../../../utilities/clipboard_interface.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../widgets/conditional_parent.dart'; +import '../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/qr.dart'; +import '../../../widgets/rounded_container.dart'; +import '../../../widgets/rounded_white_container.dart'; + +class EpicSlatepackDialog extends ConsumerStatefulWidget { + const EpicSlatepackDialog({ + super.key, + required this.slatepackResult, + this.clipboard = const ClipboardWrapper(), + }); + + final EpicSlatepackResult slatepackResult; + final ClipboardInterface clipboard; + + @override + ConsumerState createState() => _EpicSlatepackDialogState(); +} + +class _EpicSlatepackDialogState extends ConsumerState { + void _copySlatepack() { + widget.clipboard.setData( + ClipboardData(text: widget.slatepackResult.slatepack!), + ); + showFloatingFlushBar( + type: FlushBarType.info, + message: "Slate copied to clipboard", + iconAsset: Assets.svg.copy, + context: context, + ); + } + + void _shareSlatepack() { + // TODO: Implement file sharing for desktop platforms. + showFloatingFlushBar( + type: FlushBarType.info, + message: "Share functionality coming soon", + context: context, + ); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: + (child) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with title and close button. + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Send Slate", + style: STextStyles.pageTitleH2(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Padding(padding: const EdgeInsets.all(32), child: child), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Instructions. + RoundedContainer( + color: + Theme.of(context).extension()!.textFieldDefaultBG, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Next Steps:", + style: STextStyles.label( + context, + ).copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + "1. Share this slate with the recipient\n" + "2. Wait for them to return the response slate\n" + "3. Import their response to finalize the transaction", + style: STextStyles.w400_14(context), + ), + ], + ), + ), + + const SizedBox(height: 12), + + // QR Code view. + Center( + child: QR( + data: widget.slatepackResult.slatepack!, + size: 220, + ), + ), + + const SizedBox(height: 12), + + // Slatepack text view. + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text("Slate", style: STextStyles.itemSubtitle(context)), + const Spacer(), + GestureDetector( + onTap: _copySlatepack, + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.copy, + width: 10, + height: 10, + color: + Theme.of( + context, + ).extension()!.infoItemIcons, + ), + const SizedBox(width: 4), + Text("Copy", style: STextStyles.link2(context)), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + Container( + constraints: const BoxConstraints( + maxHeight: 200, + minHeight: 100, + ), + child: SingleChildScrollView( + child: SelectableText( + widget.slatepackResult.slatepack!, + style: STextStyles.w400_14( + context, + ).copyWith(fontFamily: 'monospace'), + ), + ), + ), + ], + ), + ), + + if (!Util.isDesktop) + PrimaryButton(label: "Done", onPressed: Navigator.of(context).pop), + ], + ), + ); + } +} diff --git a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart index 8c05fa973d..387138d8cf 100644 --- a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart +++ b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart @@ -1,4 +1,4 @@ -/* +/* * This file is part of Stack Wallet. * * Copyright (c) 2023 Cypher Stack @@ -14,8 +14,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../models/paymint/fee_object_model.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/public_private_balance_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/constants.dart'; @@ -23,12 +25,14 @@ 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'; import '../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/wallet/impl/firo_wallet.dart'; +import '../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; +import '../../../wallets/wallet/wallet.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; import '../../../widgets/animated_text.dart'; -import '../../../wl_gen/interfaces/cs_monero_interface.dart'; final feeSheetSessionCacheProvider = ChangeNotifierProvider((ref) { @@ -76,22 +80,64 @@ class _TransactionFeeSelectionSheetState "Calculating...", ]; + Amount _addFiroOpReturnFee({ + required Amount fee, + required BigInt feeRate, + required FiroWallet wallet, + required CryptoCurrency coin, + }) { + final opReturnData = ref.read(pOpReturnData); + if (opReturnData == null || + opReturnData.isEmpty || + ref.read(publicPrivateBalanceStateProvider) != BalanceType.public) { + return fee; + } + + final extraOutputVSize = AddressUtils.opReturnOutputVSizeFromHex( + opReturnData, + ); + final extraFee = wallet.estimateTxFee( + vSize: extraOutputVSize, + feeRatePerKB: feeRate, + ); + + return fee + + Amount( + rawValue: BigInt.from(extraFee), + fractionDigits: coin.fractionDigits, + ); + } + Future feeFor({ required Amount amount, required FeeRateType feeRateType, required BigInt feeRate, required CryptoCurrency coin, }) async { + if (!widget.isToken && + coin is Firo && + ref.read(publicPrivateBalanceStateProvider) == BalanceType.public && + (ref.read(pOpReturnData)?.isNotEmpty ?? false)) { + final wallet = ref.read(pWallets).getWallet(walletId) as FiroWallet; + final fee = await wallet.estimateFeeFor(amount, feeRate); + return _addFiroOpReturnFee( + fee: fee, + feeRate: feeRate, + wallet: wallet, + coin: coin, + ); + } + switch (feeRateType) { case FeeRateType.fast: if (ref.read(feeSheetSessionCacheProvider).fast[amount] == null) { if (widget.isToken == false) { final wallet = ref.read(pWallets).getWallet(walletId); - if (coin is Monero || coin is Wownero) { + if (coin is CryptonoteCurrency) { final fee = await wallet.estimateFeeFor( amount, - BigInt.from(csMonero.getTxPriorityHigh()), + BigInt.from((wallet as CryptonoteWallet).getTxPriorityHigh()), ); ref.read(feeSheetSessionCacheProvider).fast[amount] = fee; } else if (coin is Firo) { @@ -114,8 +160,13 @@ class _TransactionFeeSelectionSheetState .estimateFeeFor(amount, feeRate); } } else { - final tokenWallet = ref.read(pCurrentTokenWallet)!; - final fee = await tokenWallet.estimateFeeFor(amount, feeRate); + final Wallet wallet; + if (coin is Ethereum) { + wallet = ref.read(pCurrentTokenWallet)!; + } else { + wallet = ref.read(pWallets).getWallet(walletId); + } + final fee = await wallet.estimateFeeFor(amount, feeRate); ref.read(feeSheetSessionCacheProvider).fast[amount] = fee; } } @@ -125,10 +176,10 @@ class _TransactionFeeSelectionSheetState if (ref.read(feeSheetSessionCacheProvider).average[amount] == null) { if (widget.isToken == false) { final wallet = ref.read(pWallets).getWallet(walletId); - if (coin is Monero || coin is Wownero) { + if (coin is CryptonoteCurrency) { final fee = await wallet.estimateFeeFor( amount, - BigInt.from(csMonero.getTxPriorityMedium()), + BigInt.from((wallet as CryptonoteWallet).getTxPriorityMedium()), ); ref.read(feeSheetSessionCacheProvider).average[amount] = fee; } else if (coin is Firo) { @@ -150,8 +201,13 @@ class _TransactionFeeSelectionSheetState await wallet.estimateFeeFor(amount, feeRate); } } else { - final tokenWallet = ref.read(pCurrentTokenWallet)!; - final fee = await tokenWallet.estimateFeeFor(amount, feeRate); + final Wallet wallet; + if (coin is Ethereum) { + wallet = ref.read(pCurrentTokenWallet)!; + } else { + wallet = ref.read(pWallets).getWallet(walletId); + } + final fee = await wallet.estimateFeeFor(amount, feeRate); ref.read(feeSheetSessionCacheProvider).average[amount] = fee; } } @@ -161,10 +217,10 @@ class _TransactionFeeSelectionSheetState if (ref.read(feeSheetSessionCacheProvider).slow[amount] == null) { if (widget.isToken == false) { final wallet = ref.read(pWallets).getWallet(walletId); - if (coin is Monero || coin is Wownero) { + if (coin is CryptonoteCurrency) { final fee = await wallet.estimateFeeFor( amount, - BigInt.from(csMonero.getTxPriorityNormal()), + BigInt.from((wallet as CryptonoteWallet).getTxPriorityNormal()), ); ref.read(feeSheetSessionCacheProvider).slow[amount] = fee; } else if (coin is Firo) { @@ -186,8 +242,13 @@ class _TransactionFeeSelectionSheetState .estimateFeeFor(amount, feeRate); } } else { - final tokenWallet = ref.read(pCurrentTokenWallet)!; - final fee = await tokenWallet.estimateFeeFor(amount, feeRate); + final Wallet wallet; + if (coin is Ethereum) { + wallet = ref.read(pCurrentTokenWallet)!; + } else { + wallet = ref.read(pWallets).getWallet(walletId); + } + final fee = await wallet.estimateFeeFor(amount, feeRate); ref.read(feeSheetSessionCacheProvider).slow[amount] = fee; } } @@ -268,7 +329,9 @@ class _TransactionFeeSelectionSheetState const SizedBox(height: 36), FutureBuilder( future: widget.isToken - ? ref.read(pCurrentTokenWallet)!.fees + ? (coin is Ethereum + ? ref.read(pCurrentTokenWallet)!.fees + : wallet.fees) : wallet.fees, builder: (context, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.done && diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index fdf78136e0..3d30fc5f6a 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -163,9 +163,10 @@ class _TokenSendViewState extends ConsumerState { // ); Logging.instance.d("qrResult content: ${qrResult.rawContent}"); + if (qrResult.rawContent == null) return; final paymentData = AddressUtils.parsePaymentUri( - qrResult.rawContent, + qrResult.rawContent!, logging: Logging.instance, ); @@ -206,7 +207,7 @@ class _TokenSendViewState extends ConsumerState { // now check for non standard encoded basic address } else { - _address = qrResult.rawContent.split("\n").first.trim(); + _address = qrResult.rawContent!.split("\n").first.trim(); sendToController.text = _address ?? ""; _updatePreviewButtonState(_address, _amountToSend); @@ -249,24 +250,22 @@ class _TokenSendViewState extends ConsumerState { locale: ref.read(localeServiceChangeNotifierProvider).locale, ); if (baseAmount != null) { - final _price = - ref - .read(priceAnd24hChangeNotifierProvider) - .getTokenPrice(tokenContract.address) - ?.value; + final _price = ref + .read(priceAnd24hChangeNotifierProvider) + .getTokenPrice(tokenContract.address) + ?.value; if (_price == null || _price == Decimal.zero) { _amountToSend = Amount.zero; } else { - _amountToSend = - baseAmount <= Amount.zero - ? Amount.zero - : Amount.fromDecimal( - (baseAmount.decimal / _price).toDecimal( - scaleOnInfinitePrecision: tokenContract.decimals, - ), - fractionDigits: tokenContract.decimals, - ); + _amountToSend = baseAmount <= Amount.zero + ? Amount.zero + : Amount.fromDecimal( + (baseAmount.decimal / _price).toDecimal( + scaleOnInfinitePrecision: tokenContract.decimals, + ), + fractionDigits: tokenContract.decimals, + ); } if (_cachedAmountToSend != null && _cachedAmountToSend == _amountToSend) { return; @@ -296,7 +295,7 @@ class _TokenSendViewState extends ConsumerState { if (!_cryptoAmountChangeLock) { final cryptoAmount = ref .read(pAmountFormatter(coin)) - .tryParse(cryptoAmountController.text, ethContract: tokenContract); + .tryParse(cryptoAmountController.text, tokenContract: tokenContract); if (cryptoAmount != null) { _amountToSend = cryptoAmount; if (_cachedAmountToSend != null && @@ -305,11 +304,10 @@ class _TokenSendViewState extends ConsumerState { } _cachedAmountToSend = _amountToSend; - final price = - ref - .read(priceAnd24hChangeNotifierProvider) - .getTokenPrice(tokenContract.address) - ?.value; + final price = ref + .read(priceAnd24hChangeNotifierProvider) + .getTokenPrice(tokenContract.address) + ?.value; if (price != null && price > Decimal.zero) { baseAmountController.text = (_amountToSend!.decimal * price) @@ -496,8 +494,9 @@ class _TokenSendViewState extends ConsumerState { address: _address!, amount: amount, isChange: false, - addressType: - tokenWallet.cryptoCurrency.getAddressType(_address!)!, + addressType: tokenWallet.cryptoCurrency.getAddressType( + _address!, + )!, ), ], feeRateType: ref.read(feeRateTypeMobileStateProvider), @@ -518,14 +517,13 @@ class _TokenSendViewState extends ConsumerState { Navigator.of(context).push( RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: - (_) => ConfirmTransactionView( - txData: txData, - walletId: walletId, - isTokenTx: true, - onSuccess: clearSendForm, - routeOnSuccessName: TokenView.routeName, - ), + builder: (_) => ConfirmTransactionView( + txData: txData, + walletId: walletId, + isTokenTx: true, + onSuccess: clearSendForm, + routeOnSuccessName: TokenView.routeName, + ), settings: const RouteSettings( name: ConfirmTransactionView.routeName, ), @@ -555,10 +553,9 @@ class _TokenSendViewState extends ConsumerState { child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), onPressed: () { @@ -699,10 +696,9 @@ class _TokenSendViewState extends ConsumerState { children: [ Container( decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.popupBG, + color: Theme.of( + context, + ).extension()!.popupBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -750,7 +746,7 @@ class _TokenSendViewState extends ConsumerState { )), ) .spendable, - ethContract: tokenContract, + tokenContract: tokenContract, withUnitName: false, indicatePrecisionLoss: true, ); @@ -776,7 +772,8 @@ class _TokenSendViewState extends ConsumerState { )), ) .spendable, - ethContract: tokenContract, + tokenContract: + tokenContract, ), style: STextStyles.titleBold12( context, @@ -835,85 +832,90 @@ class _TokenSendViewState extends ConsumerState { }, focusNode: _addressFocusNode, style: STextStyles.field(context), - decoration: standardInputDecoration( - "Enter ${tokenContract.symbol} address", - _addressFocusNode, - context, - ).copyWith( - contentPadding: const EdgeInsets.only( - left: 16, - top: 6, - bottom: 8, - right: 5, - ), - suffixIcon: Padding( - padding: - sendToController.text.isEmpty + decoration: + standardInputDecoration( + "Enter ${tokenContract.symbol} address", + _addressFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: sendToController.text.isEmpty ? const EdgeInsets.only(right: 8) : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceAround, - children: [ - _addressToggleFlag - ? TextFieldIconButton( - key: const Key( - "tokenSendViewClearAddressFieldButtonKey", + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + _addressToggleFlag + ? TextFieldIconButton( + key: const Key( + "tokenSendViewClearAddressFieldButtonKey", + ), + onTap: () { + sendToController.text = + ""; + _address = ""; + _updatePreviewButtonState( + _address, + _amountToSend, + ); + setState(() { + _addressToggleFlag = + false; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "tokenSendViewPasteAddressFieldButtonKey", + ), + onTap: + _onTokenSendViewPasteAddressFieldButtonPressed, + child: + sendToController + .text + .isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (sendToController.text.isEmpty) + TextFieldIconButton( + key: const Key( + "sendViewAddressBookButtonKey", + ), + onTap: () { + Navigator.of( + context, + ).pushNamed( + AddressBookView.routeName, + arguments: widget.coin, + ); + }, + child: + const AddressBookIcon(), ), - onTap: () { - sendToController.text = ""; - _address = ""; - _updatePreviewButtonState( - _address, - _amountToSend, - ); - setState(() { - _addressToggleFlag = false; - }); - }, - child: const XIcon(), - ) - : TextFieldIconButton( - key: const Key( - "tokenSendViewPasteAddressFieldButtonKey", + if (sendToController.text.isEmpty) + TextFieldIconButton( + key: const Key( + "sendViewScanQrButtonKey", + ), + onTap: + _onTokenSendViewScanQrButtonPressed, + child: const QrCodeIcon(), ), - onTap: - _onTokenSendViewPasteAddressFieldButtonPressed, - child: - sendToController - .text - .isEmpty - ? const ClipboardIcon() - : const XIcon(), - ), - if (sendToController.text.isEmpty) - TextFieldIconButton( - key: const Key( - "sendViewAddressBookButtonKey", - ), - onTap: () { - Navigator.of(context).pushNamed( - AddressBookView.routeName, - arguments: widget.coin, - ); - }, - child: const AddressBookIcon(), - ), - if (sendToController.text.isEmpty) - TextFieldIconButton( - key: const Key( - "sendViewScanQrButtonKey", - ), - onTap: - _onTokenSendViewScanQrButtonPressed, - child: const QrCodeIcon(), - ), - ], + ], + ), + ), ), ), - ), - ), ), ), Builder( @@ -935,14 +937,12 @@ class _TokenSendViewState extends ConsumerState { child: Text( error, textAlign: TextAlign.left, - style: STextStyles.label( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.label(context) + .copyWith( + color: Theme.of(context) .extension()! .textError, - ), + ), ), ), ); @@ -977,23 +977,21 @@ class _TokenSendViewState extends ConsumerState { autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, style: STextStyles.smallMed14(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark, + color: Theme.of( + context, + ).extension()!.textDark, ), key: const Key( "amountInputFieldCryptoTextFieldKey", ), controller: cryptoAmountController, focusNode: _cryptoFocus, - keyboardType: - Util.isDesktop - ? null - : const TextInputType.numberWithOptions( - signed: false, - decimal: true, - ), + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( @@ -1026,14 +1024,12 @@ class _TokenSendViewState extends ConsumerState { ref .watch(pAmountUnit(coin)) .unitForContract(tokenContract), - style: STextStyles.smallMed14( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.smallMed14(context) + .copyWith( + color: Theme.of(context) .extension()! .accentColorDark, - ), + ), ), ), ), @@ -1044,26 +1040,25 @@ class _TokenSendViewState extends ConsumerState { if (Prefs.instance.externalCalls) TextField( autocorrect: Util.isDesktop ? false : true, - enableSuggestions: - Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop + ? false + : true, style: STextStyles.smallMed14(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark, + color: Theme.of( + context, + ).extension()!.textDark, ), key: const Key( "amountInputFieldFiatTextFieldKey", ), controller: baseAmountController, focusNode: _baseFocus, - keyboardType: - Util.isDesktop - ? null - : const TextInputType.numberWithOptions( - signed: false, - decimal: true, - ), + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( @@ -1098,14 +1093,12 @@ class _TokenSendViewState extends ConsumerState { (value) => value.currency, ), ), - style: STextStyles.smallMed14( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.smallMed14(context) + .copyWith( + color: Theme.of(context) .extension()! .accentColorDark, - ), + ), ), ), ), @@ -1124,41 +1117,42 @@ class _TokenSendViewState extends ConsumerState { ), child: TextField( autocorrect: Util.isDesktop ? false : true, - enableSuggestions: - Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop + ? false + : true, controller: noteController, focusNode: _noteFocusNode, style: STextStyles.field(context), onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Type something...", - _noteFocusNode, - context, - ).copyWith( - suffixIcon: - noteController.text.isNotEmpty + decoration: + standardInputDecoration( + "Type something...", + _noteFocusNode, + context, + ).copyWith( + 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, - ), + ), ), ), const SizedBox(height: 12), @@ -1172,8 +1166,9 @@ class _TokenSendViewState extends ConsumerState { children: [ TextField( autocorrect: Util.isDesktop ? false : true, - enableSuggestions: - Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop + ? false + : true, controller: feeController, readOnly: true, textInputAction: TextInputAction.none, @@ -1183,10 +1178,9 @@ class _TokenSendViewState extends ConsumerState { horizontal: 12, ), child: RawMaterialButton( - splashColor: - Theme.of( - context, - ).extension()!.highlight, + splashColor: Theme.of( + context, + ).extension()!.highlight, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -1201,19 +1195,21 @@ class _TokenSendViewState extends ConsumerState { top: Radius.circular(20), ), ), - builder: - (_) => TransactionFeeSelectionSheet( + builder: (_) => + TransactionFeeSelectionSheet( walletId: walletId, isToken: true, - amount: (Decimal.tryParse( - cryptoAmountController - .text, - ) ?? - Decimal.zero) - .toAmount( - fractionDigits: - tokenContract.decimals, - ), + amount: + (Decimal.tryParse( + cryptoAmountController + .text, + ) ?? + Decimal.zero) + .toAmount( + fractionDigits: + tokenContract + .decimals, + ), updateChosen: (String fee) { if (fee == "custom") { if (!isCustomFee.value) { @@ -1317,28 +1313,24 @@ class _TokenSendViewState extends ConsumerState { TextButton( onPressed: ref - .watch( - previewTokenTxButtonStateProvider - .state, - ) - .state - ? _previewTransaction - : null, + .watch( + previewTokenTxButtonStateProvider.state, + ) + .state + ? _previewTransaction + : null, style: ref - .watch( - previewTokenTxButtonStateProvider - .state, - ) - .state - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle( - context, - ), + .watch( + previewTokenTxButtonStateProvider.state, + ) + .state + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context), child: Text( "Preview", style: STextStyles.button(context), diff --git a/lib/pages/settings_views/global_settings_view/global_settings_view.dart b/lib/pages/settings_views/global_settings_view/global_settings_view.dart index 5dc6d4101f..754c3c2e29 100644 --- a/lib/pages/settings_views/global_settings_view/global_settings_view.dart +++ b/lib/pages/settings_views/global_settings_view/global_settings_view.dart @@ -16,12 +16,14 @@ import '../../../app_config.dart'; import '../../../route_generator.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; +import '../../../utilities/constants.dart'; import '../../../utilities/text_styles.dart'; import '../../../widgets/background.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../widgets/rounded_white_container.dart'; import '../../address_book_views/address_book_view.dart'; import '../../pinpad_views/lock_screen_view.dart'; +import '../../shopinbit/shopinbit_settings_view.dart'; import '../sub_widgets/settings_list_button.dart'; import 'about_view.dart'; import 'advanced_views/advanced_settings_view.dart'; @@ -96,21 +98,19 @@ class GlobalSettingsView extends StatelessWidget { Navigator.push( context, RouteGenerator.getRoute( - shouldUseMaterialRoute: - RouteGenerator - .useMaterialPageRoute, - builder: - (_) => const LockscreenView( - showBackButton: true, - routeOnSuccess: - StackBackupView.routeName, - biometricsCancelButtonString: - "CANCEL", - biometricsLocalizedReason: - "Authenticate to access ${AppConfig.prefix} backup & restore settings", - biometricsAuthenticationTitle: - "${AppConfig.prefix} backup", - ), + shouldUseMaterialRoute: RouteGenerator + .useMaterialPageRoute, + builder: (_) => const LockscreenView( + showBackButton: true, + routeOnSuccess: + StackBackupView.routeName, + biometricsCancelButtonString: + "CANCEL", + biometricsLocalizedReason: + "Authenticate to access ${AppConfig.prefix} backup & restore settings", + biometricsAuthenticationTitle: + "${AppConfig.prefix} backup", + ), settings: const RouteSettings( name: "/swblockscreen", ), @@ -246,6 +246,25 @@ class GlobalSettingsView extends StatelessWidget { ); }, ), + if (Constants.enableExchange && + AppConfig.hasFeature( + AppFeature.shopinBit, + )) + Column( + children: [ + const SizedBox(height: 8), + SettingsListButton( + iconAssetName: Assets.svg.key, + iconSize: 16, + title: "ShopinBit", + onPressed: () { + Navigator.of(context).pushNamed( + ShopInBitSettingsView.routeName, + ); + }, + ), + ], + ), const SizedBox(height: 8), SettingsListButton( iconAssetName: Assets.svg.questionMessage, diff --git a/lib/pages/settings_views/global_settings_view/hidden_settings.dart b/lib/pages/settings_views/global_settings_view/hidden_settings.dart index 52b78cbcf0..5a4a3bb704 100644 --- a/lib/pages/settings_views/global_settings_view/hidden_settings.dart +++ b/lib/pages/settings_views/global_settings_view/hidden_settings.dart @@ -41,19 +41,17 @@ class HiddenSettings extends StatelessWidget { padding: const EdgeInsets.all(8.0), child: AppBarIconButton( size: 32, - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, shadows: const [], icon: SvgPicture.asset( Assets.svg.arrowLeft, width: 18, height: 18, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), onPressed: Navigator.of(context).pop, ), @@ -81,8 +79,8 @@ class HiddenSettings extends StatelessWidget { ref .read(prefsChangeNotifierProvider) .advancedFiroFeatures = !ref - .read(prefsChangeNotifierProvider) - .advancedFiroFeatures; + .read(prefsChangeNotifierProvider) + .advancedFiroFeatures; }, child: RoundedWhiteContainer( child: Text( @@ -94,10 +92,9 @@ class HiddenSettings extends StatelessWidget { ? "Hide advanced Firo features" : "Show advanced Firo features", style: STextStyles.button(context).copyWith( - color: - Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), ), @@ -109,10 +106,9 @@ class HiddenSettings extends StatelessWidget { builder: (_, ref, __) { return GestureDetector( onTap: () async { - final notifs = - ref - .read(notificationsProvider) - .notifications; + final notifs = ref + .read(notificationsProvider) + .notifications; for (final n in notifs) { await ref @@ -137,10 +133,9 @@ class HiddenSettings extends StatelessWidget { child: Text( "Delete notifications", style: STextStyles.button(context).copyWith( - color: - Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), ), @@ -153,17 +148,17 @@ class HiddenSettings extends StatelessWidget { return GestureDetector( onTap: () async { ref - .read(prefsChangeNotifierProvider) - .logsPath = null; + .read(prefsChangeNotifierProvider) + .logsPath = + null; }, child: RoundedWhiteContainer( child: Text( "Reset log location", style: STextStyles.button(context).copyWith( - color: - Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), ), @@ -285,14 +280,14 @@ class HiddenSettings extends StatelessWidget { 6) { return GestureDetector( onTap: () async { - final familiarity = - ref - .read(prefsChangeNotifierProvider) - .familiarity; + final familiarity = ref + .read(prefsChangeNotifierProvider) + .familiarity; if (familiarity < 6) { ref - .read(prefsChangeNotifierProvider) - .familiarity = 6; + .read(prefsChangeNotifierProvider) + .familiarity = + 6; Constants.exchangeForExperiencedUsers(6); } @@ -300,14 +295,12 @@ class HiddenSettings extends StatelessWidget { child: RoundedWhiteContainer( child: Text( "Enable exchange", - style: STextStyles.button( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.button(context) + .copyWith( + color: Theme.of(context) .extension()! .accentColorDark, - ), + ), ), ), ); @@ -323,22 +316,18 @@ class HiddenSettings extends StatelessWidget { onTap: () async { await showDialog( context: context, - builder: - (_) => TorWarningDialog( - coin: Stellar( - CryptoCurrencyNetwork.main, - ), - ), + builder: (_) => TorWarningDialog( + coin: Stellar(CryptoCurrencyNetwork.main), + ), ); }, child: RoundedWhiteContainer( child: Text( "Show Tor warning popup", style: STextStyles.button(context).copyWith( - color: - Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), ), diff --git a/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart b/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart index e0ebd4c532..bd009f1710 100644 --- a/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart +++ b/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart @@ -35,8 +35,6 @@ import '../../../../utilities/tor_plain_net_option_enum.dart'; import '../../../../utilities/util.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; -import '../../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; -import '../../../../wallets/wallet/intermediate/lib_salvium_wallet.dart'; import '../../../../widgets/background.dart'; import '../../../../widgets/conditional_parent.dart'; import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -99,12 +97,11 @@ class _AddEditNodeViewState extends ConsumerState { } Future attemptSave() async { - final canConnect = await testNodeConnection( + final canConnect = await ref.read(testNodeConnectionProvider)( context: context, onSuccess: _onTestSuccess, cryptoCurrency: coin, nodeFormData: ref.read(nodeFormDataProvider), - ref: ref, ); bool? shouldSave; @@ -114,107 +111,102 @@ class _AddEditNodeViewState extends ConsumerState { context: context, useSafeArea: true, barrierDismissible: true, - builder: - (_) => - isDesktop - ? DesktopDialog( - maxWidth: 440, - maxHeight: 300, - child: Column( + builder: (_) => isDesktop + ? DesktopDialog( + maxWidth: 440, + maxHeight: 300, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 32), + child: Row( children: [ - Padding( - padding: const EdgeInsets.only(top: 32), - child: Row( - children: [ - const SizedBox(width: 32), - Text( - "Server currently unreachable", - style: STextStyles.desktopH3(context), - ), - ], - ), + const SizedBox(width: 32), + Text( + "Server currently unreachable", + style: STextStyles.desktopH3(context), ), - Expanded( - child: Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - top: 16, - bottom: 32, - ), - child: Column( - children: [ - const Spacer(), - Text( - "Would you like to save this node anyways?", - style: STextStyles.desktopTextMedium( + ], + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + top: 16, + bottom: 32, + ), + child: Column( + children: [ + const Spacer(), + Text( + "Would you like to save this node anyways?", + style: STextStyles.desktopTextMedium(context), + ), + const Spacer(flex: 2), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: isDesktop + ? ButtonHeight.l + : null, + onPressed: () => Navigator.of( context, - ), + rootNavigator: true, + ).pop(false), ), - const Spacer(flex: 2), - Row( - children: [ - Expanded( - child: SecondaryButton( - label: "Cancel", - buttonHeight: - isDesktop ? ButtonHeight.l : null, - onPressed: - () => Navigator.of( - context, - rootNavigator: true, - ).pop(false), - ), - ), - const SizedBox(width: 16), - Expanded( - child: PrimaryButton( - label: "Save", - buttonHeight: - isDesktop ? ButtonHeight.l : null, - onPressed: - () => Navigator.of( - context, - rootNavigator: true, - ).pop(true), - ), - ), - ], + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Save", + buttonHeight: isDesktop + ? ButtonHeight.l + : null, + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(true), ), - ], - ), + ), + ], ), - ), - ], - ), - ) - : StackDialog( - title: "Server currently unreachable", - message: "Would you like to save this node anyways?", - leftButton: TextButton( - onPressed: () async { - Navigator.of(context).pop(false); - }, - child: Text( - "Cancel", - style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, - ), + ], ), ), - rightButton: TextButton( - onPressed: () async { - Navigator.of(context).pop(true); - }, - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - child: Text("Save", style: STextStyles.button(context)), - ), ), + ], + ), + ) + : StackDialog( + title: "Server currently unreachable", + message: "Would you like to save this node anyways?", + leftButton: TextButton( + onPressed: () async { + Navigator.of(context).pop(false); + }, + child: Text( + "Cancel", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + rightButton: TextButton( + onPressed: () async { + Navigator.of(context).pop(true); + }, + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + child: Text("Save", style: STextStyles.button(context)), + ), + ), ).then((value) { if (value is bool && value) { shouldSave = true; @@ -233,7 +225,7 @@ class _AddEditNodeViewState extends ConsumerState { // strip unused path String address = formData.host!; - if (coin is LibMoneroWallet || coin is LibSalviumWallet) { + if (coin is CryptonoteCurrency) { if (address.startsWith("http")) { final uri = Uri.parse(address); address = "${uri.scheme}://${uri.host}"; @@ -267,6 +259,7 @@ class _AddEditNodeViewState extends ConsumerState { clearnetEnabled: plainEnabled, forceNoTor: forceNoTor, isPrimary: false, + nodeApiSecret: formData.apiSecret, ); await ref @@ -296,6 +289,7 @@ class _AddEditNodeViewState extends ConsumerState { clearnetEnabled: plainEnabled, forceNoTor: forceNoTor, isPrimary: formData.isPrimary ?? false, + nodeApiSecret: formData.apiSecret, ); await ref @@ -382,7 +376,7 @@ class _AddEditNodeViewState extends ConsumerState { } else { try { final result = await ref.read(pBarcodeScanner).scan(context: context); - await _processQrData(result.rawContent); + await _processQrData(result.rawContent ?? ""); } on PlatformException catch (e, s) { if (mounted) { try { @@ -450,8 +444,9 @@ class _AddEditNodeViewState extends ConsumerState { saveEnabled = false; testConnectionEnabled = false; } else { - final node = - ref.read(nodeServiceChangeNotifierProvider).getNodeById(id: nodeId!)!; + final node = ref + .read(nodeServiceChangeNotifierProvider) + .getNodeById(id: nodeId!)!; testConnectionEnabled = node.host.isNotEmpty; saveEnabled = testConnectionEnabled && node.name.isNotEmpty; } @@ -468,205 +463,193 @@ class _AddEditNodeViewState extends ConsumerState { Widget build(BuildContext context) { final NodeModel? node = viewType == AddEditNodeViewType.edit && nodeId != null - ? ref.watch( - nodeServiceChangeNotifierProvider.select( - (value) => value.getNodeById(id: nodeId!), - ), - ) - : null; + ? ref.watch( + nodeServiceChangeNotifierProvider.select( + (value) => value.getNodeById(id: nodeId!), + ), + ) + : null; return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton( - onPressed: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 75), - ); - } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - ), - title: Text( - viewType == AddEditNodeViewType.edit - ? "Edit node" - : "Add node", - style: STextStyles.navBarTitle(context), - ), - actions: [ - if (viewType == AddEditNodeViewType.add && - coin - is CryptonoteCurrency) // TODO: [prio=low] do something other than `coin is CryptonoteCurrency` in the future - Padding( - padding: const EdgeInsets.only( - top: 10, - bottom: 10, - right: 10, - ), - child: AspectRatio( - aspectRatio: 1, - child: AppBarIconButton( - key: const Key("qrNodeAppBarButtonKey"), - size: 36, - shadows: const [], - color: - Theme.of( - context, - ).extension()!.background, - icon: QrCodeIcon( - width: 20, - height: 20, - color: - Theme.of( - context, - ).extension()!.accentColorDark, - ), - onPressed: _scanQr, - ), + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + viewType == AddEditNodeViewType.edit ? "Edit node" : "Add node", + style: STextStyles.navBarTitle(context), + ), + actions: [ + if (viewType == AddEditNodeViewType.add && + coin + is CryptonoteCurrency) // TODO: [prio=low] do something other than `coin is CryptonoteCurrency` in the future + Padding( + padding: const EdgeInsets.only( + top: 10, + bottom: 10, + right: 10, + ), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + key: const Key("qrNodeAppBarButtonKey"), + size: 36, + shadows: const [], + color: Theme.of( + context, + ).extension()!.background, + icon: QrCodeIcon( + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), + onPressed: _scanQr, ), - if (viewType == AddEditNodeViewType.edit && - ref - .watch( - nodeServiceChangeNotifierProvider.select( - (value) => value.getNodesFor(coin), - ), - ) - .length > - 1) - Padding( - padding: const EdgeInsets.only( - top: 10, - bottom: 10, - right: 10, + ), + ), + if (viewType == AddEditNodeViewType.edit && + ref + .watch( + nodeServiceChangeNotifierProvider.select( + (value) => value.getNodesFor(coin), + ), + ) + .length > + 1) + Padding( + padding: const EdgeInsets.only( + top: 10, + bottom: 10, + right: 10, + ), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + key: const Key("deleteNodeAppBarButtonKey"), + size: 36, + shadows: const [], + color: Theme.of( + context, + ).extension()!.background, + icon: SvgPicture.asset( + Assets.svg.trash, + color: Theme.of( + context, + ).extension()!.accentColorDark, + width: 20, + height: 20, ), - child: AspectRatio( - aspectRatio: 1, - child: AppBarIconButton( - key: const Key("deleteNodeAppBarButtonKey"), - size: 36, - shadows: const [], - color: - Theme.of( - context, - ).extension()!.background, - icon: SvgPicture.asset( - Assets.svg.trash, - color: - Theme.of( - context, - ).extension()!.accentColorDark, - width: 20, - height: 20, - ), - onPressed: () async { - Navigator.popUntil( - context, - ModalRoute.withName( - widget.routeOnSuccessOrDelete, - ), - ); + onPressed: () async { + Navigator.popUntil( + context, + ModalRoute.withName(widget.routeOnSuccessOrDelete), + ); - await ref - .read(nodeServiceChangeNotifierProvider) - .delete(nodeId!, true); - }, - ), - ), + await ref + .read(nodeServiceChangeNotifierProvider) + .delete(nodeId!, true); + }, ), - ], - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.only( - top: 12, - left: 12, - right: 12, - bottom: 12, - ), - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(4), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 8, - ), - child: IntrinsicHeight(child: child), - ), - ), - ); - }, ), ), + ], + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only( + top: 12, + left: 12, + right: 12, + bottom: 12, + ), + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(4), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 8, + ), + child: IntrinsicHeight(child: child), + ), + ), + ); + }, ), ), ), + ), + ), child: ConditionalParent( condition: isDesktop, - builder: - (child) => DesktopDialog( - maxWidth: 580, - maxHeight: double.infinity, - child: Column( - mainAxisSize: MainAxisSize.min, + builder: (child) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const SizedBox(height: 8), Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - const SizedBox(width: 8), - const AppBarBackButton(iconSize: 24, size: 40), - Text( - "Add new node", - style: STextStyles.desktopH3(context), - ), - ], + const SizedBox(width: 8), + const AppBarBackButton(iconSize: 24, size: 40), + Text( + "Add new node", + style: STextStyles.desktopH3(context), ), - 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()! - .textFieldDefaultBG - : Theme.of( - context, - ).extension()!.background, - icon: const QrCodeIcon(width: 21, height: 21), - onPressed: _scanQr, - ), - ), ], ), - Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - top: 16, - bottom: 32, + 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()!.textFieldDefaultBG + : Theme.of( + context, + ).extension()!.background, + icon: const QrCodeIcon(width: 21, height: 21), + onPressed: _scanQr, + ), ), - child: child, - ), ], ), - ), + Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + top: 16, + bottom: 32, + ), + child: child, + ), + ], + ), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -704,37 +687,36 @@ class _AddEditNodeViewState extends ConsumerState { label: "Test connection", enabled: testConnectionEnabled, buttonHeight: isDesktop ? ButtonHeight.l : null, - onPressed: - testConnectionEnabled - ? () async { - final testPassed = await testNodeConnection( - context: context, - onSuccess: _onTestSuccess, - cryptoCurrency: coin, - nodeFormData: ref.read(nodeFormDataProvider), - ref: ref, - ); - if (context.mounted) { - if (testPassed) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.success, - message: "Server ping success", - context: context, - ), - ); - } else { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Server unreachable", - context: context, - ), - ); - } + onPressed: testConnectionEnabled + ? () async { + final testPassed = + await ref.read(testNodeConnectionProvider)( + context: context, + onSuccess: _onTestSuccess, + cryptoCurrency: coin, + nodeFormData: ref.read(nodeFormDataProvider), + ); + if (context.mounted) { + if (testPassed) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Server ping success", + context: context, + ), + ); + } else { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Server unreachable", + context: context, + ), + ); } } - : null, + } + : null, ), ), if (isDesktop) const SizedBox(width: 16), @@ -752,14 +734,13 @@ class _AddEditNodeViewState extends ConsumerState { if (!isDesktop) const SizedBox(height: 16), if (!isDesktop) TextButton( - style: - saveEnabled - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle(context), + style: saveEnabled + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context), onPressed: saveEnabled ? attemptSave : null, child: Text("Save", style: STextStyles.button(context)), ), @@ -771,7 +752,7 @@ class _AddEditNodeViewState extends ConsumerState { } class NodeFormData { - String? name, host, login, password; + String? name, host, login, password, apiSecret; int? port; bool? useSSL, isFailover, trusted, forceNoTor, isPrimary; TorPlainNetworkOption? netOption; @@ -812,12 +793,14 @@ class _NodeFormState extends ConsumerState { late final TextEditingController _portController; late final TextEditingController _passwordController; late final TextEditingController _usernameController; + late final TextEditingController _apiSecretController; final _nameFocusNode = FocusNode(); final _passwordFocusNode = FocusNode(); final _portFocusNode = FocusNode(); final _hostFocusNode = FocusNode(); final _usernameFocusNode = FocusNode(); + final _apiSecretFocusNode = FocusNode(); bool _useSSL = false; bool _isFailover = false; @@ -873,10 +856,15 @@ class _NodeFormState extends ConsumerState { onChanged?.call(canSave, canTestConnection); ref.read(nodeFormDataProvider).name = _nameController.text; ref.read(nodeFormDataProvider).host = _hostController.text; - ref.read(nodeFormDataProvider).login = - _usernameController.text.isEmpty ? null : _usernameController.text; - ref.read(nodeFormDataProvider).password = - _passwordController.text.isEmpty ? null : _passwordController.text; + ref.read(nodeFormDataProvider).login = _usernameController.text.isEmpty + ? null + : _usernameController.text; + ref.read(nodeFormDataProvider).password = _passwordController.text.isEmpty + ? null + : _passwordController.text; + ref.read(nodeFormDataProvider).apiSecret = _apiSecretController.text.isEmpty + ? null + : _apiSecretController.text; ref.read(nodeFormDataProvider).port = port; ref.read(nodeFormDataProvider).useSSL = _useSSL; ref.read(nodeFormDataProvider).isFailover = _isFailover; @@ -895,6 +883,7 @@ class _NodeFormState extends ConsumerState { _portController = TextEditingController(); _passwordController = TextEditingController(); _usernameController = TextEditingController(); + _apiSecretController = TextEditingController(); enableAuthFields = _checkShouldEnableAuthFields(widget.coin); @@ -916,6 +905,7 @@ class _NodeFormState extends ConsumerState { _hostController.text = node.host; _portController.text = node.port.toString(); _usernameController.text = node.loginName ?? ""; + _apiSecretController.text = node.nodeApiSecret ?? ""; _useSSL = node.useSSL; _isFailover = node.isFailover; _trusted = node.trusted ?? false; @@ -959,12 +949,14 @@ class _NodeFormState extends ConsumerState { _portController.dispose(); _passwordController.dispose(); _usernameController.dispose(); + _apiSecretController.dispose(); _nameFocusNode.dispose(); _passwordFocusNode.dispose(); _usernameFocusNode.dispose(); _hostFocusNode.dispose(); _portFocusNode.dispose(); + _apiSecretFocusNode.dispose(); super.dispose(); } @@ -985,31 +977,32 @@ class _NodeFormState extends ConsumerState { controller: _nameController, focusNode: _nameFocusNode, style: STextStyles.field(context), - decoration: standardInputDecoration( - "Node name", - _nameFocusNode, - context, - ).copyWith( - suffixIcon: - !shouldBeReadOnly && _nameController.text.isNotEmpty + decoration: + standardInputDecoration( + "Node name", + _nameFocusNode, + context, + ).copyWith( + suffixIcon: + !shouldBeReadOnly && _nameController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - _nameController.text = ""; - _updateState(); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + _nameController.text = ""; + _updateState(); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), onChanged: (newValue) { _updateState(); setState(() {}); @@ -1030,31 +1023,32 @@ class _NodeFormState extends ConsumerState { controller: _hostController, focusNode: _hostFocusNode, style: STextStyles.field(context), - decoration: standardInputDecoration( - (widget.coin is! CryptonoteCurrency) ? "IP address" : "Url", - _hostFocusNode, - context, - ).copyWith( - suffixIcon: - !shouldBeReadOnly && _hostController.text.isNotEmpty + decoration: + standardInputDecoration( + (widget.coin is! CryptonoteCurrency) ? "IP address" : "Url", + _hostFocusNode, + context, + ).copyWith( + suffixIcon: + !shouldBeReadOnly && _hostController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - _hostController.text = ""; - _updateState(); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + _hostController.text = ""; + _updateState(); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), onChanged: (newValue) { // parse port hack try { @@ -1098,7 +1092,7 @@ class _NodeFormState extends ConsumerState { } else { enableSSLCheckbox = true; } - } else if (widget.coin is LibMoneroWallet || widget.coin is LibSalviumWallet) { + } else if (widget.coin is CryptonoteCurrency) { if (newValue.startsWith("https://")) { _useSSL = true; } else if (newValue.startsWith("http://")) { @@ -1139,31 +1133,28 @@ class _NodeFormState extends ConsumerState { inputFormatters: [FilteringTextInputFormatter.digitsOnly], keyboardType: TextInputType.number, style: STextStyles.field(context), - decoration: standardInputDecoration( - "Port", - _portFocusNode, - context, - ).copyWith( - suffixIcon: - !shouldBeReadOnly && _portController.text.isNotEmpty + decoration: standardInputDecoration("Port", _portFocusNode, context) + .copyWith( + suffixIcon: + !shouldBeReadOnly && _portController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - _portController.text = ""; - _updateState(); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + _portController.text = ""; + _updateState(); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), onChanged: (newValue) { _updateState(); setState(() {}); @@ -1184,31 +1175,32 @@ class _NodeFormState extends ConsumerState { enabled: enableField(_usernameController), focusNode: _usernameFocusNode, style: STextStyles.field(context), - decoration: standardInputDecoration( - "Login (optional)", - _usernameFocusNode, - context, - ).copyWith( - suffixIcon: - !shouldBeReadOnly && _usernameController.text.isNotEmpty + decoration: + standardInputDecoration( + "Login (optional)", + _usernameFocusNode, + context, + ).copyWith( + suffixIcon: + !shouldBeReadOnly && _usernameController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - _usernameController.text = ""; - _updateState(); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + _usernameController.text = ""; + _updateState(); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), onChanged: (newValue) { _updateState(); setState(() {}); @@ -1230,31 +1222,32 @@ class _NodeFormState extends ConsumerState { obscureText: true, focusNode: _passwordFocusNode, style: STextStyles.field(context), - decoration: standardInputDecoration( - "Password (optional)", - _passwordFocusNode, - context, - ).copyWith( - suffixIcon: - !shouldBeReadOnly && _passwordController.text.isNotEmpty + decoration: + standardInputDecoration( + "Password (optional)", + _passwordFocusNode, + context, + ).copyWith( + suffixIcon: + !shouldBeReadOnly && _passwordController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - _passwordController.text = ""; - _updateState(); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + _passwordController.text = ""; + _updateState(); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), onChanged: (newValue) { _updateState(); setState(() {}); @@ -1262,19 +1255,66 @@ class _NodeFormState extends ConsumerState { ), ), if (enableAuthFields) const SizedBox(height: 8), + if (widget.coin is Mimblewimblecoin) + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + controller: _apiSecretController, + readOnly: shouldBeReadOnly, + enabled: enableField(_apiSecretController), + obscureText: true, + focusNode: _apiSecretFocusNode, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "API secret (optional)", + _apiSecretFocusNode, + context, + ).copyWith( + suffixIcon: + !shouldBeReadOnly && + _apiSecretController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + _apiSecretController.text = ""; + _updateState(); + }, + ), + ], + ), + ), + ) + : null, + ), + onChanged: (newValue) { + _updateState(); + setState(() {}); + }, + ), + ), + if (widget.coin is Mimblewimblecoin) const SizedBox(height: 8), if (widget.coin is! CryptonoteCurrency) Row( children: [ GestureDetector( - onTap: - !shouldBeReadOnly && enableSSLCheckbox - ? () { - setState(() { - _useSSL = !_useSSL; - }); - _updateState(); - } - : null, + onTap: !shouldBeReadOnly && enableSSLCheckbox + ? () { + setState(() { + _useSSL = !_useSSL; + }); + _updateState(); + } + : null, child: Container( color: Colors.transparent, child: Row( @@ -1283,26 +1323,24 @@ class _NodeFormState extends ConsumerState { width: 20, height: 20, child: Checkbox( - fillColor: - !shouldBeReadOnly && enableSSLCheckbox - ? null - : MaterialStateProperty.all( - Theme.of(context) - .extension()! - .checkboxBGDisabled, - ), + fillColor: !shouldBeReadOnly && enableSSLCheckbox + ? null + : MaterialStateProperty.all( + Theme.of(context) + .extension()! + .checkboxBGDisabled, + ), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, value: _useSSL, - onChanged: - !shouldBeReadOnly && enableSSLCheckbox - ? (newValue) { - setState(() { - _useSSL = newValue!; - }); - _updateState(); - } - : null, + onChanged: !shouldBeReadOnly && enableSSLCheckbox + ? (newValue) { + setState(() { + _useSSL = newValue!; + }); + _updateState(); + } + : null, ), ), const SizedBox(width: 12), @@ -1316,19 +1354,18 @@ class _NodeFormState extends ConsumerState { ), ], ), - if (widget.coin is LibMoneroWallet || widget.coin is LibSalviumWallet) + if (widget.coin is CryptonoteCurrency) Row( children: [ GestureDetector( - onTap: - !widget.readOnly /*&& trustedCheckbox*/ - ? () { - setState(() { - _trusted = !_trusted; - }); - _updateState(); - } - : null, + onTap: !widget.readOnly /*&& trustedCheckbox*/ + ? () { + setState(() { + _trusted = !_trusted; + }); + _updateState(); + } + : null, child: Container( color: Colors.transparent, child: Row( @@ -1337,26 +1374,24 @@ class _NodeFormState extends ConsumerState { width: 20, height: 20, child: Checkbox( - fillColor: - !widget.readOnly - ? null - : MaterialStateProperty.all( - Theme.of(context) - .extension()! - .checkboxBGDisabled, - ), + fillColor: !widget.readOnly + ? null + : MaterialStateProperty.all( + Theme.of(context) + .extension()! + .checkboxBGDisabled, + ), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, value: _trusted, - onChanged: - !widget.readOnly - ? (newValue) { - setState(() { - _trusted = newValue!; - }); - _updateState(); - } - : null, + onChanged: !widget.readOnly + ? (newValue) { + setState(() { + _trusted = newValue!; + }); + _updateState(); + } + : null, ), ), const SizedBox(width: 12), @@ -1373,9 +1408,7 @@ class _NodeFormState extends ConsumerState { if (widget.coin is! CryptonoteCurrency && widget.coin is! Epiccash && widget.coin is! Mimblewimblecoin) - const SizedBox( - height: 8, - ), + const SizedBox(height: 8), if (widget.coin is! CryptonoteCurrency && widget.coin is! Epiccash && widget.coin is! Mimblewimblecoin) @@ -1509,25 +1542,23 @@ class _NodeFormState extends ConsumerState { width: 20, height: 20, child: Checkbox( - fillColor: - !widget.readOnly - ? null - : MaterialStateProperty.all( - Theme.of( - context, - ).extension()!.checkboxBGDisabled, - ), + fillColor: !widget.readOnly + ? null + : MaterialStateProperty.all( + Theme.of( + context, + ).extension()!.checkboxBGDisabled, + ), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, value: _forceNoTor, - onChanged: - !widget.readOnly - ? (newValue) { - setState(() { - _forceNoTor = newValue!; - }); - _updateState(); - } - : null, + onChanged: !widget.readOnly + ? (newValue) { + setState(() { + _forceNoTor = newValue!; + }); + _updateState(); + } + : null, ), ), const SizedBox(width: 12), @@ -1564,9 +1595,8 @@ class RadioTextButton extends StatelessWidget { Widget build(BuildContext context) { return ConditionalParent( condition: Util.isDesktop, - builder: - (child) => - MouseRegion(cursor: SystemMouseCursors.click, child: child), + builder: (child) => + MouseRegion(cursor: SystemMouseCursors.click, child: child), child: GestureDetector( onTap: () { if (value != groupValue) { @@ -1583,20 +1613,18 @@ class RadioTextButton extends StatelessWidget { width: 20, height: 20, child: Radio( - activeColor: - Theme.of( - context, - ).extension()!.radioButtonIconEnabled, + activeColor: Theme.of( + context, + ).extension()!.radioButtonIconEnabled, value: value, groupValue: groupValue, - onChanged: - !enabled - ? null - : (_) { - if (value != groupValue) { - onChanged.call(value); - } - }, + onChanged: !enabled + ? null + : (_) { + if (value != groupValue) { + onChanged.call(value); + } + }, ), ), const SizedBox(width: 14), diff --git a/lib/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart b/lib/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart index 481c3906d5..233ba0c6b9 100644 --- a/lib/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart +++ b/lib/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart @@ -127,125 +127,113 @@ class _NodeDetailsViewState extends ConsumerState { return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton( - onPressed: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 75), - ); - } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - ), - title: Text( - "Node details", - style: STextStyles.navBarTitle(context), - ), - actions: [ - // if (!nodeId.startsWith(DefaultNodes.defaultNodeIdPrefix)) - Padding( - padding: const EdgeInsets.only( - top: 10, - bottom: 10, - right: 10, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "Node details", + style: STextStyles.navBarTitle(context), + ), + actions: [ + // if (!nodeId.startsWith(DefaultNodes.defaultNodeIdPrefix)) + Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10, right: 10), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + key: const Key("nodeDetailsEditNodeAppBarButtonKey"), + size: 36, + shadows: const [], + color: Theme.of( + context, + ).extension()!.background, + icon: SvgPicture.asset( + Assets.svg.pencil, + color: Theme.of( + context, + ).extension()!.accentColorDark, + width: 20, + height: 20, ), - child: AspectRatio( - aspectRatio: 1, - child: AppBarIconButton( - key: const Key("nodeDetailsEditNodeAppBarButtonKey"), - size: 36, - shadows: const [], - color: - Theme.of( - context, - ).extension()!.background, - icon: SvgPicture.asset( - Assets.svg.pencil, - color: - Theme.of( - context, - ).extension()!.accentColorDark, - width: 20, - height: 20, - ), - onPressed: () { - Navigator.of(context).pushNamed( - AddEditNodeView.routeName, - arguments: Tuple4( - AddEditNodeViewType.edit, - coin, - nodeId, - popRouteName, - ), - ); - }, - ), - ), - ), - ], - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.only(top: 12, left: 12, right: 12), - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(4), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 8, - ), - child: IntrinsicHeight(child: child), - ), + onPressed: () { + Navigator.of(context).pushNamed( + AddEditNodeView.routeName, + arguments: Tuple4( + AddEditNodeViewType.edit, + coin, + nodeId, + popRouteName, ), ); }, ), ), ), + ], + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 12, left: 12, right: 12), + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(4), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 8, + ), + child: IntrinsicHeight(child: child), + ), + ), + ); + }, + ), ), ), + ), + ), child: ConditionalParent( condition: isDesktop, - builder: - (child) => DesktopDialog( - maxWidth: 580, - maxHeight: double.infinity, - child: Column( - mainAxisSize: MainAxisSize.min, + builder: (child) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( children: [ - Row( - children: [ - const SizedBox(width: 8), - const AppBarBackButton(iconSize: 24, size: 40), - Text( - "Node details", - style: STextStyles.desktopH3(context), - ), - ], - ), - Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - top: 16, - bottom: 32, - ), - child: child, - ), + const SizedBox(width: 8), + const AppBarBackButton(iconSize: 24, size: 40), + Text("Node details", style: STextStyles.desktopH3(context)), ], ), - ), + Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + top: 16, + bottom: 32, + ), + child: child, + ), + ], + ), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -260,28 +248,27 @@ class _NodeDetailsViewState extends ConsumerState { if (isDesktop && canDelete) SizedBox( height: 56, - child: - _desktopReadOnly - ? null - : Row( - children: [ - Expanded( - child: DeleteButton( - label: "Delete node", - desktopMed: true, - onPressed: () async { - Navigator.of(context).pop(); + child: _desktopReadOnly + ? null + : Row( + children: [ + Expanded( + child: DeleteButton( + label: "Delete node", + desktopMed: true, + onPressed: () async { + Navigator.of(context).pop(); - await ref - .read(nodeServiceChangeNotifierProvider) - .delete(node!.id, true); - }, - ), + await ref + .read(nodeServiceChangeNotifierProvider) + .delete(node!.id, true); + }, ), - const SizedBox(width: 16), - const Spacer(), - ], - ), + ), + const SizedBox(width: 16), + const Spacer(), + ], + ), ), if (isDesktop && !_desktopReadOnly && canDelete) const SizedBox(height: 45), @@ -292,10 +279,9 @@ class _NodeDetailsViewState extends ConsumerState { label: "Test connection", buttonHeight: isDesktop ? ButtonHeight.l : null, onPressed: () async { - final node = - ref - .read(nodeServiceChangeNotifierProvider) - .getNodeById(id: nodeId)!; + final node = ref + .read(nodeServiceChangeNotifierProvider) + .getNodeById(id: nodeId)!; final TorPlainNetworkOption netOption; if (ref.read(nodeFormDataProvider).netOption != null) { @@ -307,28 +293,27 @@ class _NodeDetailsViewState extends ConsumerState { ); } - final nodeFormData = - NodeFormData() - ..useSSL = node.useSSL - ..trusted = node.trusted - ..name = node.name - ..host = node.host - ..login = node.loginName - ..port = node.port - ..isFailover = node.isFailover - ..netOption = netOption - ..forceNoTor = node.forceNoTor; + final nodeFormData = NodeFormData() + ..useSSL = node.useSSL + ..trusted = node.trusted + ..name = node.name + ..host = node.host + ..login = node.loginName + ..port = node.port + ..isFailover = node.isFailover + ..netOption = netOption + ..forceNoTor = node.forceNoTor; nodeFormData.password = await node.getPassword( ref.read(secureStoreProvider), ); if (context.mounted) { - final testPassed = await testNodeConnection( - context: context, - nodeFormData: nodeFormData, - cryptoCurrency: coin, - ref: ref, - ); + final testPassed = + await ref.read(testNodeConnectionProvider)( + context: context, + nodeFormData: nodeFormData, + cryptoCurrency: coin, + ); if (testPassed) { if (context.mounted) { @@ -359,52 +344,54 @@ class _NodeDetailsViewState extends ConsumerState { if (isDesktop) Expanded( child: - // !nodeId.startsWith(DefaultNodes.defaultNodeIdPrefix) - // ? - PrimaryButton( - label: _desktopReadOnly ? "Edit" : "Save", - buttonHeight: ButtonHeight.l, - onPressed: () async { - final shouldSave = _desktopReadOnly == false; - setState(() { - _desktopReadOnly = !_desktopReadOnly; - }); + // !nodeId.startsWith(DefaultNodes.defaultNodeIdPrefix) + // ? + PrimaryButton( + label: _desktopReadOnly ? "Edit" : "Save", + buttonHeight: ButtonHeight.l, + onPressed: () async { + final shouldSave = _desktopReadOnly == false; + setState(() { + _desktopReadOnly = !_desktopReadOnly; + }); - if (shouldSave) { - final editedNode = node!.copyWith( - host: ref.read(nodeFormDataProvider).host, - port: ref.read(nodeFormDataProvider).port, - name: ref.read(nodeFormDataProvider).name, - useSSL: ref.read(nodeFormDataProvider).useSSL, - trusted: ref.read(nodeFormDataProvider).trusted, - loginName: ref.read(nodeFormDataProvider).login, - isFailover: - ref.read(nodeFormDataProvider).isFailover, - torEnabled: - ref.read(nodeFormDataProvider).netOption == - TorPlainNetworkOption.tor || - ref.read(nodeFormDataProvider).netOption == - TorPlainNetworkOption.both, - clearnetEnabled: - ref.read(nodeFormDataProvider).netOption == - TorPlainNetworkOption.clear || - ref.read(nodeFormDataProvider).netOption == - TorPlainNetworkOption.both, - forceNoTor: - ref.read(nodeFormDataProvider).forceNoTor, - ); - - await ref - .read(nodeServiceChangeNotifierProvider) - .save( - editedNode, - ref.read(nodeFormDataProvider).password, - true, + if (shouldSave) { + final editedNode = node!.copyWith( + host: ref.read(nodeFormDataProvider).host, + port: ref.read(nodeFormDataProvider).port, + name: ref.read(nodeFormDataProvider).name, + useSSL: ref.read(nodeFormDataProvider).useSSL, + trusted: ref.read(nodeFormDataProvider).trusted, + loginName: ref.read(nodeFormDataProvider).login, + isFailover: ref + .read(nodeFormDataProvider) + .isFailover, + torEnabled: + ref.read(nodeFormDataProvider).netOption == + TorPlainNetworkOption.tor || + ref.read(nodeFormDataProvider).netOption == + TorPlainNetworkOption.both, + clearnetEnabled: + ref.read(nodeFormDataProvider).netOption == + TorPlainNetworkOption.clear || + ref.read(nodeFormDataProvider).netOption == + TorPlainNetworkOption.both, + forceNoTor: ref + .read(nodeFormDataProvider) + .forceNoTor, ); - await _notifyWalletsOfUpdatedNode(); - } - }, - ), + + await ref + .read(nodeServiceChangeNotifierProvider) + .save( + editedNode, + ref.read(nodeFormDataProvider).password, + true, + ); + await _notifyWalletsOfUpdatedNode(); + } + }, + ), // : Container() ), ], diff --git a/lib/pages/settings_views/global_settings_view/security_views/security_view.dart b/lib/pages/settings_views/global_settings_view/security_views/security_view.dart index 76331b0bb4..c3608fb969 100644 --- a/lib/pages/settings_views/global_settings_view/security_views/security_view.dart +++ b/lib/pages/settings_views/global_settings_view/security_views/security_view.dart @@ -8,6 +8,8 @@ * */ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -61,54 +63,50 @@ class _SecurityViewState extends ConsumerState { Future _createDuressPin() async { final result = await showDialog( context: context, - builder: - (context) => StackDialogBase( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + builder: (context) => StackDialogBase( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Enable duress PIN", style: STextStyles.pageTitleH2(context)), + const SizedBox(height: 8), + Row( children: [ - Text( - "Enable duress PIN", - style: STextStyles.pageTitleH2(context), + Flexible( + child: Text( + "When unlocking the app with a duress PIN, only wallets" + " marked as visible in duress mode will be loaded and" + " shown. Be aware that providing a duress PIN instead" + " of your real PIN to law enforcement, border agents," + " or other authorities may be considered deception and" + " could carry legal consequences depending on your" + " jurisdiction. Use with care and according to your" + " threat model.", + style: STextStyles.smallMed14(context), + ), ), - const SizedBox(height: 8), - Row( - children: [ - Flexible( - child: Text( - "When unlocking the app with a duress PIN, only wallets" - " marked as visible in duress mode will be loaded and" - " shown. Be aware that providing a duress PIN instead" - " of your real PIN to law enforcement, border agents," - " or other authorities may be considered deception and" - " could carry legal consequences depending on your" - " jurisdiction. Use with care and according to your" - " threat model.", - style: STextStyles.smallMed14(context), - ), - ), - ], + ], + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: () => Navigator.of(context).pop(false), + ), ), - const SizedBox(height: 20), - Row( - children: [ - Expanded( - child: SecondaryButton( - label: "Cancel", - onPressed: () => Navigator.of(context).pop(false), - ), - ), - const SizedBox(width: 8), - Expanded( - child: PrimaryButton( - label: "Ok", - onPressed: () => Navigator.of(context).pop(true), - ), - ), - ], + const SizedBox(width: 8), + Expanded( + child: PrimaryButton( + label: "Ok", + onPressed: () => Navigator.of(context).pop(true), + ), ), ], ), - ), + ], + ), + ), ); if (result == true && mounted) { @@ -116,14 +114,13 @@ class _SecurityViewState extends ConsumerState { context, RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: - (_) => const LockscreenView( - showBackButton: true, - routeOnSuccess: CreateDuressPinView.routeName, - biometricsCancelButtonString: "CANCEL", - biometricsLocalizedReason: "Authenticate to create duress PIN", - biometricsAuthenticationTitle: "Create duress PIN", - ), + builder: (_) => const LockscreenView( + showBackButton: true, + routeOnSuccess: CreateDuressPinView.routeName, + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: "Authenticate to create duress PIN", + biometricsAuthenticationTitle: "Create duress PIN", + ), settings: const RouteSettings(name: "/createDuressPinLockscreen"), ), ); @@ -133,66 +130,62 @@ class _SecurityViewState extends ConsumerState { Future _deleteDuressPin() async { await showDialog( context: context, - builder: - (context) => StackDialogBase( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + builder: (context) => StackDialogBase( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Disable duress PIN", style: STextStyles.pageTitleH2(context)), + const SizedBox(height: 8), + Row( children: [ - Text( - "Disable duress PIN", - style: STextStyles.pageTitleH2(context), - ), - const SizedBox(height: 8), - Row( - children: [ - Flexible( - child: Text( - "Your duress pin will be deleted. " - "You will be asked to create a PIN when you enable this again. " - "Are you sure you want to continue?", + Flexible( + child: Text( + "Your duress pin will be deleted. " + "You will be asked to create a PIN when you enable this again. " + "Are you sure you want to continue?", - style: STextStyles.smallMed14(context), - ), - ), - ], + style: STextStyles.smallMed14(context), + ), ), - const SizedBox(height: 20), - Row( - children: [ - Expanded( - child: SecondaryButton( - label: "Cancel", - onPressed: Navigator.of(context).pop, - ), - ), - const SizedBox(width: 8), - Expanded( - child: PrimaryButton( - label: "Ok", - onPressed: () async { - try { - await ref - .read(secureStoreProvider) - .delete(key: kDuressPinKey); - } catch (e, s) { - Logging.instance.f( - "dpin delete failed!!", - error: e, - stackTrace: s, - ); - } + ], + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 8), + Expanded( + child: PrimaryButton( + label: "Ok", + onPressed: () async { + try { + await ref + .read(secureStoreProvider) + .delete(key: kDuressPinKey); + } catch (e, s) { + Logging.instance.f( + "dpin delete failed!!", + error: e, + stackTrace: s, + ); + } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - ), - ), - ], + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), ), ], ), - ), + ], + ), + ), ); ref.read(prefsChangeNotifierProvider).hasDuressPin = false; @@ -235,15 +228,14 @@ class _SecurityViewState extends ConsumerState { RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: - (_) => const LockscreenView( - showBackButton: true, - routeOnSuccess: ChangePinView.routeName, - biometricsCancelButtonString: "CANCEL", - biometricsLocalizedReason: - "Authenticate to change PIN", - biometricsAuthenticationTitle: "Change PIN", - ), + builder: (_) => const LockscreenView( + showBackButton: true, + routeOnSuccess: ChangePinView.routeName, + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: + "Authenticate to change PIN", + biometricsAuthenticationTitle: "Change PIN", + ), settings: const RouteSettings( name: "/changepinlockscreen", ), @@ -312,8 +304,9 @@ class _SecurityViewState extends ConsumerState { ), onValueChanged: (newValue) { ref - .read(prefsChangeNotifierProvider) - .useBiometrics = newValue; + .read(prefsChangeNotifierProvider) + .useBiometrics = + newValue; }, ), ), @@ -358,8 +351,9 @@ class _SecurityViewState extends ConsumerState { ), onValueChanged: (newValue) { ref - .read(prefsChangeNotifierProvider) - .randomizePIN = newValue; + .read(prefsChangeNotifierProvider) + .randomizePIN = + newValue; }, ), ), @@ -405,8 +399,9 @@ class _SecurityViewState extends ConsumerState { ), onValueChanged: (newValue) { ref - .read(prefsChangeNotifierProvider) - .autoPin = newValue; + .read(prefsChangeNotifierProvider) + .autoPin = + newValue; }, ), ), @@ -417,6 +412,100 @@ class _SecurityViewState extends ConsumerState { }, ), ), + const SizedBox(height: 8), + RoundedWhiteContainer( + child: Consumer( + builder: (_, ref, __) { + return RawMaterialButton( + 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( + "Cover in background", + style: STextStyles.titleBold12(context), + textAlign: TextAlign.left, + ), + SizedBox( + height: 20, + width: 40, + child: DraggableSwitchButton( + isOn: ref.watch( + prefsChangeNotifierProvider.select( + (value) => value.privacyScreen, + ), + ), + onValueChanged: (newValue) { + ref + .read(prefsChangeNotifierProvider) + .privacyScreen = + newValue; + }, + ), + ), + ], + ), + ), + ); + }, + ), + ), + if (Platform.isAndroid) const SizedBox(height: 8), + if (Platform.isAndroid) + RoundedWhiteContainer( + child: Consumer( + builder: (_, ref, __) { + return RawMaterialButton( + 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( + "Disable screenshots", + style: STextStyles.titleBold12(context), + textAlign: TextAlign.left, + ), + SizedBox( + height: 20, + width: 40, + child: DraggableSwitchButton( + isOn: ref.watch( + prefsChangeNotifierProvider.select( + (value) => value.disableScreenShots, + ), + ), + onValueChanged: (newValue) { + ref + .read(prefsChangeNotifierProvider) + .disableScreenShots = + newValue; + }, + ), + ), + ], + ), + ), + ); + }, + ), + ), if (!ref.watch(pDuress)) const SizedBox(height: 8), if (!ref.watch(pDuress)) RoundedWhiteContainer( @@ -508,8 +597,9 @@ class _SecurityViewState extends ConsumerState { ), onChanged: (newValue) { ref - .read(prefsChangeNotifierProvider) - .biometricsDuress = newValue; + .read(prefsChangeNotifierProvider) + .biometricsDuress = + newValue; }, ), ), @@ -536,17 +626,15 @@ class _SecurityViewState extends ConsumerState { RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: - (_) => const LockscreenView( - showBackButton: true, - routeOnSuccess: - AutoLockTimeoutSettingsView.routeName, - biometricsCancelButtonString: "CANCEL", - biometricsLocalizedReason: - "Authenticate to change auto lock settings", - biometricsAuthenticationTitle: - "Auto lock settings", - ), + builder: (_) => const LockscreenView( + showBackButton: true, + routeOnSuccess: + AutoLockTimeoutSettingsView.routeName, + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: + "Authenticate to change auto lock settings", + biometricsAuthenticationTitle: "Auto lock settings", + ), settings: const RouteSettings( name: "/autoLockTimeoutSettingsLockScreen", ), diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/auto_backup_view.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/auto_backup_view.dart index 57785f5f43..849f10be09 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/auto_backup_view.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/auto_backup_view.dart @@ -101,8 +101,9 @@ class _AutoBackupViewState extends ConsumerState { child: Text( "Back", style: STextStyles.button(context).copyWith( - color: - Theme.of(context).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), onPressed: () { @@ -155,8 +156,9 @@ class _AutoBackupViewState extends ConsumerState { child: Text( "Back", style: STextStyles.button(context).copyWith( - color: - Theme.of(context).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), onPressed: () { @@ -313,14 +315,13 @@ class _AutoBackupViewState extends ConsumerState { TextSpan( text: "stackwallet.com.", style: STextStyles.richLink(context), - recognizer: - TapGestureRecognizer() - ..onTap = () { - launchUrl( - Uri.parse("https://stackwallet.com"), - mode: LaunchMode.externalApplication, - ); - }, + recognizer: TapGestureRecognizer() + ..onTap = () { + launchUrl( + Uri.parse("https://stackwallet.com"), + mode: LaunchMode.externalApplication, + ); + }, ), ], ), diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/create_auto_backup_view.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/create_auto_backup_view.dart index 5616ccd9d2..9e32ad392c 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/create_auto_backup_view.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/create_auto_backup_view.dart @@ -8,6 +8,7 @@ * */ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -19,7 +20,6 @@ import 'package:stack_wallet_backup/stack_wallet_backup.dart'; import 'package:zxcvbn/zxcvbn.dart'; import '../../../../app_config.dart'; -import '../../../../notifications/show_flush_bar.dart'; import '../../../../providers/global/prefs_provider.dart'; import '../../../../providers/global/secure_store_provider.dart'; import '../../../../themes/stack_colors.dart'; @@ -27,7 +27,9 @@ import '../../../../utilities/assets.dart'; import '../../../../utilities/constants.dart'; import '../../../../utilities/flutter_secure_storage_interface.dart'; import '../../../../utilities/format.dart'; +import '../../../../utilities/fs.dart'; import '../../../../utilities/logger.dart'; +import '../../../../utilities/show_loading.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; import '../../../../widgets/background.dart'; @@ -77,6 +79,103 @@ class _EnableAutoBackupViewState extends ConsumerState { passwordRepeatController.text.isNotEmpty; } + Future _createEnableAutoBackup() async { + final String pathToSave = fileLocationController.text; + final String passphrase = passwordController.text; + final String repeatPassphrase = passwordRepeatController.text; + + if (validateFail(context, pathToSave, passphrase, repeatPassphrase)) return; + + if (mounted) { + final now = DateTime.now(); + Exception? ex; + final savedPath = await showLoading( + whileFuture: () async { + String adkString; + int adkVersion; + try { + final adk = await compute(generateAdk, passphrase); + adkString = Format.uint8listToString(adk.item2); + adkVersion = adk.item1; + } on Exception catch (e, s) { + final String err = getErrorMessageFromSWBException(e); + Logging.instance.e(err, error: e, stackTrace: s); + rethrow; + } + + await secureStore.write(key: "auto_adk_string", value: adkString); + await secureStore.write( + key: "auto_adk_version_string", + value: adkVersion.toString(), + ); + + final fileToSavePath = createAutoBackupFilename(pathToSave, now); + + final backup = await SWB.createStackWalletJSON( + secureStorage: secureStore, + ); + + final encryptedDataString = await SWB.encryptStackWalletWithADK( + adkString, + jsonEncode(backup), + adkVersion, + ); + + await FS.writeStringToFile( + encryptedDataString, + pathToSave, + fileToSavePath.split("/").last, + ); + + return fileToSavePath; + }(), + context: context, + message: "Encrypting initial backup", + subMessage: "This shouldn't take long", + delay: const Duration(seconds: 1), + onException: (e) => ex = e, + ); + + if (mounted) { + // pop encryption progress dialog + Navigator.of(context).pop(); + + if (savedPath != null) { + ref.read(prefsChangeNotifierProvider).autoBackupLocation = savedPath; + ref.read(prefsChangeNotifierProvider).lastAutoBackup = now; + + ref.read(prefsChangeNotifierProvider).isAutoBackupEnabled = true; + + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => StackOkDialog( + title: "${AppConfig.prefix} Auto Backup enabled and saved to:", + message: savedPath, + ), + ); + if (mounted) { + passwordController.text = ""; + passwordRepeatController.text = ""; + + Navigator.of( + context, + ).popUntil(ModalRoute.withName(AutoBackupView.routeName)); + } + } else { + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => StackOkDialog( + title: "Failed to enable Auto Backup", + message: ex?.toString(), + ), + ); + } + } + } + } + @override void initState() { secureStore = ref.read(secureStoreProvider); @@ -88,7 +187,7 @@ class _EnableAutoBackupViewState extends ConsumerState { passwordFocusNode = FocusNode(); passwordRepeatFocusNode = FocusNode(); - if (Platform.isAndroid || Platform.isIOS) { + if (Platform.isIOS) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { final dir = await stackFileSystem.prepareStorage(); if (mounted) { @@ -151,38 +250,34 @@ class _EnableAutoBackupViewState extends ConsumerState { style: STextStyles.smallMed12(context), ), const SizedBox(height: 10), - if (!Platform.isAndroid && !Platform.isIOS) + if (!Platform.isIOS) TextField( autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, - onTap: - Platform.isAndroid || Platform.isIOS - ? null - : () async { - try { - await stackFileSystem - .prepareStorage(); - - if (mounted) { - await stackFileSystem.pickDir( - context, - ); - } + onTap: Platform.isIOS + ? null + : () async { + try { + await stackFileSystem.prepareStorage(); + if (mounted) { + final filePath = await stackFileSystem + .pickDir(); if (mounted) { setState(() { fileLocationController.text = - stackFileSystem.dirPath ?? ""; + filePath ?? ""; }); } - } catch (e, s) { - Logging.instance.e( - "$e\n$s", - error: e, - stackTrace: s, - ); } - }, + } catch (e, s) { + Logging.instance.e( + "$e\n$s", + error: e, + stackTrace: s, + ); + } + }, controller: fileLocationController, style: STextStyles.field(context), decoration: InputDecoration( @@ -194,10 +289,9 @@ class _EnableAutoBackupViewState extends ConsumerState { const SizedBox(width: 16), SvgPicture.asset( Assets.svg.folder, - color: - Theme.of(context) - .extension()! - .textDark3, + color: Theme.of( + context, + ).extension()!.textDark3, width: 16, height: 16, ), @@ -218,8 +312,7 @@ class _EnableAutoBackupViewState extends ConsumerState { ), onChanged: (newValue) {}, ), - if (!Platform.isAndroid && !Platform.isIOS) - const SizedBox(height: 10), + if (!Platform.isIOS) const SizedBox(height: 10), ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -232,41 +325,41 @@ class _EnableAutoBackupViewState extends ConsumerState { obscureText: hidePassword, enableSuggestions: false, autocorrect: false, - decoration: standardInputDecoration( - "Create passphrase", - passwordFocusNode, - context, - ).copyWith( - suffixIcon: UnconstrainedBox( - child: Row( - children: [ - const SizedBox(width: 16), - GestureDetector( - key: const Key( - "createBackupPasswordFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: SvgPicture.asset( - hidePassword - ? Assets.svg.eye - : Assets.svg.eyeSlash, - color: - Theme.of(context) + decoration: + standardInputDecoration( + "Create passphrase", + passwordFocusNode, + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: Row( + children: [ + const SizedBox(width: 16), + GestureDetector( + key: const Key( + "createBackupPasswordFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of(context) .extension()! .textDark3, - width: 16, - height: 16, - ), + width: 16, + height: 16, + ), + ), + const SizedBox(width: 12), + ], ), - const SizedBox(width: 12), - ], + ), ), - ), - ), onChanged: (newValue) { if (newValue.isEmpty) { setState(() { @@ -317,13 +410,12 @@ class _EnableAutoBackupViewState extends ConsumerState { right: 12, top: passwordFeedback.isNotEmpty ? 4 : 0, ), - child: - passwordFeedback.isNotEmpty - ? Text( - passwordFeedback, - style: STextStyles.infoSmall(context), - ) - : null, + child: passwordFeedback.isNotEmpty + ? Text( + passwordFeedback, + style: STextStyles.infoSmall(context), + ) + : null, ), if (passwordFocusNode.hasFocus || passwordRepeatFocusNode.hasFocus || @@ -339,26 +431,23 @@ class _EnableAutoBackupViewState extends ConsumerState { width: MediaQuery.of(context).size.width - 32 - 24, height: 5, - fillColor: - passwordStrength < 0.51 - ? Theme.of(context) - .extension()! - .accentColorRed - : passwordStrength < 1 - ? Theme.of(context) - .extension()! - .accentColorYellow - : Theme.of(context) - .extension()! - .accentColorGreen, - backgroundColor: - Theme.of(context) - .extension()! - .buttonBackSecondary, - percent: - passwordStrength < 0.25 - ? 0.03 - : passwordStrength, + fillColor: passwordStrength < 0.51 + ? Theme.of( + context, + ).extension()!.accentColorRed + : passwordStrength < 1 + ? Theme.of(context) + .extension()! + .accentColorYellow + : Theme.of(context) + .extension()! + .accentColorGreen, + backgroundColor: Theme.of( + context, + ).extension()!.buttonBackSecondary, + percent: passwordStrength < 0.25 + ? 0.03 + : passwordStrength, ), ), const SizedBox(height: 10), @@ -374,41 +463,41 @@ class _EnableAutoBackupViewState extends ConsumerState { obscureText: hidePassword, enableSuggestions: false, autocorrect: false, - decoration: standardInputDecoration( - "Confirm passphrase", - passwordRepeatFocusNode, - context, - ).copyWith( - suffixIcon: UnconstrainedBox( - child: Row( - children: [ - const SizedBox(width: 16), - GestureDetector( - key: const Key( - "createBackupPasswordFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: SvgPicture.asset( - hidePassword - ? Assets.svg.eye - : Assets.svg.eyeSlash, - color: - Theme.of(context) + decoration: + standardInputDecoration( + "Confirm passphrase", + passwordRepeatFocusNode, + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: Row( + children: [ + const SizedBox(width: 16), + GestureDetector( + key: const Key( + "createBackupPasswordFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of(context) .extension()! .textDark3, - width: 16, - height: 16, - ), + width: 16, + height: 16, + ), + ), + const SizedBox(width: 12), + ], ), - const SizedBox(width: 12), - ], + ), ), - ), - ), onChanged: (newValue) { setState(() {}); // TODO: ? check if passwords match? @@ -425,17 +514,17 @@ class _EnableAutoBackupViewState extends ConsumerState { children: [ TextField( autocorrect: Util.isDesktop ? false : true, - enableSuggestions: - Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop + ? false + : true, readOnly: true, textInputAction: TextInputAction.none, ), Positioned.fill( child: RawMaterialButton( - splashColor: - Theme.of( - context, - ).extension()!.highlight, + splashColor: Theme.of( + context, + ).extension()!.highlight, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -450,9 +539,8 @@ class _EnableAutoBackupViewState extends ConsumerState { top: Radius.circular(20), ), ), - builder: - (_) => - const BackupFrequencyTypeSelectSheet(), + builder: (_) => + const BackupFrequencyTypeSelectSheet(), ); }, child: Padding( @@ -466,10 +554,11 @@ class _EnableAutoBackupViewState extends ConsumerState { Text( Format.prettyFrequencyType( ref.watch( - prefsChangeNotifierProvider.select( - (value) => - value.backupFrequencyType, - ), + prefsChangeNotifierProvider + .select( + (value) => value + .backupFrequencyType, + ), ), ), style: STextStyles.itemSubtitle12( @@ -482,10 +571,9 @@ class _EnableAutoBackupViewState extends ConsumerState { ), child: SvgPicture.asset( Assets.svg.chevronDown, - color: - Theme.of(context) - .extension()! - .textSubtitle2, + color: Theme.of(context) + .extension()! + .textSubtitle2, width: 12, height: 6, ), @@ -500,207 +588,16 @@ class _EnableAutoBackupViewState extends ConsumerState { const Spacer(), const SizedBox(height: 10), TextButton( - style: - shouldEnableCreate - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle(context), - onPressed: - !shouldEnableCreate - ? null - : () async { - final String pathToSave = - fileLocationController.text; - final String passphrase = - passwordController.text; - final String repeatPassphrase = - passwordRepeatController.text; - - if (pathToSave.isEmpty) { - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Directory not chosen", - context: context, - ); - return; - } - if (!(await Directory( - pathToSave, - ).exists())) { - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Directory does not exist", - context: context, - ); - return; - } - if (passphrase.isEmpty) { - showFloatingFlushBar( - type: FlushBarType.warning, - message: "A passphrase is required", - context: context, - ); - return; - } - if (passphrase != repeatPassphrase) { - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Passphrase does not match", - context: context, - ); - return; - } - - showDialog( - context: context, - barrierDismissible: false, - builder: - (_) => const StackDialog( - title: - "Encrypting initial backup", - message: - "This shouldn't take long", - ), - ); - - // make sure the dialog is able to be displayed for at least some time - final fut = Future.delayed( - const Duration(milliseconds: 300), - ); - - String adkString; - int adkVersion; - try { - final adk = await compute( - generateAdk, - passphrase, - ); - adkString = Format.uint8listToString( - adk.item2, - ); - adkVersion = adk.item1; - } on Exception catch (e, s) { - final String err = - getErrorMessageFromSWBException(e); - Logging.instance.e( - "$err\n$s", - error: e, - stackTrace: s, - ); - // pop encryption progress dialog - Navigator.of(context).pop(); - showFloatingFlushBar( - type: FlushBarType.warning, - message: err, - context: context, - ); - return; - } catch (e, s) { - Logging.instance.e( - "", - error: e, - stackTrace: s, - ); - // pop encryption progress dialog - Navigator.of(context).pop(); - showFloatingFlushBar( - type: FlushBarType.warning, - message: "$e", - context: context, - ); - return; - } - - await secureStore.write( - key: "auto_adk_string", - value: adkString, - ); - await secureStore.write( - key: "auto_adk_version_string", - value: adkVersion.toString(), - ); - - final DateTime now = DateTime.now(); - final String fileToSave = - createAutoBackupFilename( - pathToSave, - now, - ); - - final backup = await SWB - .createStackWalletJSON( - secureStorage: secureStore, - ); - - final bool result = await SWB - .encryptStackWalletWithADK( - fileToSave, - adkString, - jsonEncode(backup), - adkVersion, - ); - - // this future should already be complete unless there was an error encrypting - await Future.wait([fut]); - - if (mounted) { - // pop encryption progress dialog - Navigator.of(context).pop(); - - if (result) { - ref - .read(prefsChangeNotifierProvider) - .autoBackupLocation = pathToSave; - ref - .read(prefsChangeNotifierProvider) - .lastAutoBackup = now; - - ref - .read(prefsChangeNotifierProvider) - .isAutoBackupEnabled = true; - - await showDialog( - context: context, - barrierDismissible: false, - builder: - (_) => - Platform.isAndroid - ? StackOkDialog( - title: - "${AppConfig.prefix} Auto Backup enabled and saved to:", - message: fileToSave, - ) - : const StackOkDialog( - title: - "${AppConfig.prefix} Auto Backup enabled!", - ), - ); - if (mounted) { - passwordController.text = ""; - passwordRepeatController.text = ""; - - Navigator.of(context).popUntil( - ModalRoute.withName( - AutoBackupView.routeName, - ), - ); - } - } else { - await showDialog( - context: context, - barrierDismissible: false, - builder: - (_) => const StackOkDialog( - title: - "Failed to enable Auto Backup", - ), - ); - } - } - }, + style: shouldEnableCreate + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context), + onPressed: !shouldEnableCreate + ? null + : _createEnableAutoBackup, child: Text( "Enable Auto Backup", style: STextStyles.button(context), diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/create_backup_view.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/create_backup_view.dart index a04f0de247..0d88f525fb 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/create_backup_view.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/create_backup_view.dart @@ -18,12 +18,13 @@ import 'package:flutter_svg/svg.dart'; import 'package:zxcvbn/zxcvbn.dart'; import '../../../../app_config.dart'; -import '../../../../notifications/show_flush_bar.dart'; import '../../../../providers/global/secure_store_provider.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/assets.dart'; import '../../../../utilities/constants.dart'; +import '../../../../utilities/fs.dart'; import '../../../../utilities/logger.dart'; +import '../../../../utilities/show_loading.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; import '../../../../widgets/background.dart'; @@ -38,16 +39,16 @@ import '../../../../widgets/stack_text_field.dart'; import 'helpers/restore_create_backup.dart'; import 'helpers/swb_file_system.dart'; -class CreateBackupView extends StatefulWidget { +class CreateBackupView extends ConsumerStatefulWidget { const CreateBackupView({super.key}); static const String routeName = "/createBackup"; @override - State createState() => _RestoreFromFileViewState(); + ConsumerState createState() => _RestoreFromFileViewState(); } -class _RestoreFromFileViewState extends State { +class _RestoreFromFileViewState extends ConsumerState { late final TextEditingController fileLocationController; late final TextEditingController passwordController; late final TextEditingController passwordRepeatController; @@ -72,6 +73,120 @@ class _RestoreFromFileViewState extends State { passwordRepeatController.text.isNotEmpty; } + Future _createBackup() async { + final String pathToSave = fileLocationController.text; + final String passphrase = passwordController.text; + final String repeatPassphrase = passwordRepeatController.text; + + if (validateFail(context, pathToSave, passphrase, repeatPassphrase)) return; + + if (mounted) { + Exception? ex; + final savedPath = await showLoading( + whileFuture: () async { + final DateTime now = DateTime.now(); + final String fileToSavePath = + "$pathToSave/stackbackup" + "_${now.year}" + "_${now.month}" + "_${now.day}" + "_${now.hour}" + "_${now.minute}" + "_${now.second}.swb"; + + final backup = await SWB.createStackWalletJSON( + secureStorage: ref.read(secureStoreProvider), + ); + + final encryptedDataString = await SWB + .encryptStackWalletWithPassphrase(passphrase, jsonEncode(backup)); + + await FS.writeStringToFile( + encryptedDataString, + pathToSave, + fileToSavePath.split("/").last, + ); + + return fileToSavePath; + }(), + context: context, + message: "Encrypting backup", + subMessage: "This shouldn't take long", + delay: const Duration(seconds: 1), + onException: (e) => ex = e, + rootNavigator: Util.isDesktop, + ); + + if (mounted) { + if (savedPath != null) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => !Util.isDesktop + ? StackOkDialog(title: "Backup saved to:", message: savedPath) + : DesktopDialog( + maxHeight: double.infinity, + maxWidth: 500, + child: Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 26), + Text( + "${AppConfig.prefix} backup saved to: \n", + style: STextStyles.desktopH3(context), + ), + Text( + savedPath, + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + const SizedBox(height: 40), + Row( + children: [ + Expanded( + child: PrimaryButton( + label: "Ok", + buttonHeight: ButtonHeight.l, + onPressed: Navigator.of( + context, + rootNavigator: true, + ).pop, + ), + ), + ], + ), + ], + ), + ), + ), + ); + passwordController.text = ""; + passwordRepeatController.text = ""; + if (mounted) { + setState(() {}); + } + } else { + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => StackOkDialog( + title: "Backup creation failed", + message: ex?.toString() ?? "Unexpected error", + ), + ); + } + } + } + } + @override void initState() { stackFileSystem = SWBFileSystem(); @@ -82,7 +197,7 @@ class _RestoreFromFileViewState extends State { passwordFocusNode = FocusNode(); passwordRepeatFocusNode = FocusNode(); - if (Platform.isAndroid || Platform.isIOS) { + if (Platform.isIOS) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { final dir = await stackFileSystem.prepareStorage(); if (mounted) { @@ -117,8 +232,9 @@ class _RestoreFromFileViewState extends State { builder: (child) { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () async { @@ -128,7 +244,7 @@ class _RestoreFromFileViewState extends State { const Duration(milliseconds: 75), ); } - if (mounted) { + if (context.mounted) { Navigator.of(context).pop(); } }, @@ -168,12 +284,12 @@ class _RestoreFromFileViewState extends State { padding: const EdgeInsets.only(bottom: 10), child: Text( "Choose file location", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context).extension()!.textDark3, - ), + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), ), ), child, @@ -183,7 +299,7 @@ class _RestoreFromFileViewState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (!Platform.isAndroid && !Platform.isIOS) + if (!Platform.isIOS) Consumer( builder: (context, ref, __) { return Container( @@ -191,31 +307,26 @@ class _RestoreFromFileViewState extends State { child: TextField( autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, - onTap: - Platform.isAndroid || Platform.isIOS - ? null - : () async { - try { - await stackFileSystem.prepareStorage(); - - if (mounted) { - await stackFileSystem.pickDir(context); - } + onTap: Platform.isIOS + ? null + : () async { + try { + await stackFileSystem.prepareStorage(); + if (mounted) { + final filePath = await stackFileSystem + .pickDir(); if (mounted) { setState(() { fileLocationController.text = - stackFileSystem.dirPath ?? ""; + filePath ?? ""; }); } - } catch (e, s) { - Logging.instance.e( - "", - error: e, - stackTrace: s, - ); } - }, + } catch (e, s) { + Logging.instance.e("", error: e, stackTrace: s); + } + }, controller: fileLocationController, style: STextStyles.field(context), decoration: InputDecoration( @@ -227,10 +338,9 @@ class _RestoreFromFileViewState extends State { const SizedBox(width: 16), SvgPicture.asset( Assets.svg.folder, - color: - Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of( + context, + ).extension()!.textDark3, width: 16, height: 16, ), @@ -256,19 +366,18 @@ class _RestoreFromFileViewState extends State { ); }, ), - if (!Platform.isAndroid && !Platform.isIOS) - SizedBox(height: !isDesktop ? 8 : 24), + if (!Platform.isIOS) SizedBox(height: !isDesktop ? 8 : 24), if (isDesktop) Padding( padding: const EdgeInsets.only(bottom: 10.0), child: Text( "Create a passphrase", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context).extension()!.textDark3, - ), + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), textAlign: TextAlign.left, ), ), @@ -284,41 +393,44 @@ class _RestoreFromFileViewState extends State { obscureText: hidePassword, enableSuggestions: false, autocorrect: false, - decoration: standardInputDecoration( - "Create passphrase", - passwordFocusNode, - context, - ).copyWith( - labelStyle: - isDesktop ? STextStyles.fieldLabel(context) : null, - suffixIcon: UnconstrainedBox( - child: Row( - children: [ - const SizedBox(width: 16), - GestureDetector( - key: const Key( - "createBackupPasswordFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: SvgPicture.asset( - hidePassword ? Assets.svg.eye : Assets.svg.eyeSlash, - color: - Theme.of( + decoration: + standardInputDecoration( + "Create passphrase", + passwordFocusNode, + context, + ).copyWith( + labelStyle: isDesktop + ? STextStyles.fieldLabel(context) + : null, + suffixIcon: UnconstrainedBox( + child: Row( + children: [ + const SizedBox(width: 16), + GestureDetector( + key: const Key( + "createBackupPasswordFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of( context, ).extension()!.textDark3, - width: 16, - height: 16, - ), + width: 16, + height: 16, + ), + ), + const SizedBox(width: 12), + ], ), - const SizedBox(width: 12), - ], + ), ), - ), - ), onChanged: (newValue) { if (newValue.isEmpty) { setState(() { @@ -365,13 +477,12 @@ class _RestoreFromFileViewState extends State { right: 12, top: passwordFeedback.isNotEmpty ? 4 : 0, ), - child: - passwordFeedback.isNotEmpty - ? Text( - passwordFeedback, - style: STextStyles.infoSmall(context), - ) - : null, + child: passwordFeedback.isNotEmpty + ? Text( + passwordFeedback, + style: STextStyles.infoSmall(context), + ) + : null, ), if (passwordFocusNode.hasFocus || passwordRepeatFocusNode.hasFocus || @@ -382,22 +493,20 @@ class _RestoreFromFileViewState extends State { key: const Key("createStackBackUpProgressBar"), width: MediaQuery.of(context).size.width - 32 - 24, height: 5, - fillColor: - passwordStrength < 0.51 - ? Theme.of( - context, - ).extension()!.accentColorRed - : passwordStrength < 1 - ? Theme.of( - context, - ).extension()!.accentColorYellow - : Theme.of( - context, - ).extension()!.accentColorGreen, - backgroundColor: - Theme.of( - context, - ).extension()!.buttonBackSecondary, + fillColor: passwordStrength < 0.51 + ? Theme.of( + context, + ).extension()!.accentColorRed + : passwordStrength < 1 + ? Theme.of( + context, + ).extension()!.accentColorYellow + : Theme.of( + context, + ).extension()!.accentColorGreen, + backgroundColor: Theme.of( + context, + ).extension()!.buttonBackSecondary, percent: passwordStrength < 0.25 ? 0.03 : passwordStrength, ), ), @@ -414,41 +523,44 @@ class _RestoreFromFileViewState extends State { obscureText: hidePassword, enableSuggestions: false, autocorrect: false, - decoration: standardInputDecoration( - "Confirm passphrase", - passwordRepeatFocusNode, - context, - ).copyWith( - labelStyle: - isDesktop ? STextStyles.fieldLabel(context) : null, - suffixIcon: UnconstrainedBox( - child: Row( - children: [ - const SizedBox(width: 16), - GestureDetector( - key: const Key( - "createBackupPasswordFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: SvgPicture.asset( - hidePassword ? Assets.svg.eye : Assets.svg.eyeSlash, - color: - Theme.of( + decoration: + standardInputDecoration( + "Confirm passphrase", + passwordRepeatFocusNode, + context, + ).copyWith( + labelStyle: isDesktop + ? STextStyles.fieldLabel(context) + : null, + suffixIcon: UnconstrainedBox( + child: Row( + children: [ + const SizedBox(width: 16), + GestureDetector( + key: const Key( + "createBackupPasswordFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of( context, ).extension()!.textDark3, - width: 16, - height: 16, - ), + width: 16, + height: 16, + ), + ), + const SizedBox(width: 12), + ], ), - const SizedBox(width: 12), - ], + ), ), - ), - ), onChanged: (newValue) { setState(() {}); // TODO: ? check if passwords match? @@ -458,399 +570,44 @@ class _RestoreFromFileViewState extends State { const SizedBox(height: 16), if (!isDesktop) const Spacer(), !isDesktop - ? Consumer( - builder: (context, ref, __) { - return TextButton( - style: - shouldEnableCreate - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle(context), - onPressed: - !shouldEnableCreate - ? null - : () async { - final String pathToSave = - fileLocationController.text; - final String passphrase = - passwordController.text; - final String repeatPassphrase = - passwordRepeatController.text; - - if (pathToSave.isEmpty) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Directory not chosen", - context: context, - ), - ); - return; - } - if (!(await Directory(pathToSave).exists())) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Directory does not exist", - context: context, - ), - ); - return; - } - if (passphrase.isEmpty) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "A passphrase is required", - context: context, - ), - ); - return; - } - if (passphrase != repeatPassphrase) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Passphrase does not match", - context: context, - ), - ); - return; - } - - unawaited( - showDialog( - context: context, - barrierDismissible: false, - builder: - (_) => const StackDialog( - title: "Encrypting backup", - message: "This shouldn't take long", - ), - ), - ); - // make sure the dialog is able to be displayed for at least 1 second - await Future.delayed( - const Duration(seconds: 1), - ); - - final DateTime now = DateTime.now(); - final String fileToSave = - "$pathToSave/stackbackup_${now.year}_${now.month}_${now.day}_${now.hour}_${now.minute}_${now.second}.swb"; - - final backup = await SWB.createStackWalletJSON( - secureStorage: ref.read(secureStoreProvider), - ); - - final bool result = await SWB - .encryptStackWalletWithPassphrase( - fileToSave, - passphrase, - jsonEncode(backup), - ); - - if (mounted) { - // pop encryption progress dialog - if (!isDesktop) Navigator.of(context).pop(); - - if (result) { - await showDialog( - context: context, - barrierDismissible: false, - builder: - (_) => - Platform.isAndroid - ? StackOkDialog( - title: "Backup saved to:", - message: fileToSave, - ) - : const StackOkDialog( - title: - "Backup creation succeeded", - ), - ); - passwordController.text = ""; - passwordRepeatController.text = ""; - setState(() {}); - } else { - await showDialog( - context: context, - barrierDismissible: false, - builder: - (_) => const StackOkDialog( - title: "Backup creation failed", - ), - ); - } - } - }, - child: Text( - "Create backup", - style: STextStyles.button(context), - ), - ); - }, - ) - : Row( - children: [ - Consumer( - builder: (context, ref, __) { - return PrimaryButton( - width: 183, - buttonHeight: ButtonHeight.m, - label: "Create backup", - enabled: shouldEnableCreate, - onPressed: - !shouldEnableCreate - ? null - : () async { - final String pathToSave = - fileLocationController.text; - final String passphrase = - passwordController.text; - final String repeatPassphrase = - passwordRepeatController.text; - - if (pathToSave.isEmpty) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Directory not chosen", - context: context, - ), - ); - return; - } - if (!(await Directory( - pathToSave, - ).exists())) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Directory does not exist", - context: context, - ), - ); - return; - } - if (passphrase.isEmpty) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "A passphrase is required", - context: context, - ), - ); - return; - } - if (passphrase != repeatPassphrase) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Passphrase does not match", - context: context, - ), - ); - return; - } - - unawaited( - showDialog( - context: context, - barrierDismissible: false, - builder: (_) { - if (Util.isDesktop) { - return DesktopDialog( - maxHeight: double.infinity, - maxWidth: 450, - child: Padding( - padding: const EdgeInsets.all( - 32, - ), - child: Column( - mainAxisSize: - MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - "Encrypting initial backup", - style: - STextStyles.desktopH3( - context, - ), - ), - const SizedBox(height: 40), - Text( - "This shouldn't take long", - style: - STextStyles.desktopTextExtraExtraSmall( - context, - ), - ), - ], - ), - ), - ); - } else { - return const StackDialog( - title: - "Encrypting initial backup", - message: - "This shouldn't take long", - ); - } - }, - ), - ); - - await Future.delayed( - const Duration(seconds: 1), - ); - - // make sure the dialog is able to be displayed for at least 1 second - final fut = Future.delayed( - const Duration(seconds: 1), - ); - - final DateTime now = DateTime.now(); - final String fileToSave = - "$pathToSave/stackbackup_${now.year}_${now.month}_${now.day}_${now.hour}_${now.minute}_${now.second}.swb"; - - final backup = await SWB - .createStackWalletJSON( - secureStorage: ref.read( - secureStoreProvider, - ), - ); - - final bool result = await SWB - .encryptStackWalletWithPassphrase( - fileToSave, - passphrase, - jsonEncode(backup), - ); - - await Future.wait([fut]); - - if (mounted) { - // pop encryption progress dialog - if (!isDesktop) - Navigator.of(context).pop(); - - if (result) { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) { - if (Platform.isAndroid) { - return StackOkDialog( - title: "Backup saved to:", - message: fileToSave, - ); - } else if (isDesktop) { - return DesktopDialog( - maxHeight: double.infinity, - maxWidth: 500, - child: Padding( - padding: - const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, - ), - child: Column( - mainAxisSize: - MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment - .start, - children: [ - const SizedBox( - height: 26, - ), - Text( - "${AppConfig.prefix} backup saved to: \n", - style: - STextStyles.desktopH3( - context, - ), - ), - Text( - fileToSave, - style: - STextStyles.desktopTextExtraExtraSmall( - context, - ), - ), - const SizedBox( - height: 40, - ), - Row( - children: [ - // const Spacer(), - Expanded( - child: PrimaryButton( - label: "Ok", - buttonHeight: - ButtonHeight - .l, - onPressed: () { - int count = 0; - Navigator.of( - context, - ).popUntil( - (_) => - count++ >= - 2, - ); - }, - ), - ), - ], - ), - ], - ), - ), - ); - } else { - return const StackOkDialog( - title: - "Backup creation succeeded", - ); - } - }, - ); - passwordController.text = ""; - passwordRepeatController.text = ""; - setState(() {}); - } else { - await showDialog( - context: context, - barrierDismissible: false, - builder: - (_) => const StackOkDialog( - title: "Backup creation failed", - ), - ); - } - } - }, - ); - }, + ? TextButton( + style: shouldEnableCreate + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context), + onPressed: !shouldEnableCreate ? null : _createBackup, + child: Text( + "Create backup", + style: STextStyles.button(context), ), - const SizedBox(width: 16), - SecondaryButton( - width: 183, - buttonHeight: ButtonHeight.m, - label: "Cancel", - onPressed: () {}, - ), - ], - ), + ) + : Row( + children: [ + Consumer( + builder: (context, ref, __) { + return PrimaryButton( + width: 183, + buttonHeight: ButtonHeight.m, + label: "Create backup", + enabled: shouldEnableCreate, + onPressed: !shouldEnableCreate + ? null + : _createBackup, + ); + }, + ), + const SizedBox(width: 16), + SecondaryButton( + width: 183, + buttonHeight: ButtonHeight.m, + label: "Cancel", + onPressed: () {}, + ), + ], + ), ], ), ), diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/edit_auto_backup_view.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/edit_auto_backup_view.dart index 5ad7eab467..077ff21c51 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/edit_auto_backup_view.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/edit_auto_backup_view.dart @@ -8,7 +8,6 @@ * */ -import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -21,7 +20,6 @@ import 'package:stack_wallet_backup/stack_wallet_backup.dart'; import 'package:zxcvbn/zxcvbn.dart'; import '../../../../app_config.dart'; -import '../../../../notifications/show_flush_bar.dart'; import '../../../../providers/global/prefs_provider.dart'; import '../../../../providers/global/secure_store_provider.dart'; import '../../../../themes/stack_colors.dart'; @@ -30,7 +28,9 @@ import '../../../../utilities/constants.dart'; import '../../../../utilities/enums/backup_frequency_type.dart'; import '../../../../utilities/flutter_secure_storage_interface.dart'; import '../../../../utilities/format.dart'; +import '../../../../utilities/fs.dart'; import '../../../../utilities/logger.dart'; +import '../../../../utilities/show_loading.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; import '../../../../widgets/background.dart'; @@ -95,158 +95,96 @@ class _EditAutoBackupViewState extends ConsumerState { final String passphrase = passwordController.text; final String repeatPassphrase = passwordRepeatController.text; - if (pathToSave.isEmpty) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Directory not chosen", - context: context, - ), - ); - return; - } - if (!(await Directory(pathToSave).exists())) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Directory does not exist", - context: context, - ), - ); - return; - } - if (passphrase.isEmpty) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "A passphrase is required", - context: context, - ), - ); - return; - } - if (passphrase != repeatPassphrase) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Passphrase does not match", - context: context, - ), - ); - return; - } + if (validateFail(context, pathToSave, passphrase, repeatPassphrase)) return; - unawaited( - showDialog( - context: context, - barrierDismissible: false, - builder: - (_) => const StackDialog( - title: "Updating Auto Backup", - message: "This shouldn't take long", - ), - ), - ); - // make sure the dialog is able to be displayed for at least 1 second - final fut = Future.delayed(const Duration(seconds: 1)); - - String adkString; - int adkVersion; - try { - final adk = await compute(generateAdk, passphrase); - adkString = Format.uint8listToString(adk.item2); - adkVersion = adk.item1; - } on Exception catch (e, s) { - final String err = getErrorMessageFromSWBException(e); - Logging.instance.e("$err\n$s", error: e, stackTrace: s); - // pop encryption progress dialog - Navigator.of(context).pop(); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: err, - context: context, - ), - ); - return; - } catch (e, s) { - Logging.instance.e("$e\n$s", error: e, stackTrace: s); - // pop encryption progress dialog - Navigator.of(context).pop(); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "$e", - context: context, - ), - ); - return; - } + if (mounted) { + final now = DateTime.now(); + Exception? ex; + final savedPath = await showLoading( + whileFuture: () async { + String adkString; + int adkVersion; + try { + final adk = await compute(generateAdk, passphrase); + adkString = Format.uint8listToString(adk.item2); + adkVersion = adk.item1; + } on Exception catch (e, s) { + final String err = getErrorMessageFromSWBException(e); + Logging.instance.e(err, error: e, stackTrace: s); + rethrow; + } - await secureStore.write(key: "auto_adk_string", value: adkString); - await secureStore.write( - key: "auto_adk_version_string", - value: adkVersion.toString(), - ); + await secureStore.write(key: "auto_adk_string", value: adkString); + await secureStore.write( + key: "auto_adk_version_string", + value: adkVersion.toString(), + ); - final DateTime now = DateTime.now(); - final String fileToSave = createAutoBackupFilename(pathToSave, now); + final fileToSavePath = createAutoBackupFilename(pathToSave, now); - final backup = await SWB.createStackWalletJSON( - secureStorage: ref.read(secureStoreProvider), - ); + final backup = await SWB.createStackWalletJSON( + secureStorage: secureStore, + ); - final bool result = await SWB.encryptStackWalletWithADK( - fileToSave, - adkString, - jsonEncode(backup), - adkVersion, - ); + final encryptedDataString = await SWB.encryptStackWalletWithADK( + adkString, + jsonEncode(backup), + adkVersion, + ); - // this future should already be complete unless there was an error encrypting - await Future.wait([fut]); + await FS.writeStringToFile( + encryptedDataString, + pathToSave, + fileToSavePath.split("/").last, + ); - if (mounted) { - // pop encryption progress dialog - Navigator.of(context).pop(); - - if (result) { - ref.read(prefsChangeNotifierProvider).autoBackupLocation = pathToSave; - ref.read(prefsChangeNotifierProvider).lastAutoBackup = now; - - ref.read(prefsChangeNotifierProvider).isAutoBackupEnabled = true; - - await showDialog( - context: context, - barrierDismissible: false, - builder: - (_) => - Platform.isAndroid - ? StackOkDialog( - title: "${AppConfig.prefix} Auto Backup saved to:", - message: fileToSave, - ) - : const StackOkDialog( - title: "${AppConfig.prefix} Auto Backup saved", - ), - ); - if (mounted) { - passwordController.text = ""; - passwordRepeatController.text = ""; + return fileToSavePath; + }(), + context: context, + message: "Updating Auto Backup", + subMessage: "This shouldn't take long", + delay: const Duration(seconds: 1), + onException: (e) => ex = e, + ); + + if (mounted) { + // pop encryption progress dialog + Navigator.of(context).pop(); + + if (savedPath != null) { + ref.read(prefsChangeNotifierProvider).autoBackupLocation = pathToSave; + ref.read(prefsChangeNotifierProvider).lastAutoBackup = now; + + ref.read(prefsChangeNotifierProvider).isAutoBackupEnabled = true; + + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => StackOkDialog( + title: "${AppConfig.prefix} Auto Backup saved to:", + message: savedPath, + ), + ); + if (mounted) { + passwordController.text = ""; + passwordRepeatController.text = ""; - if (!Util.isDesktop) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(AutoBackupView.routeName)); + if (!Util.isDesktop) { + Navigator.of( + context, + ).popUntil(ModalRoute.withName(AutoBackupView.routeName)); + } } + } else { + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => StackOkDialog( + title: "Failed to update Auto Backup", + message: ex?.toString(), + ), + ); } - } else { - await showDialog( - context: context, - barrierDismissible: false, - builder: - (_) => const StackOkDialog(title: "Failed to update Auto Backup"), - ); } } } @@ -262,13 +200,14 @@ class _EditAutoBackupViewState extends ConsumerState { fileLocationController.text = ref.read(prefsChangeNotifierProvider).autoBackupLocation ?? ""; - _currentDropDownValue = - ref.read(prefsChangeNotifierProvider).backupFrequencyType; + _currentDropDownValue = ref + .read(prefsChangeNotifierProvider) + .backupFrequencyType; passwordFocusNode = FocusNode(); passwordRepeatFocusNode = FocusNode(); - if (Platform.isAndroid || Platform.isIOS) { + if (Platform.isIOS) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { final dir = await stackFileSystem.prepareStorage(); if (mounted) { @@ -302,44 +241,45 @@ class _EditAutoBackupViewState extends ConsumerState { return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton( - onPressed: () { - Navigator.of(context).pop(); - }, - ), - title: Text( - "Edit Auto Backup", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - child: IntrinsicHeight(child: child), - ), - ); - }, - ), - ), + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Edit Auto Backup", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight(child: child), + ), + ); + }, ), ), ), + ), + ), child: Column( - crossAxisAlignment: - isDesktop ? CrossAxisAlignment.start : CrossAxisAlignment.stretch, + crossAxisAlignment: isDesktop + ? CrossAxisAlignment.start + : CrossAxisAlignment.stretch, children: [ if (!isDesktop) Text("Create your backup", style: STextStyles.smallMed12(context)), @@ -352,31 +292,28 @@ class _EditAutoBackupViewState extends ConsumerState { textAlign: TextAlign.left, ), const SizedBox(height: 10), - if (!Platform.isAndroid && !Platform.isIOS) + if (!Platform.isIOS) TextField( autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, - onTap: - Platform.isAndroid || Platform.isIOS - ? null - : () async { - try { - await stackFileSystem.prepareStorage(); - - if (mounted) { - await stackFileSystem.pickDir(context); - } + onTap: Platform.isIOS + ? null + : () async { + try { + await stackFileSystem.prepareStorage(); + if (mounted) { + final filePath = await stackFileSystem.pickDir(); if (mounted) { setState(() { - fileLocationController.text = - stackFileSystem.dirPath ?? ""; + fileLocationController.text = filePath ?? ""; }); } - } catch (e, s) { - Logging.instance.e("$e\n$s", error: e, stackTrace: s); } - }, + } catch (e, s) { + Logging.instance.e("$e\n$s", error: e, stackTrace: s); + } + }, controller: fileLocationController, style: STextStyles.field(context), decoration: InputDecoration( @@ -388,10 +325,9 @@ class _EditAutoBackupViewState extends ConsumerState { const SizedBox(width: 16), SvgPicture.asset( Assets.svg.folder, - color: - Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of( + context, + ).extension()!.textDark3, width: 16, height: 16, ), @@ -419,8 +355,7 @@ class _EditAutoBackupViewState extends ConsumerState { ), textAlign: TextAlign.left, ), - if (!Platform.isAndroid && !Platform.isIOS) - const SizedBox(height: 10), + if (!Platform.isIOS) const SizedBox(height: 10), ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -433,40 +368,44 @@ class _EditAutoBackupViewState extends ConsumerState { obscureText: hidePassword, enableSuggestions: false, autocorrect: false, - decoration: standardInputDecoration( - "Create passphrase", - passwordFocusNode, - context, - ).copyWith( - labelStyle: isDesktop ? STextStyles.fieldLabel(context) : null, - suffixIcon: UnconstrainedBox( - child: Row( - children: [ - const SizedBox(width: 16), - GestureDetector( - key: const Key( - "createBackupPasswordFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: SvgPicture.asset( - hidePassword ? Assets.svg.eye : Assets.svg.eyeSlash, - color: - Theme.of( + decoration: + standardInputDecoration( + "Create passphrase", + passwordFocusNode, + context, + ).copyWith( + labelStyle: isDesktop + ? STextStyles.fieldLabel(context) + : null, + suffixIcon: UnconstrainedBox( + child: Row( + children: [ + const SizedBox(width: 16), + GestureDetector( + key: const Key( + "createBackupPasswordFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of( context, ).extension()!.textDark3, - width: 16, - height: 16, - ), + width: 16, + height: 16, + ), + ), + const SizedBox(width: 12), + ], ), - const SizedBox(width: 12), - ], + ), ), - ), - ), onChanged: (newValue) { if (newValue.isEmpty) { setState(() { @@ -513,13 +452,12 @@ class _EditAutoBackupViewState extends ConsumerState { right: 12, top: passwordFeedback.isNotEmpty ? 4 : 0, ), - child: - passwordFeedback.isNotEmpty - ? Text( - passwordFeedback, - style: STextStyles.infoSmall(context), - ) - : null, + child: passwordFeedback.isNotEmpty + ? Text( + passwordFeedback, + style: STextStyles.infoSmall(context), + ) + : null, ), if (passwordFocusNode.hasFocus || passwordRepeatFocusNode.hasFocus || @@ -528,27 +466,22 @@ class _EditAutoBackupViewState extends ConsumerState { padding: const EdgeInsets.only(left: 12, right: 12, top: 10), child: ProgressBar( key: const Key("createStackBackUpProgressBar"), - width: - isDesktop - ? 492 - : MediaQuery.of(context).size.width - 32 - 24, + width: isDesktop + ? 492 + : MediaQuery.of(context).size.width - 32 - 24, height: 5, - fillColor: - passwordStrength < 0.51 - ? Theme.of( - context, - ).extension()!.accentColorRed - : passwordStrength < 1 - ? Theme.of( - context, - ).extension()!.accentColorYellow - : Theme.of( - context, - ).extension()!.accentColorGreen, - backgroundColor: - Theme.of( - context, - ).extension()!.buttonBackSecondary, + fillColor: passwordStrength < 0.51 + ? Theme.of(context).extension()!.accentColorRed + : passwordStrength < 1 + ? Theme.of( + context, + ).extension()!.accentColorYellow + : Theme.of( + context, + ).extension()!.accentColorGreen, + backgroundColor: Theme.of( + context, + ).extension()!.buttonBackSecondary, percent: passwordStrength < 0.25 ? 0.03 : passwordStrength, ), ), @@ -565,40 +498,44 @@ class _EditAutoBackupViewState extends ConsumerState { obscureText: hidePassword, enableSuggestions: false, autocorrect: false, - decoration: standardInputDecoration( - "Confirm passphrase", - passwordRepeatFocusNode, - context, - ).copyWith( - labelStyle: isDesktop ? STextStyles.fieldLabel(context) : null, - suffixIcon: UnconstrainedBox( - child: Row( - children: [ - const SizedBox(width: 16), - GestureDetector( - key: const Key( - "createBackupPasswordFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: SvgPicture.asset( - hidePassword ? Assets.svg.eye : Assets.svg.eyeSlash, - color: - Theme.of( + decoration: + standardInputDecoration( + "Confirm passphrase", + passwordRepeatFocusNode, + context, + ).copyWith( + labelStyle: isDesktop + ? STextStyles.fieldLabel(context) + : null, + suffixIcon: UnconstrainedBox( + child: Row( + children: [ + const SizedBox(width: 16), + GestureDetector( + key: const Key( + "createBackupPasswordFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of( context, ).extension()!.textDark3, - width: 16, - height: 16, - ), + width: 16, + height: 16, + ), + ), + const SizedBox(width: 12), + ], ), - const SizedBox(width: 12), - ], + ), ), - ), - ), onChanged: (newValue) { setState(() {}); // TODO: ? check if passwords match? @@ -608,13 +545,13 @@ class _EditAutoBackupViewState extends ConsumerState { SizedBox(height: isDesktop ? 24 : 32), Text( "Auto Backup frequency", - style: - isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of(context).extension()!.textDark3, - ) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ) + : STextStyles.smallMed12(context), ), const SizedBox(height: 10), if (isDesktop) @@ -653,8 +590,9 @@ class _EditAutoBackupViewState extends ConsumerState { .backupFrequencyType != value) { ref - .read(prefsChangeNotifierProvider) - .backupFrequencyType = value; + .read(prefsChangeNotifierProvider) + .backupFrequencyType = + value; } setState(() { _currentDropDownValue = value; @@ -666,18 +604,18 @@ class _EditAutoBackupViewState extends ConsumerState { Assets.svg.chevronDown, width: 10, height: 5, - color: - Theme.of(context).extension()!.textDark3, + color: Theme.of( + context, + ).extension()!.textDark3, ), ), dropdownStyleData: DropdownStyleData( offset: const Offset(0, -10), elevation: 0, decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -699,8 +637,9 @@ class _EditAutoBackupViewState extends ConsumerState { ), Positioned.fill( child: RawMaterialButton( - splashColor: - Theme.of(context).extension()!.highlight, + splashColor: Theme.of( + context, + ).extension()!.highlight, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -737,10 +676,9 @@ class _EditAutoBackupViewState extends ConsumerState { padding: const EdgeInsets.only(right: 4.0), child: SvgPicture.asset( Assets.svg.chevronDown, - color: - Theme.of( - context, - ).extension()!.textSubtitle2, + color: Theme.of( + context, + ).extension()!.textSubtitle2, width: 12, height: 6, ), @@ -777,14 +715,13 @@ class _EditAutoBackupViewState extends ConsumerState { ), if (!isDesktop) TextButton( - style: - shouldEnableCreate - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle(context), + style: shouldEnableCreate + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context), onPressed: !shouldEnableCreate ? null : onSavePressed, child: Text("Save", style: STextStyles.button(context)), ), diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart index 355fc6643a..cb4ec42a6a 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart @@ -11,8 +11,9 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; +import 'package:drift/drift.dart'; +import 'package:flutter/material.dart'; import 'package:isar_community/isar.dart'; import 'package:stack_wallet_backup/stack_wallet_backup.dart'; import 'package:tuple/tuple.dart'; @@ -20,6 +21,7 @@ import 'package:uuid/uuid.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import '../../../../../app_config.dart'; +import '../../../../../db/drift/shared_db/shared_database.dart'; import '../../../../../db/hive/db.dart'; import '../../../../../db/isar/main_db.dart'; import '../../../../../models/exchange/change_now/exchange_transaction.dart'; @@ -31,8 +33,11 @@ import '../../../../../models/node_model.dart'; import '../../../../../models/stack_restoring_ui_state.dart'; import '../../../../../models/trade_wallet_lookup.dart'; import '../../../../../models/wallet_restore_state.dart'; +import '../../../../../notifications/show_flush_bar.dart'; import '../../../../../services/address_book_service.dart'; +import '../../../../../services/cakepay/cakepay_service.dart'; import '../../../../../services/node_service.dart'; +import '../../../../../services/shopinbit/shopinbit_service.dart'; import '../../../../../services/trade_notes_service.dart'; import '../../../../../services/trade_sent_from_stack_service.dart'; import '../../../../../services/trade_service.dart'; @@ -51,11 +56,8 @@ 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/impl/monero_wallet.dart'; -import '../../../../../wallets/wallet/impl/wownero_wallet.dart'; import '../../../../../wallets/wallet/impl/xelis_wallet.dart'; -import '../../../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; -import '../../../../../wallets/wallet/intermediate/lib_salvium_wallet.dart'; +import '../../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../../wallets/wallet/wallet.dart'; import '../../../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; import '../../../../../wallets/wallet/wallet_mixin_interfaces/private_key_interface.dart'; @@ -92,6 +94,36 @@ String createAutoBackupFilename(String dirPath, DateTime date) { "_${date.minute}_${date.second}.swb"; } +bool validateFail( + BuildContext context, + String pathToSave, + String passphrase, + String repeatPassphrase, +) { + for (final e in [ + [pathToSave.isEmpty, "Directory not chosen"], + if (!pathToSave.startsWith("content://")) + [!(Directory(pathToSave).existsSync()), "Directory does not exist"], + [passphrase.isEmpty, "A passphrase is required"], + [passphrase != repeatPassphrase, "Passphrase does not match"], + ]) { + if (e[0] as bool) { + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: e[1] as String, + context: context, + ), + ); + } + return true; + } + } + + return false; +} + abstract class SWB { static Completer? _cancelCompleter; @@ -116,10 +148,11 @@ abstract class SWB { static bool _checkShouldCancel( PreRestoreState? revertToState, SecureStorageInterface secureStorageInterface, + ShopInBitService shopinbitService, ) { if (_shouldCancelRestore) { if (revertToState != null) { - _revert(revertToState, secureStorageInterface); + _revert(revertToState, secureStorageInterface, shopinbitService); } else { _cancelCompleter!.complete(); _shouldCancelRestore = false; @@ -131,88 +164,42 @@ abstract class SWB { } } - static Future encryptStackWalletWithPassphrase( - String fileToSave, + static Future encryptStackWalletWithPassphrase( String passphrase, String plaintext, ) async { - try { - final File backupFile = File(fileToSave); - if (!backupFile.existsSync()) { - final String jsonBackup = plaintext; - final Uint8List content = Uint8List.fromList(utf8.encode(jsonBackup)); - final Uint8List encryptedContent = await encryptWithPassphrase( - passphrase, - content, - ); - backupFile.writeAsStringSync( - Format.uint8listToString(encryptedContent), - ); - } - Logging.instance.d(backupFile.absolute); - return true; - } catch (e, s) { - Logging.instance.e("$e\n$s", error: e, stackTrace: s); - return false; - } + final String jsonBackup = plaintext; + final Uint8List content = Uint8List.fromList(utf8.encode(jsonBackup)); + final Uint8List encryptedContent = await encryptWithPassphrase( + passphrase, + content, + ); + return Format.uint8listToString(encryptedContent); } - static Future encryptStackWalletWithADK( - String fileToSave, + static Future encryptStackWalletWithADK( String adk, String plaintext, int adkVersion, ) async { - try { - final File backupFile = File(fileToSave); - if (!backupFile.existsSync()) { - final String jsonBackup = plaintext; - final Uint8List content = Uint8List.fromList(utf8.encode(jsonBackup)); - final Uint8List encryptedContent = await encryptWithAdk( - Format.stringToUint8List(adk), - content, - version: adkVersion, - ); - backupFile.writeAsStringSync( - Format.uint8listToString(encryptedContent), - ); - } - Logging.instance.d(backupFile.absolute); - return true; - } catch (e, s) { - Logging.instance.e("$e\n$s", error: e, stackTrace: s); - return false; - } - } - - static Future decryptStackWalletWithPassphrase( - Tuple2 data, - ) async { - try { - final String fileToRestore = data.item1; - final String passphrase = data.item2; - final File backupFile = File(fileToRestore); - final String encryptedText = await backupFile.readAsString(); - return await decryptStackWalletStringWithPassphrase( - Tuple2(encryptedText, passphrase), - ); - } catch (e, s) { - Logging.instance.e("$e\n$s", error: e, stackTrace: s); - return null; - } + final String jsonBackup = plaintext; + final Uint8List content = Uint8List.fromList(utf8.encode(jsonBackup)); + final Uint8List encryptedContent = await encryptWithAdk( + Format.stringToUint8List(adk), + content, + version: adkVersion, + ); + return Format.uint8listToString(encryptedContent); } static Future decryptStackWalletStringWithPassphrase( - Tuple2 data, + ({String passphrase, String encryptedText}) data, ) async { try { - final encryptedText = data.item1; - final passphrase = data.item2; - - final encryptedBytes = Format.stringToUint8List(encryptedText); + final encryptedBytes = Format.stringToUint8List(data.encryptedText); final decryptedContent = await decryptWithPassphrase( - passphrase, + data.passphrase, encryptedBytes, ); @@ -253,6 +240,23 @@ abstract class SWB { Logging.instance.e("", error: e, stackTrace: s); } + Logging.instance.i("SWB backing up cakepay orders"); + final cakepayOrderIds = await CakePayService.instance.getOrderIds(); + backupJson["cakepayOrderIds"] = cakepayOrderIds; + + Logging.instance.i("SWB backing up shopin bit info"); + final sharedDB = SharedDrift.get(); + final shopinBitCustomerKeys = + await (sharedDB.select(sharedDB.shopInBitSettings) + ..orderBy([(t) => OrderingTerm.desc(t.lastUsedAt)])) + .map((row) => row.customerKey) + .get(); + + backupJson["shopinBit"] = { + if (shopinBitCustomerKeys.isNotEmpty) + "shopinBitCustomerKeys": shopinBitCustomerKeys, + }; + Logging.instance.d("SWB backing up prefs"); final Map prefs = {}; @@ -429,6 +433,7 @@ abstract class SWB { mnemonicPassphrase: mnemonicPassphrase, ); Wallet? wallet; + bool didExit = false; try { String? serializedKeys; String? multisigConfig; @@ -473,25 +478,21 @@ abstract class SWB { viewOnlyData: viewOnlyData, ); - switch (wallet.runtimeType) { - case const (EpiccashWallet): - await (wallet as EpiccashWallet).init(isRestore: true); - break; - - case const (MimblewimblecoinWallet): - await (wallet as MimblewimblecoinWallet).init(isRestore: true); + switch (wallet) { + case EpiccashWallet(): + await wallet.init(isRestore: true); break; - case const (MoneroWallet): - await (wallet as MoneroWallet).init(isRestore: true); + case MimblewimblecoinWallet(): + await wallet.init(isRestore: true); break; - case const (WowneroWallet): - await (wallet as WowneroWallet).init(isRestore: true); + case CryptonoteWallet(): + await wallet.init(isRestore: true); break; - case const (XelisWallet): - await (wallet as XelisWallet).init(isRestore: true); + case XelisWallet(): + await wallet.init(isRestore: true); break; default: @@ -501,8 +502,7 @@ abstract class SWB { int restoreHeight = walletbackup['restoreHeight'] as int? ?? 0; if (restoreHeight <= 0) { if (wallet is EpiccashWallet || - wallet is LibMoneroWallet || - wallet is LibSalviumWallet || + wallet is CryptonoteWallet || wallet is MimblewimblecoinWallet) { restoreHeight = 0; } else { @@ -572,11 +572,14 @@ abstract class SWB { await restoringFuture; + final currentAddress = await wallet.getCurrentReceivingAddress(); + + await wallet.exit(); + didExit = true; + Logging.instance.i( "SWB restored: ${info.walletId} ${info.name} ${info.coin.prettyName}", ); - - final currentAddress = await wallet.getCurrentReceivingAddress(); uiState?.update( walletId: info.walletId, restoringStatus: StackRestoringStatus.success, @@ -587,7 +590,11 @@ abstract class SWB { mnemonicPassphrase: mnemonicPassphrase, ); } catch (e, s) { - Logging.instance.i("", error: e, stackTrace: s); + Logging.instance.e( + "${wallet?.runtimeType} _asyncRestore failed", + error: e, + stackTrace: s, + ); uiState?.update( walletId: info.walletId, restoringStatus: StackRestoringStatus.failed, @@ -596,7 +603,9 @@ abstract class SWB { ); return false; } finally { - await wallet?.exit(); + if (!didExit) { + await wallet?.exit(); + } } return true; } @@ -606,6 +615,7 @@ abstract class SWB { StackRestoringUIState? uiState, Map oldToNewWalletIdMap, SecureStorageInterface secureStorageInterface, + ShopInBitService shopinbitService, ) async { final Map prefs = validJSON["prefs"] as Map; @@ -621,6 +631,9 @@ abstract class SWB { uiState?.preferences = StackRestoringStatus.restoring; + Logging.instance.d("SWB restoring cakepay order ids and shop in bit info"); + await _restoreCakepayAndShopinBitInfo(validJSON, shopinbitService); + Logging.instance.d("SWB restoring prefs"); await _restorePrefs(prefs); @@ -685,6 +698,7 @@ abstract class SWB { String jsonBackup, StackRestoringUIState? uiState, SecureStorageInterface secureStorageInterface, + ShopInBitService shopinbitService, ) async { if (!Platform.isLinux) await WakelockPlus.enable(); @@ -724,7 +738,7 @@ abstract class SWB { // basic cancel check here // no reverting required yet as nothing has been written to store - if (_checkShouldCancel(null, secureStorageInterface)) { + if (_checkShouldCancel(null, secureStorageInterface, shopinbitService)) { return false; } @@ -733,10 +747,15 @@ abstract class SWB { uiState, oldToNewWalletIdMap, secureStorageInterface, + shopinbitService, ); // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } @@ -753,7 +772,11 @@ abstract class SWB { for (final walletbackup in wallets) { // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } @@ -809,13 +832,21 @@ abstract class SWB { // final failovers = nodeService.failoverNodesFor(coin: coin); // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } managers.add(Tuple2(walletbackup, info)); // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } @@ -828,7 +859,11 @@ abstract class SWB { } // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } @@ -839,7 +874,11 @@ abstract class SWB { // start restoring wallets for (final tuple in managers) { // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } final bools = await _asyncRestore( @@ -853,13 +892,21 @@ abstract class SWB { } // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } for (final Future status in restoreStatuses) { // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } await status; @@ -867,7 +914,11 @@ abstract class SWB { if (!Platform.isLinux) await WakelockPlus.disable(); // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } @@ -885,6 +936,7 @@ abstract class SWB { static Future _revert( PreRestoreState revertToState, SecureStorageInterface secureStorageInterface, + ShopInBitService shopinbitService, ) async { final Map prefs = revertToState.validJSON["prefs"] as Map; @@ -898,6 +950,12 @@ abstract class SWB { final Map? tradeNotes = revertToState.validJSON["tradeNotes"] as Map?; + // cakepay and shopinbit + await _restoreCakepayAndShopinBitInfo( + revertToState.validJSON, + shopinbitService, + ); + // prefs await _restorePrefs(prefs); @@ -1097,6 +1155,28 @@ abstract class SWB { Logging.instance.d("Revert SWB complete"); } + static Future _restoreCakepayAndShopinBitInfo( + Map backupJson, + ShopInBitService shopinbitService, + ) async { + final cakepayOrderIds = (backupJson["cakepayOrderIds"] as List? ?? []) + .cast(); + for (final orderId in cakepayOrderIds) { + await CakePayService.instance.addOrderId(orderId); + } + + final json = backupJson["shopinBit"] as Map? ?? {}; + + if (json.isEmpty) return; + + final shopinBitCustomerKeys = json["shopinBitCustomerKeys"] as List?; + if (shopinBitCustomerKeys != null && shopinBitCustomerKeys.isNotEmpty) { + for (final key in shopinBitCustomerKeys.cast()) { + await shopinbitService.recoverCustomerKey(key); + } + } + } + static Future _restorePrefs(Map prefs) async { final _prefs = Prefs.instance; await _prefs.init(); @@ -1247,7 +1327,8 @@ abstract class SWB { TradeWalletLookup lookup = TradeWalletLookup.fromJson(json); // update walletIds final List walletIds = lookup.walletIds - .map((e) => oldToNewWalletIdMap[e]!) + // fallback to e as that wallet may have been deleted in the past + .map((e) => oldToNewWalletIdMap[e] ?? e) .toList(); lookup = lookup.copyWith(walletIds: walletIds); diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/swb_file_system.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/swb_file_system.dart index 9954cb0b7f..c02f91d45a 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/swb_file_system.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/swb_file_system.dart @@ -11,94 +11,63 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import '../../../../../app_config.dart'; -import '../../../../../utilities/stack_file_system.dart'; -import '../../../../../utilities/util.dart'; +import '../../../../../utilities/fs.dart'; class SWBFileSystem { - Directory? rootPath; - Directory? startPath; - - String? filePath; - String? dirPath; - - final bool isDesktop = Util.isDesktop; + Directory? _startPath; Future prepareStorage() async { - if (Platform.isAndroid) { - rootPath = await StackFileSystem.wtfAndroidDocumentsPath(); - } else { - rootPath = await getApplicationDocumentsDirectory(); - } - //todo: check if print needed - // debugPrint(rootPath!.absolute.toString()); + if (_startPath != null) _startPath; + + final _rootPath = await getApplicationDocumentsDirectory(); late Directory sampleFolder; const dirName = "${AppConfig.prefix}_backup"; if (Platform.isIOS) { - sampleFolder = Directory(rootPath!.path); + sampleFolder = Directory(_rootPath.path); } else if (Platform.isAndroid || Platform.isLinux || Platform.isWindows || Platform.isMacOS) { - sampleFolder = Directory(path.join(rootPath!.path, dirName)); + sampleFolder = Directory(path.join(_rootPath.path, dirName)); } - try { - if (!sampleFolder.existsSync()) { - sampleFolder.createSync(recursive: true); - } - } catch (e, s) { - // todo: come back to this - debugPrint("$e $s"); + if (!sampleFolder.existsSync()) { + sampleFolder.createSync(recursive: true); } File sampleFile = File('${sampleFolder.path}/Backups_Go_Here.info'); if (Platform.isIOS) { - sampleFile = File('${rootPath!.path}/Backups_Go_Here.info'); + sampleFile = File('${_rootPath.path}/Backups_Go_Here.info'); } - try { - if (!sampleFile.existsSync()) { - sampleFile.createSync(); - } - } catch (e, s) { - // todo: come back to this - debugPrint("$e $s"); + if (!sampleFile.existsSync()) { + sampleFile.createSync(); } - startPath = sampleFolder; + + _startPath = sampleFolder; return sampleFolder; } - Future pickDir(BuildContext context) async { - final String? chosenPath; - if (Platform.isIOS) { - chosenPath = startPath?.path; - } else { - final String path = - Platform.isWindows - ? startPath!.path.replaceAll("/", "\\") - : startPath!.path; - chosenPath = await FilePicker.platform.getDirectoryPath( - dialogTitle: "Choose Backup location", - initialDirectory: path, - lockParentWindow: true, - ); - } - dirPath = chosenPath; + Future pickDir() { + return FS.pickDirectory( + initialDirectory: Platform.isWindows + ? _startPath?.path.replaceAll("/", "\\") + : _startPath?.path, + ); } - Future openFile(BuildContext context) async { + Future openFile() async { FilePickerResult? result; if (Platform.isAndroid) { result = await FilePicker.platform.pickFiles( dialogTitle: "Load backup file", - initialDirectory: startPath!.path, + initialDirectory: _startPath!.path, type: FileType.any, allowCompression: false, lockParentWindow: true, @@ -106,7 +75,7 @@ class SWBFileSystem { } else if (Platform.isIOS) { result = await FilePicker.platform.pickFiles( dialogTitle: "Load backup file", - initialDirectory: startPath!.path, + initialDirectory: _startPath!.path, type: FileType.any, allowCompression: false, lockParentWindow: true, @@ -114,7 +83,7 @@ class SWBFileSystem { } else { result = await FilePicker.platform.pickFiles( dialogTitle: "Load backup file", - initialDirectory: startPath!.path, + initialDirectory: _startPath!.path, type: FileType.custom, allowedExtensions: ['bin', 'swb'], allowCompression: false, @@ -122,6 +91,6 @@ class SWBFileSystem { ); } - filePath = result?.paths.first; + return result?.paths.first; } } diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/restore_from_encrypted_string_view.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/restore_from_encrypted_string_view.dart index 5aded8ec62..77ed42e0f5 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/restore_from_encrypted_string_view.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/restore_from_encrypted_string_view.dart @@ -12,7 +12,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:tuple/tuple.dart'; import '../../../../app_config.dart'; import '../../../../notifications/show_flush_bar.dart'; @@ -73,8 +72,9 @@ class _RestoreFromEncryptedStringViewState onWillPop: _onWillPop, child: Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () async { @@ -120,41 +120,41 @@ class _RestoreFromEncryptedStringViewState obscureText: hidePassword, enableSuggestions: false, autocorrect: false, - decoration: standardInputDecoration( - "Enter password", - passwordFocusNode, - context, - ).copyWith( - suffixIcon: UnconstrainedBox( - child: Row( - children: [ - const SizedBox(width: 16), - GestureDetector( - key: const Key( - "restoreFromFilePasswordFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: SvgPicture.asset( - hidePassword - ? Assets.svg.eye - : Assets.svg.eyeSlash, - color: - Theme.of(context) + decoration: + standardInputDecoration( + "Enter password", + passwordFocusNode, + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: Row( + children: [ + const SizedBox(width: 16), + GestureDetector( + key: const Key( + "restoreFromFilePasswordFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of(context) .extension()! .textDark3, - width: 16, - height: 16, - ), + width: 16, + height: 16, + ), + ), + const SizedBox(width: 12), + ], ), - const SizedBox(width: 12), - ], + ), ), - ), - ), onChanged: (newValue) { setState(() {}); }, @@ -163,114 +163,108 @@ class _RestoreFromEncryptedStringViewState const SizedBox(height: 16), const Spacer(), TextButton( - style: - passwordController.text.isEmpty - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle( - context, - ), - onPressed: - passwordController.text.isEmpty - ? null - : () async { - final String passphrase = - passwordController.text; + style: passwordController.text.isEmpty + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context), + onPressed: passwordController.text.isEmpty + ? null + : () async { + final String passphrase = + passwordController.text; - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 75), - ); - } + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75), + ); + } - bool shouldPop = false; - showDialog( - barrierDismissible: false, - context: context, - builder: - (_) => WillPopScope( - onWillPop: () async { - return shouldPop; - }, - child: Column( - crossAxisAlignment: - CrossAxisAlignment - .stretch, - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Material( - color: Colors.transparent, - child: Center( - child: Text( - "Decrypting ${AppConfig.prefix} backup file", - style: STextStyles.pageTitleH2( - context, - ).copyWith( - color: - Theme.of( - context, - ) - .extension< - StackColors - >()! - .textWhite, - ), + bool shouldPop = false; + showDialog( + barrierDismissible: false, + context: context, + builder: (_) => WillPopScope( + onWillPop: () async { + return shouldPop; + }, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Material( + color: Colors.transparent, + child: Center( + child: Text( + "Decrypting ${AppConfig.prefix} backup file", + style: + STextStyles.pageTitleH2( + context, + ).copyWith( + color: + Theme.of(context) + .extension< + StackColors + >()! + .textWhite, ), - ), - ), - const SizedBox(height: 64), - const Center( - child: LoadingIndicator( - width: 100, - ), - ), - ], + ), ), ), - ); - - final String? - jsonString = await compute( - SWB.decryptStackWalletStringWithPassphrase, - Tuple2(widget.encrypted, passphrase), - debugLabel: - "stack wallet decryption compute", - ); + const SizedBox(height: 64), + const Center( + child: LoadingIndicator( + width: 100, + ), + ), + ], + ), + ), + ); - if (mounted) { - // pop LoadingIndicator - shouldPop = true; - Navigator.of(context).pop(); + final String? jsonString = await compute( + SWB.decryptStackWalletStringWithPassphrase, + ( + encryptedText: widget.encrypted, + passphrase: passphrase, + ), + debugLabel: + "stack wallet decryption compute", + ); - passwordController.text = ""; + if (mounted) { + // pop LoadingIndicator + shouldPop = true; + Navigator.of(context).pop(); - if (jsonString == null) { - showFloatingFlushBar( - type: FlushBarType.warning, - message: - "Failed to decrypt backup file", - context: context, - ); - return; - } + passwordController.text = ""; - Navigator.of(context).push( - RouteGenerator.getRoute( - builder: - (_) => - StackRestoreProgressView( - jsonString: jsonString, - fromFile: true, - ), - ), + if (jsonString == null) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "Failed to decrypt backup file", + context: context, ); + return; } - }, + + Navigator.of(context).push( + RouteGenerator.getRoute( + builder: (_) => + StackRestoreProgressView( + jsonString: jsonString, + fromFile: true, + ), + ), + ); + } + }, child: Text( "Restore", style: STextStyles.button(context), diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/restore_from_file_view.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/restore_from_file_view.dart index d0ce73db15..1b8f4283be 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/restore_from_file_view.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/restore_from_file_view.dart @@ -15,7 +15,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:tuple/tuple.dart'; import '../../../../app_config.dart'; import '../../../../notifications/show_flush_bar.dart'; @@ -89,8 +88,9 @@ class _RestoreFromFileViewState extends ConsumerState { builder: (child) { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () async { @@ -140,12 +140,12 @@ class _RestoreFromFileViewState extends ConsumerState { padding: const EdgeInsets.only(bottom: 10.0), child: Text( "Choose file location", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context).extension()!.textDark3, - ), + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), textAlign: TextAlign.left, ), ), @@ -163,14 +163,13 @@ class _RestoreFromFileViewState extends ConsumerState { try { await stackFileSystem.prepareStorage(); if (mounted) { - await stackFileSystem.openFile(context); - } + final filePath = await stackFileSystem.openFile(); - if (mounted) { - setState(() { - fileLocationController.text = - stackFileSystem.filePath ?? ""; - }); + if (mounted) { + setState(() { + fileLocationController.text = filePath ?? ""; + }); + } } } catch (e, s) { Logging.instance.e("$e\n$s", error: e, stackTrace: s); @@ -187,10 +186,9 @@ class _RestoreFromFileViewState extends ConsumerState { const SizedBox(width: 16), SvgPicture.asset( Assets.svg.folder, - color: - Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of( + context, + ).extension()!.textDark3, width: 16, height: 16, ), @@ -215,12 +213,12 @@ class _RestoreFromFileViewState extends ConsumerState { padding: const EdgeInsets.only(bottom: 10.0), child: Text( "Enter passphrase", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context).extension()!.textDark3, - ), + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), textAlign: TextAlign.left, ), ), @@ -236,41 +234,44 @@ class _RestoreFromFileViewState extends ConsumerState { obscureText: hidePassword, enableSuggestions: false, autocorrect: false, - decoration: standardInputDecoration( - "Enter passphrase", - passwordFocusNode, - context, - ).copyWith( - labelStyle: - isDesktop ? STextStyles.fieldLabel(context) : null, - suffixIcon: UnconstrainedBox( - child: Row( - children: [ - const SizedBox(width: 16), - GestureDetector( - key: const Key( - "restoreFromFilePasswordFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: SvgPicture.asset( - hidePassword ? Assets.svg.eye : Assets.svg.eyeSlash, - color: - Theme.of( + decoration: + standardInputDecoration( + "Enter passphrase", + passwordFocusNode, + context, + ).copyWith( + labelStyle: isDesktop + ? STextStyles.fieldLabel(context) + : null, + suffixIcon: UnconstrainedBox( + child: Row( + children: [ + const SizedBox(width: 16), + GestureDetector( + key: const Key( + "restoreFromFilePasswordFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of( context, ).extension()!.textDark3, - width: 16, - height: 16, - ), + width: 16, + height: 16, + ), + ), + const SizedBox(width: 12), + ], ), - const SizedBox(width: 12), - ], + ), ), - ), - ), onChanged: (newValue) { setState(() {}); }, @@ -280,20 +281,20 @@ class _RestoreFromFileViewState extends ConsumerState { if (!isDesktop) const Spacer(), !isDesktop ? TextButton( - style: - passwordController.text.isEmpty || - fileLocationController.text.isEmpty - ? Theme.of(context) + style: + passwordController.text.isEmpty || + fileLocationController.text.isEmpty + ? Theme.of(context) .extension()! .getPrimaryDisabledButtonStyle(context) - : Theme.of(context) + : Theme.of(context) .extension()! .getPrimaryEnabledButtonStyle(context), - onPressed: - passwordController.text.isEmpty || - fileLocationController.text.isEmpty - ? null - : () async { + onPressed: + passwordController.text.isEmpty || + fileLocationController.text.isEmpty + ? null + : () async { final String fileToRestore = fileLocationController.text; final String passphrase = passwordController.text; @@ -319,48 +320,51 @@ class _RestoreFromFileViewState extends ConsumerState { showDialog( barrierDismissible: false, context: context, - builder: - (_) => WillPopScope( - onWillPop: () async { - return shouldPop; - }, - child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Material( - color: Colors.transparent, - child: Center( - child: Text( - "Decrypting ${AppConfig.prefix} backup file", - style: STextStyles.pageTitleH2( + builder: (_) => WillPopScope( + onWillPop: () async { + return shouldPop; + }, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Material( + color: Colors.transparent, + child: Center( + child: Text( + "Decrypting ${AppConfig.prefix} backup file", + style: + STextStyles.pageTitleH2( context, ).copyWith( - color: - Theme.of(context) - .extension< - StackColors - >()! - .textWhite, + color: Theme.of(context) + .extension()! + .textWhite, ), - ), - ), ), - const SizedBox(height: 64), - const Center( - child: LoadingIndicator(width: 100), - ), - ], + ), ), - ), + const SizedBox(height: 64), + const Center( + child: LoadingIndicator(width: 100), + ), + ], + ), + ), ), ); + final encryptedText = await File( + fileToRestore, + ).readAsString(); + final String? jsonString = await compute( - SWB.decryptStackWalletWithPassphrase, - Tuple2(fileToRestore, passphrase), + SWB.decryptStackWalletStringWithPassphrase, + ( + encryptedText: encryptedText, + passphrase: passphrase, + ), debugLabel: "stack wallet decryption compute", ); @@ -382,31 +386,30 @@ class _RestoreFromFileViewState extends ConsumerState { await Navigator.of(context).push( RouteGenerator.getRoute( - builder: - (_) => StackRestoreProgressView( - jsonString: jsonString, - shouldPushToHome: true, - ), + builder: (_) => StackRestoreProgressView( + jsonString: jsonString, + shouldPushToHome: true, + ), ), ); } }, - child: Text("Restore", style: STextStyles.button(context)), - ) + child: Text("Restore", style: STextStyles.button(context)), + ) : Row( - children: [ - PrimaryButton( - width: 183, - buttonHeight: ButtonHeight.m, - label: "Restore", - enabled: - !(passwordController.text.isEmpty || - fileLocationController.text.isEmpty), - onPressed: - passwordController.text.isEmpty || - fileLocationController.text.isEmpty - ? null - : () async { + children: [ + PrimaryButton( + width: 183, + buttonHeight: ButtonHeight.m, + label: "Restore", + enabled: + !(passwordController.text.isEmpty || + fileLocationController.text.isEmpty), + onPressed: + passwordController.text.isEmpty || + fileLocationController.text.isEmpty + ? null + : () async { final String fileToRestore = fileLocationController.text; final String passphrase = @@ -433,55 +436,58 @@ class _RestoreFromFileViewState extends ConsumerState { showDialog( barrierDismissible: false, context: context, - builder: - (_) => WillPopScope( - onWillPop: () async { - return shouldPop; - }, - child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Material( - color: Colors.transparent, - child: Center( - child: Text( - "Decrypting ${AppConfig.prefix} backup file", - style: - STextStyles.pageTitleH2( - context, - ).copyWith( - color: - Theme.of(context) - .extension< - StackColors - >()! - .textWhite, - ), - ), - ), - ), - const SizedBox(height: 64), - const Center( - child: LoadingIndicator( - width: 100, - ), + builder: (_) => WillPopScope( + onWillPop: () async { + return shouldPop; + }, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Material( + color: Colors.transparent, + child: Center( + child: Text( + "Decrypting ${AppConfig.prefix} backup file", + style: + STextStyles.pageTitleH2( + context, + ).copyWith( + color: Theme.of(context) + .extension< + StackColors + >()! + .textWhite, + ), ), - ], + ), ), - ), + const SizedBox(height: 64), + const Center( + child: LoadingIndicator(width: 100), + ), + ], + ), + ), ), ); + final encryptedText = await File( + fileToRestore, + ).readAsString(); + final String? jsonString = await compute( - SWB.decryptStackWalletWithPassphrase, - Tuple2(fileToRestore, passphrase), + SWB.decryptStackWalletStringWithPassphrase, + ( + encryptedText: encryptedText, + passphrase: passphrase, + ), debugLabel: "stack wallet decryption compute", ); - if (mounted) { + if (context.mounted) { // pop LoadingIndicator shouldPop = true; Navigator.of( @@ -571,16 +577,16 @@ class _RestoreFromFileViewState extends ConsumerState { ); } }, - ), - const SizedBox(width: 16), - SecondaryButton( - width: 183, - buttonHeight: ButtonHeight.m, - label: "Cancel", - onPressed: () {}, - ), - ], - ), + ), + const SizedBox(width: 16), + SecondaryButton( + width: 183, + buttonHeight: ButtonHeight.m, + label: "Cancel", + onPressed: () {}, + ), + ], + ), ], ), ), diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_views/stack_restore_progress_view.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_views/stack_restore_progress_view.dart index 7b71dc8c64..2062ef3f9b 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_views/stack_restore_progress_view.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_views/stack_restore_progress_view.dart @@ -19,6 +19,7 @@ import '../../../../../pages_desktop_specific/desktop_home_view.dart'; import '../../../../../pages_desktop_specific/desktop_menu.dart'; import '../../../../../providers/desktop/current_desktop_menu_item.dart'; import '../../../../../providers/global/secure_store_provider.dart'; +import '../../../../../providers/global/shopin_bit_service_provider.dart'; import '../../../../../providers/providers.dart'; import '../../../../../providers/stack_restore/stack_restoring_ui_state_provider.dart'; import '../../../../../themes/stack_colors.dart'; @@ -69,34 +70,32 @@ class _StackRestoreProgressViewState showDialog( barrierDismissible: false, context: context, - builder: - (_) => WillPopScope( - onWillPop: () async { - return shouldPop; - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Material( - color: Colors.transparent, - child: Center( - child: Text( - "Cancelling restore. Please wait.", - style: STextStyles.pageTitleH2(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textWhite, - ), - ), + builder: (_) => WillPopScope( + onWillPop: () async { + return shouldPop; + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Material( + color: Colors.transparent, + child: Center( + child: Text( + "Cancelling restore. Please wait.", + style: STextStyles.pageTitleH2(context).copyWith( + color: Theme.of( + context, + ).extension()!.textWhite, ), ), - const SizedBox(height: 64), - const Center(child: LoadingIndicator(width: 100)), - ], + ), ), - ), + const SizedBox(height: 64), + const Center(child: LoadingIndicator(width: 100)), + ], + ), + ), ), ); @@ -108,12 +107,12 @@ class _StackRestoreProgressViewState if (mounted) { !isDesktop ? Navigator.of(context).popUntil( - ModalRoute.withName( - widget.fromFile - ? RestoreFromEncryptedStringView.routeName - : StackBackupView.routeName, - ), - ) + ModalRoute.withName( + widget.fromFile + ? RestoreFromEncryptedStringView.routeName + : StackBackupView.routeName, + ), + ) : Navigator.of(context).popUntil((_) => count++ >= 2); } } @@ -164,6 +163,7 @@ class _StackRestoreProgressViewState widget.jsonString, uiState, ref.read(secureStoreProvider), + ref.read(pShopinBitService), ); } catch (e, s) { Logging.instance.w("$e\n$s", error: e, stackTrace: s); @@ -199,8 +199,9 @@ class _StackRestoreProgressViewState case StackRestoringStatus.waiting: return SvgPicture.asset( Assets.svg.loader, - color: - Theme.of(context).extension()!.buttonBackSecondary, + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, ); case StackRestoringStatus.restoring: return SvgPicture.asset( @@ -248,8 +249,9 @@ class _StackRestoreProgressViewState return WillPopScope( onWillPop: _onWillPop, child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () async { @@ -302,69 +304,22 @@ class _StackRestoreProgressViewState ); return !isDesktop ? RestoringItemCard( - left: SizedBox( - width: 32, - height: 32, - child: RoundedContainer( - padding: const EdgeInsets.all(0), - color: - Theme.of( - context, - ).extension()!.buttonBackSecondary, - child: Center( - child: SvgPicture.asset( - Assets.svg.gear, - width: 16, - height: 16, - color: - Theme.of( - context, - ).extension()!.accentColorDark, - ), - ), - ), - ), - right: SizedBox( - width: 20, - height: 20, - child: _getIconForState(state), - ), - title: "Preferences", - subTitle: - state == StackRestoringStatus.failed - ? Text( - "Something went wrong", - style: STextStyles.errorSmall(context), - ) - : null, - ) - : RoundedContainer( - padding: EdgeInsets.zero, - color: - Theme.of(context).extension()!.popupBG, - borderColor: - Theme.of( - context, - ).extension()!.background, - child: RestoringItemCard( left: SizedBox( width: 32, height: 32, child: RoundedContainer( padding: const EdgeInsets.all(0), - color: - Theme.of(context) - .extension()! - .buttonBackSecondary, + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, child: Center( child: SvgPicture.asset( Assets.svg.gear, width: 16, height: 16, - color: - Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -375,15 +330,56 @@ class _StackRestoreProgressViewState child: _getIconForState(state), ), title: "Preferences", - subTitle: - state == StackRestoringStatus.failed - ? Text( + subTitle: state == StackRestoringStatus.failed + ? Text( + "Something went wrong", + style: STextStyles.errorSmall(context), + ) + : null, + ) + : RoundedContainer( + padding: EdgeInsets.zero, + color: Theme.of( + context, + ).extension()!.popupBG, + borderColor: Theme.of( + context, + ).extension()!.background, + child: RestoringItemCard( + left: SizedBox( + width: 32, + height: 32, + child: RoundedContainer( + padding: const EdgeInsets.all(0), + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, + child: Center( + child: SvgPicture.asset( + Assets.svg.gear, + width: 16, + height: 16, + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + right: SizedBox( + width: 20, + height: 20, + child: _getIconForState(state), + ), + title: "Preferences", + subTitle: state == StackRestoringStatus.failed + ? Text( "Something went wrong", style: STextStyles.errorSmall(context), ) - : null, - ), - ); + : null, + ), + ); }, ), const SizedBox(height: 12), @@ -396,67 +392,21 @@ class _StackRestoreProgressViewState ); return !isDesktop ? RestoringItemCard( - left: SizedBox( - width: 32, - height: 32, - child: RoundedContainer( - padding: const EdgeInsets.all(0), - color: - Theme.of( - context, - ).extension()!.buttonBackSecondary, - child: Center( - child: AddressBookIcon( - width: 16, - height: 16, - color: - Theme.of( - context, - ).extension()!.accentColorDark, - ), - ), - ), - ), - right: SizedBox( - width: 20, - height: 20, - child: _getIconForState(state), - ), - title: "Address book", - subTitle: - state == StackRestoringStatus.failed - ? Text( - "Something went wrong", - style: STextStyles.errorSmall(context), - ) - : null, - ) - : RoundedContainer( - padding: EdgeInsets.zero, - color: - Theme.of(context).extension()!.popupBG, - borderColor: - Theme.of( - context, - ).extension()!.background, - child: RestoringItemCard( left: SizedBox( width: 32, height: 32, child: RoundedContainer( padding: const EdgeInsets.all(0), - color: - Theme.of(context) - .extension()! - .buttonBackSecondary, + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, child: Center( child: AddressBookIcon( width: 16, height: 16, - color: - Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -467,15 +417,55 @@ class _StackRestoreProgressViewState child: _getIconForState(state), ), title: "Address book", - subTitle: - state == StackRestoringStatus.failed - ? Text( + subTitle: state == StackRestoringStatus.failed + ? Text( + "Something went wrong", + style: STextStyles.errorSmall(context), + ) + : null, + ) + : RoundedContainer( + padding: EdgeInsets.zero, + color: Theme.of( + context, + ).extension()!.popupBG, + borderColor: Theme.of( + context, + ).extension()!.background, + child: RestoringItemCard( + left: SizedBox( + width: 32, + height: 32, + child: RoundedContainer( + padding: const EdgeInsets.all(0), + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, + child: Center( + child: AddressBookIcon( + width: 16, + height: 16, + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + right: SizedBox( + width: 20, + height: 20, + child: _getIconForState(state), + ), + title: "Address book", + subTitle: state == StackRestoringStatus.failed + ? Text( "Something went wrong", style: STextStyles.errorSmall(context), ) - : null, - ), - ); + : null, + ), + ); }, ), const SizedBox(height: 12), @@ -488,69 +478,22 @@ class _StackRestoreProgressViewState ); return !isDesktop ? RestoringItemCard( - left: SizedBox( - width: 32, - height: 32, - child: RoundedContainer( - padding: const EdgeInsets.all(0), - color: - Theme.of( - context, - ).extension()!.buttonBackSecondary, - child: Center( - child: SvgPicture.asset( - Assets.svg.node, - width: 16, - height: 16, - color: - Theme.of( - context, - ).extension()!.accentColorDark, - ), - ), - ), - ), - right: SizedBox( - width: 20, - height: 20, - child: _getIconForState(state), - ), - title: "Nodes", - subTitle: - state == StackRestoringStatus.failed - ? Text( - "Something went wrong", - style: STextStyles.errorSmall(context), - ) - : null, - ) - : RoundedContainer( - padding: EdgeInsets.zero, - color: - Theme.of(context).extension()!.popupBG, - borderColor: - Theme.of( - context, - ).extension()!.background, - child: RestoringItemCard( left: SizedBox( width: 32, height: 32, child: RoundedContainer( padding: const EdgeInsets.all(0), - color: - Theme.of(context) - .extension()! - .buttonBackSecondary, + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, child: Center( child: SvgPicture.asset( Assets.svg.node, width: 16, height: 16, - color: - Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -561,15 +504,56 @@ class _StackRestoreProgressViewState child: _getIconForState(state), ), title: "Nodes", - subTitle: - state == StackRestoringStatus.failed - ? Text( + subTitle: state == StackRestoringStatus.failed + ? Text( + "Something went wrong", + style: STextStyles.errorSmall(context), + ) + : null, + ) + : RoundedContainer( + padding: EdgeInsets.zero, + color: Theme.of( + context, + ).extension()!.popupBG, + borderColor: Theme.of( + context, + ).extension()!.background, + child: RestoringItemCard( + left: SizedBox( + width: 32, + height: 32, + child: RoundedContainer( + padding: const EdgeInsets.all(0), + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, + child: Center( + child: SvgPicture.asset( + Assets.svg.node, + width: 16, + height: 16, + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + right: SizedBox( + width: 20, + height: 20, + child: _getIconForState(state), + ), + title: "Nodes", + subTitle: state == StackRestoringStatus.failed + ? Text( "Something went wrong", style: STextStyles.errorSmall(context), ) - : null, - ), - ); + : null, + ), + ); }, ), const SizedBox(height: 12), @@ -582,69 +566,22 @@ class _StackRestoreProgressViewState ); return !isDesktop ? RestoringItemCard( - left: SizedBox( - width: 32, - height: 32, - child: RoundedContainer( - padding: const EdgeInsets.all(0), - color: - Theme.of( - context, - ).extension()!.buttonBackSecondary, - child: Center( - child: SvgPicture.asset( - Assets.svg.arrowsTwoWay, - width: 16, - height: 16, - color: - Theme.of( - context, - ).extension()!.accentColorDark, - ), - ), - ), - ), - right: SizedBox( - width: 20, - height: 20, - child: _getIconForState(state), - ), - title: "Exchange history", - subTitle: - state == StackRestoringStatus.failed - ? Text( - "Something went wrong", - style: STextStyles.errorSmall(context), - ) - : null, - ) - : RoundedContainer( - padding: EdgeInsets.zero, - color: - Theme.of(context).extension()!.popupBG, - borderColor: - Theme.of( - context, - ).extension()!.background, - child: RestoringItemCard( left: SizedBox( width: 32, height: 32, child: RoundedContainer( padding: const EdgeInsets.all(0), - color: - Theme.of(context) - .extension()! - .buttonBackSecondary, + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, child: Center( child: SvgPicture.asset( Assets.svg.arrowsTwoWay, width: 16, height: 16, - color: - Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -655,15 +592,56 @@ class _StackRestoreProgressViewState child: _getIconForState(state), ), title: "Exchange history", - subTitle: - state == StackRestoringStatus.failed - ? Text( + subTitle: state == StackRestoringStatus.failed + ? Text( + "Something went wrong", + style: STextStyles.errorSmall(context), + ) + : null, + ) + : RoundedContainer( + padding: EdgeInsets.zero, + color: Theme.of( + context, + ).extension()!.popupBG, + borderColor: Theme.of( + context, + ).extension()!.background, + child: RestoringItemCard( + left: SizedBox( + width: 32, + height: 32, + child: RoundedContainer( + padding: const EdgeInsets.all(0), + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, + child: Center( + child: SvgPicture.asset( + Assets.svg.arrowsTwoWay, + width: 16, + height: 16, + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + right: SizedBox( + width: 20, + height: 20, + child: _getIconForState(state), + ), + title: "Exchange history", + subTitle: state == StackRestoringStatus.failed + ? Text( "Something went wrong", style: STextStyles.errorSmall(context), ) - : null, - ), - ); + : null, + ), + ); }, ), const SizedBox(height: 16), @@ -685,55 +663,54 @@ class _StackRestoreProgressViewState const SizedBox(height: 30), SizedBox( width: MediaQuery.of(context).size.width - 32, - child: - !isDesktop - ? TextButton( - onPressed: () async { - if (_success) { - if (widget.shouldPushToHome) { - Navigator.of(context).popUntil( - ModalRoute.withName(HomeView.routeName), - ); - } else { - Navigator.of(context).pop(); - } + child: !isDesktop + ? TextButton( + onPressed: () async { + if (_success) { + if (widget.shouldPushToHome) { + Navigator.of(context).popUntil( + ModalRoute.withName(HomeView.routeName), + ); } else { - if (await _requestCancel()) { - await _cancel(); - } + Navigator.of(context).pop(); } - }, - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - child: Text( - _success ? "OK" : "Cancel restore process", - style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.buttonTextPrimary, - ), + } else { + if (await _requestCancel()) { + await _cancel(); + } + } + }, + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + child: Text( + _success ? "OK" : "Cancel restore process", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.buttonTextPrimary, ), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - _success - ? PrimaryButton( + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _success + ? PrimaryButton( width: 248, buttonHeight: ButtonHeight.l, enabled: true, label: "Done", onPressed: () async { - final DesktopMenuItemId keyID = - DesktopMenuItemId.myStack; + const DesktopMenuItemId keyID = .myStack; ref - .read( - currentDesktopMenuItemProvider.state, - ) - .state = keyID; + .read( + currentDesktopMenuItemProvider + .state, + ) + .state = + keyID; if (widget.shouldPushToHome) { unawaited( @@ -756,7 +733,7 @@ class _StackRestoreProgressViewState } }, ) - : SecondaryButton( + : SecondaryButton( width: 248, buttonHeight: ButtonHeight.l, enabled: true, @@ -767,8 +744,8 @@ class _StackRestoreProgressViewState } }, ), - ], - ), + ], + ), ), ], ), diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_widgets/restoring_wallet_card.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_widgets/restoring_wallet_card.dart index f38104ccd7..9a792c48de 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_widgets/restoring_wallet_card.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_widgets/restoring_wallet_card.dart @@ -22,18 +22,20 @@ import '../../../../../themes/stack_colors.dart'; import '../../../../../themes/theme_providers.dart'; import '../../../../../utilities/assets.dart'; import '../../../../../utilities/enums/stack_restoring_status.dart'; +import '../../../../../utilities/logger.dart'; import '../../../../../utilities/text_styles.dart'; import '../../../../../utilities/util.dart'; +import '../../../../../wallets/wallet/impl/epiccash_wallet.dart'; +import '../../../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../../../../wallets/wallet/impl/xelis_wallet.dart'; +import '../../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../../widgets/loading_indicator.dart'; import '../../../../../widgets/rounded_container.dart'; import '../sub_views/recovery_phrase_view.dart'; import 'restoring_item_card.dart'; class RestoringWalletCard extends ConsumerStatefulWidget { - const RestoringWalletCard({ - super.key, - required this.provider, - }); + const RestoringWalletCard({super.key, required this.provider}); final ChangeNotifierProvider provider; @@ -45,13 +47,78 @@ class RestoringWalletCard extends ConsumerStatefulWidget { class _RestoringWalletCardState extends ConsumerState { late final ChangeNotifierProvider provider; + Future _retry() async { + final wallet = ref.read(provider).wallet!; + try { + ref + .read(stackRestoringUIStateProvider) + .update( + walletId: wallet.walletId, + restoringStatus: StackRestoringStatus.restoring, + ); + + switch (wallet) { + case EpiccashWallet(): + await wallet.init(isRestore: true); + break; + + case MimblewimblecoinWallet(): + await wallet.init(isRestore: true); + break; + + case CryptonoteWallet(): + await wallet.init(isRestore: true); + await wallet.open(); + break; + + case XelisWallet(): + await wallet.init(isRestore: true); + break; + + default: + await wallet.init(); + } + + await wallet.recover(isRescan: true); + + final address = await wallet.getCurrentReceivingAddress(); + + await wallet.exit(); + + if (mounted) { + ref + .read(stackRestoringUIStateProvider) + .update( + walletId: wallet.walletId, + restoringStatus: StackRestoringStatus.success, + address: address?.value, + ); + } + } catch (e, s) { + Logging.instance.e( + "retry SWB single wallet tapped", + error: e, + stackTrace: s, + ); + if (mounted) { + ref + .read(stackRestoringUIStateProvider) + .update( + walletId: wallet.walletId, + restoringStatus: StackRestoringStatus.failed, + ); + } + } + } + Widget _getIconForState(StackRestoringStatus state) { switch (state) { case StackRestoringStatus.waiting: return SvgPicture.asset( Assets.svg.loader, - color: - Theme.of(context).extension()!.buttonBackSecondary, + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, ); case StackRestoringStatus.restoring: return const LoadingIndicator(); @@ -81,8 +148,9 @@ class _RestoringWalletCardState extends ConsumerState { @override Widget build(BuildContext context) { final coin = ref.watch(provider.select((value) => value.coin)); - final restoringStatus = - ref.watch(provider.select((value) => value.restoringState)); + final restoringStatus = ref.watch( + provider.select((value) => value.restoringState), + ); return !Util.isDesktop ? RestoringItemCard( left: SizedBox( @@ -93,9 +161,7 @@ class _RestoringWalletCardState extends ConsumerState { color: ref.watch(pCoinColor(coin)), child: Center( child: SvgPicture.file( - File( - ref.watch(coinIconProvider(coin)), - ), + File(ref.watch(coinIconProvider(coin))), height: 20, width: 20, ), @@ -103,36 +169,7 @@ class _RestoringWalletCardState extends ConsumerState { ), ), onRightTapped: restoringStatus == StackRestoringStatus.failed - ? () async { - final wallet = ref.read(provider).wallet!; - - ref.read(stackRestoringUIStateProvider).update( - walletId: wallet.walletId, - restoringStatus: StackRestoringStatus.restoring, - ); - - try { - await wallet.recover(isRescan: true); - - if (mounted) { - final address = - await wallet.getCurrentReceivingAddress(); - - ref.read(stackRestoringUIStateProvider).update( - walletId: wallet.walletId, - restoringStatus: StackRestoringStatus.success, - address: address!.value, - ); - } - } catch (_) { - if (mounted) { - ref.read(stackRestoringUIStateProvider).update( - walletId: wallet.walletId, - restoringStatus: StackRestoringStatus.failed, - ); - } - } - } + ? _retry : null, right: SizedBox( width: 20, @@ -149,30 +186,27 @@ class _RestoringWalletCardState extends ConsumerState { style: STextStyles.errorSmall(context), ) : ref.watch(provider.select((value) => value.address)) != null - ? Text( - ref.watch(provider.select((value) => value.address))!, - style: STextStyles.infoSmall(context), - ) - : null, + ? Text( + ref.watch(provider.select((value) => value.address))!, + style: STextStyles.infoSmall(context), + ) + : null, button: restoringStatus == StackRestoringStatus.failed ? Container( height: 20, decoration: BoxDecoration( - color: Theme.of(context) - .extension()! - .buttonBackSecondary, - borderRadius: BorderRadius.circular( - 1000, - ), + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, + borderRadius: BorderRadius.circular(1000), ), child: RawMaterialButton( materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - splashColor: - Theme.of(context).extension()!.highlight, + splashColor: Theme.of( + context, + ).extension()!.highlight, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 1000, - ), + borderRadius: BorderRadius.circular(1000), ), onPressed: () async { final mnemonic = ref.read(provider).mnemonic; @@ -193,9 +227,9 @@ class _RestoringWalletCardState extends ConsumerState { child: Text( "Show recovery phrase", style: STextStyles.infoSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -216,11 +250,7 @@ class _RestoringWalletCardState extends ConsumerState { color: ref.watch(pCoinColor(coin)), child: Center( child: SvgPicture.file( - File( - ref.watch( - coinIconProvider(coin), - ), - ), + File(ref.watch(coinIconProvider(coin))), height: 20, width: 20, ), @@ -228,60 +258,7 @@ class _RestoringWalletCardState extends ConsumerState { ), ), onRightTapped: restoringStatus == StackRestoringStatus.failed - ? () async { - final wallet = ref.read(provider).wallet!; - - ref.read(stackRestoringUIStateProvider).update( - walletId: wallet.walletId, - restoringStatus: StackRestoringStatus.restoring, - ); - - try { - // final mnemonicList = await manager.mnemonic; - // int maxUnusedAddressGap = 20; - // if (coin is Firo) { - // maxUnusedAddressGap = 50; - // } - // const maxNumberOfIndexesToCheck = 1000; - // - // if (mnemonicList.isEmpty) { - // await manager.recoverFromMnemonic( - // mnemonic: ref.read(provider).mnemonic!, - // mnemonicPassphrase: - // ref.read(provider).mnemonicPassphrase!, - // maxUnusedAddressGap: maxUnusedAddressGap, - // maxNumberOfIndexesToCheck: - // maxNumberOfIndexesToCheck, - // height: ref.read(provider).height ?? 0, - // ); - // } else { - // await manager.fullRescan( - // maxUnusedAddressGap, - // maxNumberOfIndexesToCheck, - // ); - // } - - await wallet.recover(isRescan: true); - - if (mounted) { - final address = - await wallet.getCurrentReceivingAddress(); - - ref.read(stackRestoringUIStateProvider).update( - walletId: wallet.walletId, - restoringStatus: StackRestoringStatus.success, - address: address!.value, - ); - } - } catch (_) { - if (mounted) { - ref.read(stackRestoringUIStateProvider).update( - walletId: wallet.walletId, - restoringStatus: StackRestoringStatus.failed, - ); - } - } - } + ? _retry : null, right: SizedBox( width: 20, @@ -298,31 +275,27 @@ class _RestoringWalletCardState extends ConsumerState { style: STextStyles.errorSmall(context), ) : ref.watch(provider.select((value) => value.address)) != null - ? Text( - ref.watch(provider.select((value) => value.address))!, - style: STextStyles.infoSmall(context), - ) - : null, + ? Text( + ref.watch(provider.select((value) => value.address))!, + style: STextStyles.infoSmall(context), + ) + : null, button: restoringStatus == StackRestoringStatus.failed ? Container( height: 20, decoration: BoxDecoration( - color: Theme.of(context) - .extension()! - .buttonBackSecondary, - borderRadius: BorderRadius.circular( - 1000, - ), + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, + borderRadius: BorderRadius.circular(1000), ), child: RawMaterialButton( materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - splashColor: Theme.of(context) - .extension()! - .highlight, + splashColor: Theme.of( + context, + ).extension()!.highlight, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 1000, - ), + borderRadius: BorderRadius.circular(1000), ), onPressed: () async { final mnemonic = ref.read(provider).mnemonic; @@ -343,9 +316,9 @@ class _RestoringWalletCardState extends ConsumerState { child: Text( "Show recovery phrase", style: STextStyles.infoSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), diff --git a/lib/pages/settings_views/sub_widgets/view_only_wallet_data_widget.dart b/lib/pages/settings_views/sub_widgets/view_only_wallet_data_widget.dart index 2659860879..ef68684fe6 100644 --- a/lib/pages/settings_views/sub_widgets/view_only_wallet_data_widget.dart +++ b/lib/pages/settings_views/sub_widgets/view_only_wallet_data_widget.dart @@ -81,6 +81,22 @@ class ViewOnlyWalletDataWidget extends StatelessWidget { ), ], ), + final SparkViewOnlyWalletData e => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DetailItem( + title: "View Key", + detail: e.viewKey, + button: Util.isDesktop + ? IconCopyButton( + data: e.viewKey, + ) + : SimpleCopyButton( + data: e.viewKey, + ), + ), + ], + ), }; } } diff --git a/lib/pages/settings_views/wallet_settings_view/epicbox_settings/add_edit_epicbox_mobile_view.dart b/lib/pages/settings_views/wallet_settings_view/epicbox_settings/add_edit_epicbox_mobile_view.dart new file mode 100644 index 0000000000..907aaafded --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/epicbox_settings/add_edit_epicbox_mobile_view.dart @@ -0,0 +1,463 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../../models/epicbox_server_model.dart'; +import '../../../../notifications/show_flush_bar.dart'; +import '../../../../providers/global/node_service_provider.dart'; +import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/assets.dart'; +import '../../../../utilities/constants.dart'; +import '../../../../utilities/test_epicbox_server_connection.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../widgets/background.dart'; +import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../../widgets/desktop/primary_button.dart'; +import '../../../../widgets/desktop/secondary_button.dart'; +import '../../../../widgets/icon_widgets/x_icon.dart'; +import '../../../../widgets/stack_text_field.dart'; +import '../../../../widgets/textfield_icon_button.dart'; + +enum AddEditEpicboxMobileViewType { add, edit } + +class AddEditEpicboxMobileView extends ConsumerStatefulWidget { + const AddEditEpicboxMobileView({ + super.key, + required this.viewType, + this.epicBoxId, + required this.routeOnSuccessOrDelete, + }) : assert( + (viewType == .edit && epicBoxId != null) || + viewType == .add && epicBoxId == null, + ); + + static const routeName = "/addEditEpicboxMobile"; + + final AddEditEpicboxMobileViewType viewType; + final String? epicBoxId; + final String routeOnSuccessOrDelete; + + @override + ConsumerState createState() => + _AddEditEpicboxMobileViewState(); +} + +class _AddEditEpicboxMobileViewState + extends ConsumerState { + late final TextEditingController _nameController; + late final TextEditingController _hostController; + late final TextEditingController _portController; + + final _nameFocusNode = FocusNode(); + final _hostFocusNode = FocusNode(); + final _portFocusNode = FocusNode(); + + bool _useSSL = true; + int? port; + + bool get canSave { + return _nameController.text.isNotEmpty && canTestConnection; + } + + bool get canTestConnection { + return _hostController.text.isNotEmpty && + port != null && + port! >= 0 && + port! <= 65535; + } + + Future _testConnection() async { + final data = EpicBoxFormData() + ..name = _nameController.text + ..host = _hostController.text + ..port = port ?? 443 + ..useSSL = _useSSL; + + final result = await testEpicBoxServerConnection(data); + if (!mounted) return; + + if (result != null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Connection successful", + context: context, + ), + ); + } else { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Could not connect to server", + context: context, + ), + ); + } + } + + Future _attemptSave() async { + final data = EpicBoxFormData() + ..name = _nameController.text + ..host = _hostController.text + ..port = port ?? 443 + ..useSSL = _useSSL; + + final canConnect = await testEpicBoxServerConnection(data) != null; + + bool shouldSave = canConnect; + + if (!canConnect && mounted) { + await showDialog( + context: context, + useSafeArea: true, + barrierDismissible: true, + builder: (context) => AlertDialog( + title: const Text("Server currently unreachable"), + content: const Text("Would you like to save this server anyways?"), + actions: [ + // todo both pop until routeOnSuccessOrDelete ? + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + "Cancel", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text( + "Save", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ], + ), + ).then((value) { + if (value == true) { + shouldSave = true; + } + }); + } + + if (!shouldSave) return; + + final epicBox = EpicBoxServerModel( + id: widget.epicBoxId ?? const Uuid().v1(), + host: _hostController.text, + port: port ?? 443, + name: _nameController.text, + useSSL: _useSSL, + enabled: true, + isFailover: true, + isDown: false, + ); + + await ref.read(nodeServiceChangeNotifierProvider).addEpicBox(epicBox, true); + + if (mounted) { + Navigator.of(context).pop(); + } + } + + late final bool canDelete; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(); + _hostController = TextEditingController(); + _portController = TextEditingController(); + + switch (widget.viewType) { + case .add: + _portController.text = "443"; + port = 443; + canDelete = false; + break; + + case .edit: + final epicBox = ref + .read(nodeServiceChangeNotifierProvider) + .getEpicBoxById(id: widget.epicBoxId!)!; + + _nameController.text = epicBox.name; + _hostController.text = epicBox.host; + _portController.text = (epicBox.port ?? 443).toString(); + _useSSL = epicBox.useSSL ?? true; + port = epicBox.port ?? 443; + canDelete = !epicBox.isDefault; + break; + } + } + + @override + void dispose() { + _nameController.dispose(); + _hostController.dispose(); + _portController.dispose(); + _nameFocusNode.dispose(); + _hostFocusNode.dispose(); + _portFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + widget.viewType == AddEditEpicboxMobileViewType.add + ? "Add Epicbox Server" + : "Edit Epicbox Server", + style: STextStyles.navBarTitle(context), + ), + actions: [ + if (canDelete) + Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10, right: 10), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + key: const Key("deleteNodeAppBarButtonKey"), + size: 36, + shadows: const [], + color: Theme.of( + context, + ).extension()!.background, + icon: SvgPicture.asset( + Assets.svg.trash, + color: Theme.of( + context, + ).extension()!.accentColorDark, + width: 20, + height: 20, + ), + onPressed: () async { + Navigator.popUntil( + context, + ModalRoute.withName(widget.routeOnSuccessOrDelete), + ); + await ref + .read(nodeServiceChangeNotifierProvider) + .deleteEpicBox(widget.epicBoxId!, true); + }, + ), + ), + ), + ], + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: false, + enableSuggestions: false, + controller: _nameController, + focusNode: _nameFocusNode, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Server name", + _nameFocusNode, + context, + ).copyWith( + suffixIcon: _nameController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only( + right: 0, + ), + child: UnconstrainedBox( + child: TextFieldIconButton( + child: const XIcon(), + onTap: () { + _nameController.clear(); + setState(() {}); + }, + ), + ), + ) + : null, + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: false, + enableSuggestions: false, + controller: _hostController, + focusNode: _hostFocusNode, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Host", + _hostFocusNode, + context, + ).copyWith( + suffixIcon: _hostController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only( + right: 0, + ), + child: UnconstrainedBox( + child: TextFieldIconButton( + child: const XIcon(), + onTap: () { + _hostController.clear(); + setState(() {}); + }, + ), + ), + ) + : null, + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: false, + enableSuggestions: false, + controller: _portController, + focusNode: _portFocusNode, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + keyboardType: TextInputType.number, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Port", + _portFocusNode, + context, + ).copyWith( + suffixIcon: _portController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only( + right: 0, + ), + child: UnconstrainedBox( + child: TextFieldIconButton( + child: const XIcon(), + onTap: () { + _portController.clear(); + setState(() {}); + }, + ), + ), + ) + : null, + ), + onChanged: (value) { + port = int.tryParse(value); + setState(() {}); + }, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + GestureDetector( + onTap: () { + setState(() { + _useSSL = !_useSSL; + }); + }, + child: Container( + color: Colors.transparent, + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: Checkbox( + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + value: _useSSL, + onChanged: (newValue) { + setState(() { + _useSSL = newValue!; + }); + }, + ), + ), + const SizedBox(width: 12), + Text( + "Use SSL", + style: STextStyles.itemSubtitle12( + context, + ), + ), + ], + ), + ), + ), + ], + ), + + const Spacer(), + const SizedBox(height: 16), + SecondaryButton( + label: "Test connection", + enabled: canTestConnection, + onPressed: canTestConnection + ? _testConnection + : null, + ), + const SizedBox(height: 16), + PrimaryButton( + label: "Save", + onPressed: canSave ? _attemptSave : null, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/epicbox_settings/manage_epicbox_view.dart b/lib/pages/settings_views/wallet_settings_view/epicbox_settings/manage_epicbox_view.dart new file mode 100644 index 0000000000..797ce8e019 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/epicbox_settings/manage_epicbox_view.dart @@ -0,0 +1,217 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../notifications/show_flush_bar.dart'; +import '../../../../providers/providers.dart'; +import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/assets.dart'; +import '../../../../utilities/default_epicboxes.dart'; +import '../../../../utilities/test_epicbox_server_connection.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; +import '../../../../widgets/background.dart'; +import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../../widgets/epicbox_card.dart'; +import 'add_edit_epicbox_mobile_view.dart'; + +class ManageEpicboxView extends ConsumerStatefulWidget { + const ManageEpicboxView({super.key, required this.walletId}); + + static const routeName = "/manageEpicbox"; + + final String walletId; + + @override + ConsumerState createState() => _ManageEpicboxViewState(); +} + +class _ManageEpicboxViewState extends ConsumerState { + Future _onConnect(String epicBoxId) async { + final epicBox = + ref + .read(nodeServiceChangeNotifierProvider) + .getEpicBoxById(id: epicBoxId) ?? + DefaultEpicBoxes.all.firstWhere((e) => e.id == epicBoxId); + + final data = EpicBoxFormData() + ..host = epicBox.host + ..port = epicBox.port ?? 443 + ..useSSL = epicBox.useSSL; + + final canConnect = await testEpicBoxServerConnection(data) != null; + + if (!canConnect && mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + iconAsset: Assets.svg.circleAlert, + message: "Could not connect to server", + context: context, + ), + ); + return; + } + + await ref + .read(nodeServiceChangeNotifierProvider) + .setPrimaryEpicBox(epicBox: epicBox, shouldNotifyListeners: true); + + // update wallet's epicbox config + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as EpiccashWallet; + await wallet.updateEpicboxConfig(epicBox.host, epicBox.port ?? 443); + + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Connected to ${epicBox.name}", + context: context, + ), + ); + } + } + + void _onEdit(String epicBoxId) { + Navigator.of(context).pushNamed( + AddEditEpicboxMobileView.routeName, + arguments: ( + viewType: AddEditEpicboxMobileViewType.edit, + epicBoxId: epicBoxId, + routeOnSuccessOrDelete: ManageEpicboxView.routeName, + ), + ); + } + + void _onAdd() { + Navigator.of(context).pushNamed( + AddEditEpicboxMobileView.routeName, + arguments: ( + viewType: AddEditEpicboxMobileViewType.add, + epicBoxId: null, + routeOnSuccessOrDelete: ManageEpicboxView.routeName, + ), + ); + } + + @override + Widget build(BuildContext context) { + final epicBoxes = ref.watch( + nodeServiceChangeNotifierProvider.select((value) => value.getEpicBoxes()), + ); + final primaryEpicBox = ref.watch( + nodeServiceChangeNotifierProvider.select( + (value) => value.getPrimaryEpicBox(), + ), + ); + + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Epicbox Servers", + style: STextStyles.navBarTitle(context), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 10), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + icon: SizedBox( + width: 20, + height: 20, + child: Center( + child: Icon( + Icons.add, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + size: 20, + ), + ), + ), + onPressed: _onAdd, + ), + ), + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text( + "Default servers", + style: STextStyles.smallMed12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), + ), + ), + ...DefaultEpicBoxes.all.map( + (epicBox) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: EpicBoxCard( + key: Key("${epicBox.id}_card_key"), + epicBoxId: epicBox.id, + onConnect: () => _onConnect(epicBox.id), + onEdit: () {}, + testOnInit: primaryEpicBox?.id == epicBox.id, + ), + ), + ), + ], + if (epicBoxes.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text( + "Custom servers", + style: STextStyles.smallMed12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), + ), + ), + ...epicBoxes.map( + (epicBox) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: EpicBoxCard( + key: Key("${epicBox.id}_card_key"), + epicBoxId: epicBox.id, + onConnect: () => _onConnect(epicBox.id), + onEdit: () => _onEdit(epicBox.id), + testOnInit: primaryEpicBox?.id == epicBox.id, + ), + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart index 2e3ffbb5af..3eccb79285 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart @@ -39,11 +39,11 @@ import '../../../../wallets/crypto_currency/coins/mimblewimblecoin.dart'; import '../../../../wallets/crypto_currency/coins/monero.dart'; import '../../../../wallets/crypto_currency/coins/salvium.dart'; import '../../../../wallets/crypto_currency/coins/wownero.dart'; +import '../../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; -import '../../../../wallets/wallet/impl/salvium_wallet.dart'; -import '../../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; +import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; import '../../../../widgets/animated_text.dart'; @@ -154,6 +154,13 @@ class _WalletNetworkSettingsViewState // pop rescanning dialog Navigator.of(context, rootNavigator: isDesktop).pop(); + final String message; + if (wallet is CryptonoteWallet || wallet is EpiccashWallet) { + message = "Rescan started"; + } else { + message = "Rescan completed"; + } + // show success await showDialog( context: context, @@ -164,7 +171,7 @@ class _WalletNetworkSettingsViewState builder: (child) => DesktopDialog(maxHeight: 150, maxWidth: 500, child: child), child: StackDialog( - title: "Rescan completed", + title: message, rightButton: TextButton( style: Theme.of(context) .extension()! @@ -333,16 +340,9 @@ class _WalletNetworkSettingsViewState final coin = ref.watch(pWalletCoin(widget.walletId)); - if (coin is Salvium) { + if (coin is CryptonoteCurrency) { final double highestPercent = - (ref.read(pWallets).getWallet(widget.walletId) as SalviumWallet) - .highestPercentCached; - if (_percent < highestPercent) { - _percent = highestPercent.clamp(0.0, 1.0); - } - } else if (coin is Monero || coin is Wownero) { - final double highestPercent = - (ref.read(pWallets).getWallet(widget.walletId) as LibMoneroWallet) + (ref.read(pWallets).getWallet(widget.walletId) as CryptonoteWallet) .highestPercentCached; if (_percent < highestPercent) { _percent = highestPercent.clamp(0.0, 1.0); @@ -387,11 +387,8 @@ class _WalletNetworkSettingsViewState ), title: Text("Network", style: STextStyles.navBarTitle(context)), actions: [ - if (ref.watch(pWalletCoin(widget.walletId)) is! Epiccash && - ref.watch(pWalletCoin(widget.walletId)) - is! Mimblewimblecoin || - ref.watch(pWalletCoin(widget.walletId)) - is! Mimblewimblecoin) + if (ref.watch(pWalletCoin(widget.walletId)) + is! Mimblewimblecoin) Padding( padding: const EdgeInsets.only( top: 10, @@ -991,7 +988,6 @@ class _WalletNetworkSettingsViewState ), ), if (isDesktop && - ref.watch(pWalletCoin(widget.walletId)) is! Epiccash && ref.watch(pWalletCoin(widget.walletId)) is! Mimblewimblecoin) RoundedWhiteContainer( borderColor: isDesktop diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart index b1b4854511..4fd2e8f95d 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart @@ -18,9 +18,9 @@ import 'package:tuple/tuple.dart'; import '../../../db/hive/db.dart'; import '../../../db/sqlite/firo_cache.dart'; import '../../../models/epicbox_config_model.dart'; -import '../../../models/mwcmqs_config_model.dart'; import '../../../models/keys/key_data_interface.dart'; import '../../../models/keys/view_only_wallet_data.dart'; +import '../../../models/mwcmqs_config_model.dart'; import '../../../notifications/show_flush_bar.dart'; import '../../../providers/global/wallets_provider.dart'; import '../../../providers/ui/transaction_filter_provider.dart'; @@ -30,6 +30,7 @@ import '../../../services/event_bus/events/global/wallet_sync_status_changed_eve import '../../../services/event_bus/global_event_bus.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; +import '../../../utilities/if_not_already.dart'; import '../../../utilities/show_loading.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; @@ -38,11 +39,11 @@ import '../../../wallets/crypto_currency/intermediate/frost_currency.dart'; import '../../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; import '../../../wallets/wallet/impl/epiccash_wallet.dart'; -import '../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; -import '../../../wallets/wallet/intermediate/lib_salvium_wallet.dart'; import '../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../widgets/background.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -54,10 +55,12 @@ import '../../home_view/home_view.dart'; import '../../pinpad_views/lock_screen_view.dart'; import '../global_settings_view/syncing_preferences_views/syncing_preferences_view.dart'; import '../sub_widgets/settings_list_button.dart'; +import 'epicbox_settings/manage_epicbox_view.dart'; import 'frost_ms/frost_ms_options_view.dart'; import 'wallet_backup_views/wallet_backup_view.dart'; import 'wallet_network_settings_view/wallet_network_settings_view.dart'; import 'wallet_settings_wallet_settings/change_representative_view.dart'; +import 'wallet_settings_wallet_settings/spark_view_key_view.dart'; import 'wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart'; import 'wallet_settings_wallet_settings/xpub_view.dart'; @@ -89,6 +92,7 @@ class _WalletSettingsViewState extends ConsumerState { late final CryptoCurrency coin; late String xpub; late final bool xPubEnabled; + late final bool sparkViewKeyEnabled; late final EventBus eventBus; @@ -98,6 +102,162 @@ class _WalletSettingsViewState extends ConsumerState { late StreamSubscription _syncStatusSubscription; // late StreamSubscription _nodeStatusSubscription; + late final VoidCallback _walletBackupPressed; + late final VoidCallback _walletXPubPressed; + late final VoidCallback _walletSparkViewKeyPressed; + + Future __walletSparkViewKeyPressedHelper() async { + final wallet = ref.read(pWallets).getWallet(walletId) as SparkInterface; + final sparkViewKeyHex = wallet.sparkViewKey!; + + if (mounted) { + await Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => LockscreenView( + routeOnSuccessArguments: (walletId, sparkViewKeyHex), + showBackButton: true, + routeOnSuccess: SparkViewKeyView.routeName, + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: "Authenticate to view spark view key", + biometricsAuthenticationTitle: "View spark view key", + ), + settings: const RouteSettings( + name: "/viewSparkViewKeyDataLockscreen", + ), + ), + ); + } + } + + Future _walletXPubHelper() async { + final xpubData = await showLoading( + delay: const Duration(milliseconds: 800), + whileFuture: + (ref.read(pWallets).getWallet(walletId) as ExtendedKeysInterface) + .getXPubs(), + context: context, + message: "Loading xpubs", + rootNavigator: Util.isDesktop, + ); + + if (mounted) { + await Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => LockscreenView( + routeOnSuccessArguments: (walletId, xpubData!), + showBackButton: true, + routeOnSuccess: XPubView.routeName, + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: "Authenticate to view xpub data", + biometricsAuthenticationTitle: "View xpub data", + ), + settings: const RouteSettings(name: "/viewXPubDataLockscreen"), + ), + ); + } + } + + Future _walletBackupPressedHelper() async { + // TODO: [prio=med] take wallets that don't have a mnemonic into account + + final wallet = ref.read(pWallets).getWallet(widget.walletId); + + List? mnemonic; + ({ + String myName, + String config, + String keys, + ({String config, String keys})? prevGen, + })? + frostWalletData; + if (wallet is BitcoinFrostWallet) { + final futures = [ + wallet.getSerializedKeys(), + wallet.getMultisigConfig(), + wallet.getSerializedKeysPrevGen(), + wallet.getMultisigConfigPrevGen(), + ]; + + final results = await Future.wait(futures); + + if (results.length == 4) { + frostWalletData = ( + myName: wallet.frostInfo.myName, + config: results[1]!, + keys: results[0]!, + prevGen: results[2] == null || results[3] == null + ? null + : (config: results[3]!, keys: results[2]!), + ); + } + } else { + if (wallet is MnemonicInterface) { + if (wallet is ViewOnlyOptionInterface && + (wallet as ViewOnlyOptionInterface).isViewOnly) { + // TODO: is something needed here? + } else { + mnemonic = await wallet.getMnemonicAsWords(); + } + } + } + + KeyDataInterface? keyData; + if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { + keyData = await wallet.getViewOnlyWalletData(); + } else if (wallet is ExtendedKeysInterface) { + keyData = await wallet.getXPrivs(); + } else if (wallet is CryptonoteWallet) { + keyData = await wallet.getKeys(); + } + + if (mounted) { + if (keyData != null && + wallet is ViewOnlyOptionInterface && + wallet.isViewOnly) { + await Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => LockscreenView( + routeOnSuccessArguments: (walletId: walletId, keyData: keyData), + showBackButton: true, + routeOnSuccess: MobileKeyDataView.routeName, + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: "Authenticate to view recovery data", + biometricsAuthenticationTitle: "View recovery data", + ), + settings: const RouteSettings(name: "/viewRecoveryDataLockscreen"), + ), + ); + } else { + await Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => LockscreenView( + routeOnSuccessArguments: ( + walletId: walletId, + mnemonic: mnemonic ?? [], + frostWalletData: frostWalletData, + keyData: keyData, + ), + showBackButton: true, + routeOnSuccess: WalletBackupView.routeName, + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: "Authenticate to view recovery phrase", + biometricsAuthenticationTitle: "View recovery phrase", + ), + settings: const RouteSettings(name: "/viewRecoverPhraseLockscreen"), + ), + ); + } + } + } + @override void initState() { walletId = widget.walletId; @@ -106,8 +266,10 @@ class _WalletSettingsViewState extends ConsumerState { final wallet = ref.read(pWallets).getWallet(walletId); if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { xPubEnabled = false; + sparkViewKeyEnabled = false; } else { xPubEnabled = wallet is ExtendedKeysInterface; + sparkViewKeyEnabled = wallet is SparkInterface; } xpub = ""; @@ -115,8 +277,9 @@ class _WalletSettingsViewState extends ConsumerState { _currentSyncStatus = widget.initialSyncStatus; // _currentNodeStatus = widget.initialNodeStatus; - eventBus = - widget.eventBus != null ? widget.eventBus! : GlobalEventBus.instance; + eventBus = widget.eventBus != null + ? widget.eventBus! + : GlobalEventBus.instance; _syncStatusSubscription = eventBus .on() @@ -139,6 +302,14 @@ class _WalletSettingsViewState extends ConsumerState { } }); + _walletBackupPressed = IfNotAlreadyAsync( + _walletBackupPressedHelper, + ).execute; + _walletXPubPressed = IfNotAlreadyAsync(_walletXPubHelper).execute; + _walletSparkViewKeyPressed = IfNotAlreadyAsync( + __walletSparkViewKeyPressedHelper, + ).execute; + // _nodeStatusSubscription = // eventBus.on().listen( // (event) async { @@ -182,6 +353,11 @@ class _WalletSettingsViewState extends ConsumerState { canBackup = false; } + final shouldShowClearSparkCache = + wallet is SparkInterface && + (!wallet.isViewOnly || + (wallet.isViewOnly && wallet.viewOnlyType == .spark)); + return Background( child: Scaffold( backgroundColor: Theme.of(context).extension()!.background, @@ -193,423 +369,264 @@ class _WalletSettingsViewState extends ConsumerState { ), title: Text("Settings", style: STextStyles.navBarTitle(context)), ), - body: SafeArea( - child: LayoutBuilder( - builder: (builderContext, constraints) { - return Padding( - padding: const EdgeInsets.only(left: 12, top: 12, right: 12), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 24, - ), - child: IntrinsicHeight( - child: Padding( - padding: const EdgeInsets.all(4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - RoundedWhiteContainer( - padding: const EdgeInsets.all(4), - child: Column( - children: [ - SettingsListButton( - iconAssetName: Assets.svg.addressBook, - iconSize: 16, - title: "Address book", - onPressed: () { - Navigator.of(context).pushNamed( - AddressBookView.routeName, - arguments: coin, - ); - }, - ), - if (coin is FrostCurrency) - const SizedBox(height: 8), - if (coin is FrostCurrency) - SettingsListButton( - iconAssetName: Assets.svg.addressBook2, - iconSize: 16, - title: "FROST Multisig settings", - onPressed: () { - Navigator.of(context).pushNamed( - FrostMSWalletOptionsView.routeName, - arguments: walletId, - ); - }, - ), - const SizedBox(height: 8), - SettingsListButton( - iconAssetName: Assets.svg.node, - iconSize: 16, - title: "Network", - onPressed: () { - Navigator.of(context).pushNamed( - WalletNetworkSettingsView.routeName, - arguments: Tuple3( - walletId, - _currentSyncStatus, - widget.initialNodeStatus, - ), - ); - }, - ), - if (canBackup) const SizedBox(height: 8), - if (canBackup) - Consumer( - builder: (_, ref, __) { - return SettingsListButton( - iconAssetName: Assets.svg.lock, - iconSize: 16, - title: "Wallet backup", - onPressed: () async { - // TODO: [prio=med] take wallets that don't have a mnemonic into account - - List? mnemonic; - ({ - String myName, - String config, - String keys, - ({String config, String keys})? - prevGen, - })? - frostWalletData; - if (wallet is BitcoinFrostWallet) { - final futures = [ - wallet.getSerializedKeys(), - wallet.getMultisigConfig(), - wallet - .getSerializedKeysPrevGen(), - wallet - .getMultisigConfigPrevGen(), - ]; - - final results = await Future.wait( - futures, - ); - - if (results.length == 4) { - frostWalletData = ( - myName: - wallet.frostInfo.myName, - config: results[1]!, - keys: results[0]!, - prevGen: - results[2] == null || - results[3] == null - ? null - : ( - config: results[3]!, - keys: results[2]!, - ), - ); - } - } else { - if (wallet is MnemonicInterface) { - if (wallet - is ViewOnlyOptionInterface && - (wallet as ViewOnlyOptionInterface) - .isViewOnly) { - // TODO: is something needed here? - } else { - mnemonic = - await wallet - .getMnemonicAsWords(); - } - } - } - - KeyDataInterface? keyData; - if (wallet - is ViewOnlyOptionInterface && - wallet.isViewOnly) { - keyData = - await wallet - .getViewOnlyWalletData(); - } else if (wallet - is ExtendedKeysInterface) { - keyData = await wallet.getXPrivs(); - } else if (wallet - is LibMoneroWallet) { - keyData = await wallet.getKeys(); - } else if (wallet - is LibSalviumWallet) { - keyData = await wallet.getKeys(); - } - - if (context.mounted) { - if (keyData != null && - wallet - is ViewOnlyOptionInterface && - wallet.isViewOnly) { - await Navigator.push( - context, - RouteGenerator.getRoute( - shouldUseMaterialRoute: - RouteGenerator - .useMaterialPageRoute, - builder: - (_) => LockscreenView( - routeOnSuccessArguments: - ( - walletId: - walletId, - keyData: - keyData, - ), - showBackButton: true, - routeOnSuccess: - MobileKeyDataView - .routeName, - biometricsCancelButtonString: - "CANCEL", - biometricsLocalizedReason: - "Authenticate to view recovery data", - biometricsAuthenticationTitle: - "View recovery data", - ), - settings: const RouteSettings( - name: - "/viewRecoveryDataLockscreen", - ), - ), - ); - } else { - await Navigator.push( - context, - RouteGenerator.getRoute( - shouldUseMaterialRoute: - RouteGenerator - .useMaterialPageRoute, - builder: - (_) => LockscreenView( - routeOnSuccessArguments: ( - walletId: walletId, - mnemonic: - mnemonic ?? [], - frostWalletData: - frostWalletData, - keyData: keyData, - ), - showBackButton: true, - routeOnSuccess: - WalletBackupView - .routeName, - biometricsCancelButtonString: - "CANCEL", - biometricsLocalizedReason: - "Authenticate to view recovery phrase", - biometricsAuthenticationTitle: - "View recovery phrase", - ), - settings: const RouteSettings( - name: - "/viewRecoverPhraseLockscreen", - ), - ), - ); - } - } - }, - ); - }, - ), - const SizedBox(height: 8), - SettingsListButton( - iconAssetName: Assets.svg.downloadFolder, - title: "Wallet settings", - iconSize: 16, - onPressed: () { - Navigator.of(context).pushNamed( - WalletSettingsWalletSettingsView - .routeName, - arguments: walletId, - ); - }, - ), - const SizedBox(height: 8), - SettingsListButton( - iconAssetName: Assets.svg.arrowRotate, - title: "Syncing preferences", - onPressed: () { - Navigator.of(context).pushNamed( - SyncingPreferencesView.routeName, - ); - }, - ), - if (xPubEnabled) const SizedBox(height: 8), - if (xPubEnabled) - Consumer( - builder: (_, ref, __) { - return SettingsListButton( - iconAssetName: Assets.svg.eye, - title: "Wallet xPub", - onPressed: () async { - final xpubData = await showLoading( - delay: const Duration( - milliseconds: 800, - ), - whileFuture: - (ref - .read(pWallets) - .getWallet( - walletId, - ) - as ExtendedKeysInterface) - .getXPubs(), - context: context, - message: "Loading xpubs", - rootNavigator: Util.isDesktop, - ); - if (context.mounted) { - await Navigator.of( - context, - ).pushNamed( - XPubView.routeName, - arguments: ( - widget.walletId, - xpubData, - ), - ); - } - }, - ); - }, - ), - if (coin is Firo) const SizedBox(height: 8), - if (coin is Firo) - Consumer( - builder: (_, ref, __) { - return SettingsListButton( - iconAssetName: Assets.svg.eye, - title: "Clear electrumx cache", - onPressed: () async { - String? result; - await showDialog( - useSafeArea: false, - barrierDismissible: true, - context: context, - builder: - (_) => StackOkDialog( - title: - "Are you sure you want to clear " - "${coin.prettyName} electrumx cache?", - onOkPressed: (value) { - result = value; - }, - leftButton: SecondaryButton( - label: "Cancel", - onPressed: () { - Navigator.of( - context, - ).pop(); - }, - ), - ), - ); - - if (result == "OK" && - context.mounted) { - await showLoading( - whileFuture: Future.wait([ - Future.delayed( - const Duration( - milliseconds: 1500, - ), - ), - DB.instance - .clearSharedTransactionCache( - currency: coin, - ), - if (coin is Firo) - FiroCacheCoordinator.clearSharedCache( - coin.network, - ), - ]), - context: context, - message: "Clearing cache...", - ); - } - }, - ); - }, - ), - if (coin is NanoCurrency) - const SizedBox(height: 8), - if (coin is NanoCurrency) - Consumer( - builder: (_, ref, __) { - return SettingsListButton( - iconAssetName: Assets.svg.eye, - title: "Change representative", - onPressed: () { - Navigator.of(context).pushNamed( - ChangeRepresentativeView - .routeName, - arguments: widget.walletId, - ); - }, - ); - }, - ), - // const SizedBox( - // height: 8, - // ), - // SettingsListButton( - // iconAssetName: Assets.svg.ellipsis, - // title: "Debug Info", - // onPressed: () { - // Navigator.of(context) - // .pushNamed(DebugView.routeName); - // }, - // ), - ], - ), + body: _WalletSettingsViewBody( + children: [ + SettingsListButton( + iconAssetName: Assets.svg.addressBook, + iconSize: 16, + title: "Address book", + onPressed: () { + Navigator.of( + context, + ).pushNamed(AddressBookView.routeName, arguments: coin); + }, + ), + if (coin is FrostCurrency) const SizedBox(height: 8), + if (coin is FrostCurrency) + SettingsListButton( + iconAssetName: Assets.svg.addressBook2, + iconSize: 16, + title: "FROST Multisig settings", + onPressed: () { + Navigator.of(context).pushNamed( + FrostMSWalletOptionsView.routeName, + arguments: walletId, + ); + }, + ), + const SizedBox(height: 8), + SettingsListButton( + iconAssetName: Assets.svg.node, + iconSize: 16, + title: "Network", + onPressed: () { + Navigator.of(context).pushNamed( + WalletNetworkSettingsView.routeName, + arguments: Tuple3( + walletId, + _currentSyncStatus, + widget.initialNodeStatus, + ), + ); + }, + ), + if (wallet is EpiccashWallet) const SizedBox(height: 8), + if (wallet is EpiccashWallet) + SettingsListButton( + iconAssetName: Assets.svg.node, + iconSize: 16, + title: "Epicbox Servers", + onPressed: () { + Navigator.of(context).pushNamed( + ManageEpicboxView.routeName, + arguments: walletId, + ); + }, + ), + if (canBackup) const SizedBox(height: 8), + if (canBackup) + Consumer( + builder: (_, ref, __) { + return SettingsListButton( + iconAssetName: Assets.svg.lock, + iconSize: 16, + title: "Wallet backup", + onPressed: _walletBackupPressed, + ); + }, + ), + const SizedBox(height: 8), + SettingsListButton( + iconAssetName: Assets.svg.downloadFolder, + title: "Wallet settings", + iconSize: 16, + onPressed: () { + Navigator.of(context).pushNamed( + WalletSettingsWalletSettingsView.routeName, + arguments: walletId, + ); + }, + ), + const SizedBox(height: 8), + SettingsListButton( + iconAssetName: Assets.svg.arrowRotate, + title: "Syncing preferences", + onPressed: () { + Navigator.of( + context, + ).pushNamed(SyncingPreferencesView.routeName); + }, + ), + if (xPubEnabled) const SizedBox(height: 8), + if (xPubEnabled) + Consumer( + builder: (_, ref, __) { + return SettingsListButton( + iconAssetName: Assets.svg.eye, + title: "Wallet xPub", + onPressed: _walletXPubPressed, + ); + }, + ), + if (sparkViewKeyEnabled) const SizedBox(height: 8), + if (sparkViewKeyEnabled) + Consumer( + builder: (_, ref, __) { + return SettingsListButton( + iconAssetName: Assets.svg.eye, + title: "Spark view key", + onPressed: _walletSparkViewKeyPressed, + ); + }, + ), + if (shouldShowClearSparkCache) const SizedBox(height: 8), + if (shouldShowClearSparkCache) + Consumer( + builder: (_, ref, __) { + return SettingsListButton( + iconAssetName: Assets.svg.eye, + title: "Clear electrumx cache", + onPressed: () async { + String? result; + await showDialog( + useSafeArea: false, + barrierDismissible: true, + context: context, + builder: (_) => StackOkDialog( + title: + "Are you sure you want to clear " + "${coin.prettyName} electrumx cache?", + onOkPressed: (value) { + result = value; + }, + leftButton: SecondaryButton( + label: "Cancel", + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + ); + + if (result == "OK" && context.mounted) { + await showLoading( + whileFuture: Future.wait([ + Future.delayed(const Duration(milliseconds: 1500)), + DB.instance.clearSharedTransactionCache( + currency: coin, ), - const SizedBox(height: 12), - const Spacer(), - Consumer( - builder: (_, ref, __) { - return TextButton( - onPressed: () { - // TODO: [prio=med] needs more thought if this is still required - // ref - // .read(pWallets) - // .getWallet(walletId) - // .isActiveWallet = false; - ref + if (coin is Firo) + FiroCacheCoordinator.clearSharedCache( + coin.network, + ), + ]), + context: context, + message: "Clearing cache...", + ); + } + }, + ); + }, + ), + if (coin is NanoCurrency) const SizedBox(height: 8), + if (coin is NanoCurrency) + Consumer( + builder: (_, ref, __) { + return SettingsListButton( + iconAssetName: Assets.svg.eye, + title: "Change representative", + onPressed: () { + Navigator.of(context).pushNamed( + ChangeRepresentativeView.routeName, + arguments: widget.walletId, + ); + }, + ); + }, + ), + // const SizedBox( + // height: 8, + // ), + // SettingsListButton( + // iconAssetName: Assets.svg.ellipsis, + // title: "Debug Info", + // onPressed: () { + // Navigator.of(context) + // .pushNamed(DebugView.routeName); + // }, + // ), + ], + ), + ), + ); + } +} + +class _WalletSettingsViewBody extends StatelessWidget { + const _WalletSettingsViewBody({super.key, required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return Padding( + padding: const EdgeInsets.only(left: 12, top: 12, right: 12), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 24, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RoundedWhiteContainer( + padding: const EdgeInsets.all(4), + child: Column(children: children), + ), + + const SizedBox(height: 12), + const Spacer(), + Consumer( + builder: (_, ref, __) { + return TextButton( + onPressed: () { + ref .read(transactionFilterProvider.state) - .state = null; - - Navigator.of(context).popUntil( - ModalRoute.withName(HomeView.routeName), - ); - }, - style: Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context), - child: Text( - "Log out", - style: STextStyles.button(context).copyWith( - color: - Theme.of(context) - .extension()! - .accentColorDark, - ), - ), + .state = + null; + + Navigator.of(context).popUntil( + ModalRoute.withName(HomeView.routeName), ); }, - ), - ], + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Log out", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ); + }, ), - ), + ], ), ), ), - ); - }, - ), - ), + ), + ), + ); + }, ), ); } @@ -666,8 +683,9 @@ class _EpiBoxInfoFormState extends ConsumerState { enableSuggestions: Util.isDesktop ? false : true, controller: portController, decoration: const InputDecoration(hintText: "Port"), - keyboardType: - Util.isDesktop ? null : const TextInputType.numberWithOptions(), + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions(), ), const SizedBox(height: 8), TextButton( @@ -677,7 +695,7 @@ class _EpiBoxInfoFormState extends ConsumerState { hostController.text, int.parse(portController.text), ); - if (mounted) { + if (context.mounted) { await showFloatingFlushBar( context: context, message: "Epicbox info saved!", @@ -686,18 +704,21 @@ class _EpiBoxInfoFormState extends ConsumerState { } unawaited(wallet.refresh()); } catch (e) { - await showFloatingFlushBar( - context: context, - message: "Failed to save epicbox info: $e", - type: FlushBarType.warning, - ); + if (context.mounted) { + await showFloatingFlushBar( + context: context, + message: "Failed to save epicbox info: $e", + type: FlushBarType.warning, + ); + } } }, child: Text( "Save", style: STextStyles.button(context).copyWith( - color: - Theme.of(context).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -708,10 +729,7 @@ class _EpiBoxInfoFormState extends ConsumerState { } class MwcMqsInfoForm extends ConsumerStatefulWidget { - const MwcMqsInfoForm({ - super.key, - required this.walletId, - }); + const MwcMqsInfoForm({super.key, required this.walletId}); final String walletId; @@ -756,20 +774,17 @@ class _MwcmqsInfoFormState extends ConsumerState { controller: hostController, decoration: const InputDecoration(hintText: "Host"), ), - const SizedBox( - height: 8, - ), + const SizedBox(height: 8), TextField( autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, controller: portController, decoration: const InputDecoration(hintText: "Port"), - keyboardType: - Util.isDesktop ? null : const TextInputType.numberWithOptions(), - ), - const SizedBox( - height: 8, + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions(), ), + const SizedBox(height: 8), TextButton( onPressed: () async { try { @@ -777,7 +792,7 @@ class _MwcmqsInfoFormState extends ConsumerState { hostController.text, int.parse(portController.text), ); - if (mounted) { + if (context.mounted) { await showFloatingFlushBar( context: context, message: "Mwcmqs info saved!", @@ -786,18 +801,21 @@ class _MwcmqsInfoFormState extends ConsumerState { } unawaited(wallet.refresh()); } catch (e) { - await showFloatingFlushBar( - context: context, - message: "Failed to save mwcmqs info: $e", - type: FlushBarType.warning, - ); + if (context.mounted) { + await showFloatingFlushBar( + context: context, + message: "Failed to save mwcmqs info: $e", + type: FlushBarType.warning, + ); + } } }, child: Text( "Save", style: STextStyles.button(context).copyWith( - color: - Theme.of(context).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart index f6f6b156c1..05d51ac80e 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart @@ -11,7 +11,9 @@ import '../../../../utilities/constants.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; +import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; +import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; +import '../../../../wallets/wallet/supporting/epiccash_wallet_info_extension.dart'; import '../../../../widgets/background.dart'; import '../../../../widgets/conditional_parent.dart'; import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -21,7 +23,6 @@ import '../../../../widgets/desktop/primary_button.dart'; import '../../../../widgets/icon_widgets/x_icon.dart'; import '../../../../widgets/stack_text_field.dart'; import '../../../../widgets/textfield_icon_button.dart'; -import '../../../../wl_gen/interfaces/cs_monero_interface.dart'; class EditRefreshHeightView extends ConsumerStatefulWidget { const EditRefreshHeightView({super.key, required this.walletId}); @@ -49,16 +50,21 @@ class _EditRefreshHeightViewState extends ConsumerState { try { final newHeight = int.tryParse(_controller.text); if (newHeight != null && newHeight >= 0) { - await ref - .read(pWalletInfo(widget.walletId)) - .updateRestoreHeight( - newRestoreHeight: newHeight, - isar: ref.read(mainDBProvider).isar, - ); - final wallet = - ref.read(pWallets).getWallet(widget.walletId) as LibMoneroWallet?; - if (wallet?.wallet != null) { - csMonero.setRefreshFromBlockHeight(wallet!.wallet!, newHeight); + final wallet = ref.read(pWallets).getWallet(widget.walletId); + + if (wallet is EpiccashWallet) { + await wallet.updateRestoreHeight(newHeight); + } else { + await ref + .read(pWalletInfo(widget.walletId)) + .updateRestoreHeight( + newRestoreHeight: newHeight, + isar: ref.read(mainDBProvider).isar, + ); + } + + if (wallet is CryptonoteWallet && wallet.wallet != null) { + wallet.setRefreshFromBlockHeight(newHeight); } } else { errMessage = "Invalid height: ${_controller.text}"; @@ -96,12 +102,21 @@ class _EditRefreshHeightViewState extends ConsumerState { void initState() { super.initState(); _controller = TextEditingController(); - final wallet = - ref.read(pWallets).getWallet(widget.walletId) as LibMoneroWallet?; - if (wallet?.wallet != null) { - _controller.text = csMonero - .getRefreshFromBlockHeight(wallet!.wallet!) + final wallet = ref.read(pWallets).getWallet(widget.walletId); + if (wallet is EpiccashWallet) { + _controller.text = ref + .read(pWalletInfo(widget.walletId)) + .epicData! + .restoreHeight .toString(); + } else if (wallet is CryptonoteWallet && wallet.wallet != null) { + wallet.getRefreshFromBlockHeight().then((height) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _controller.text = height.toString(); + } + }); + }); } else { _controller.text = ref .read(pWalletInfo(widget.walletId)) diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart new file mode 100644 index 0000000000..8364d83ea2 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart @@ -0,0 +1,189 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../notifications/show_flush_bar.dart'; +import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/assets.dart'; +import '../../../../utilities/clipboard_interface.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../utilities/util.dart'; +import '../../../../widgets/background.dart'; +import '../../../../widgets/conditional_parent.dart'; +import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../../widgets/desktop/desktop_dialog.dart'; +import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../../widgets/desktop/primary_button.dart'; +import '../../../../widgets/qr.dart'; +import '../../../../widgets/rounded_white_container.dart'; + +class SparkViewKeyView extends ConsumerStatefulWidget { + const SparkViewKeyView({ + super.key, + required this.walletId, + required this.sparkViewKeyHex, + this.clipboardInterface = const ClipboardWrapper(), + this.showDesktopDialogTitle = true, + }); + + final String walletId; + final String sparkViewKeyHex; + final ClipboardInterface clipboardInterface; + final bool showDesktopDialogTitle; + + static const String routeName = "/spark_view_key"; + + @override + ConsumerState createState() => _SparkViewKeyViewState(); +} + +class _SparkViewKeyViewState extends ConsumerState { + Future _copy() async { + await widget.clipboardInterface.setData( + ClipboardData(text: widget.sparkViewKeyHex), + ); + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + iconAsset: Assets.svg.copy, + context: context, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final bool isDesktop = Util.isDesktop; + + return ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Spark View Key", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 12, left: 16, right: 16), + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + children: [ + Expanded(child: child), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + child: ConditionalParent( + condition: isDesktop && widget.showDesktopDialogTitle, + builder: (child) => DesktopDialog( + maxWidth: 600, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Spark View Key", + style: STextStyles.desktopH2(context), + ), + ), + DesktopDialogCloseButton( + onPressedOverride: Navigator.of( + context, + rootNavigator: true, + ).pop, + ), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.fromLTRB(32, 0, 32, 32), + child: SingleChildScrollView(child: child), + ), + ), + ], + ), + ), + child: Column( + mainAxisSize: Util.isDesktop ? MainAxisSize.min : MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(height: Util.isDesktop ? 12 : 16), + QR( + data: widget.sparkViewKeyHex, + size: Util.isDesktop + ? 256 + : MediaQuery.of(context).size.width / 1.5, + ), + SizedBox(height: Util.isDesktop ? 12 : 16), + RoundedWhiteContainer( + borderColor: Util.isDesktop + ? Theme.of( + context, + ).extension()!.textFieldDefaultBG + : null, + child: SelectableText( + widget.sparkViewKeyHex, + style: STextStyles.w500_14(context), + ), + ), + SizedBox(height: Util.isDesktop ? 12 : 16), + if (!Util.isDesktop) const Spacer(), + Row( + children: [ + if (Util.isDesktop) const Spacer(), + if (Util.isDesktop) const SizedBox(width: 16), + Expanded( + child: PrimaryButton(label: "Copy", onPressed: _copy), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart index 7792fb2e13..6a266b247a 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart @@ -22,8 +22,9 @@ import '../../../../utilities/logger.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../wallets/isar/models/wallet_info.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; -import '../../../../wallets/wallet/intermediate/lib_salvium_wallet.dart'; +import '../../../../wallets/wallet/impl/bitcoin_wallet.dart'; +import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; +import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/multi_address_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/rbf_interface.dart'; @@ -88,6 +89,37 @@ class _WalletSettingsWalletSettingsViewState } } + bool _switchLegacyToggledLock = false; // Mutex. + Future _switchLegacyToggled() async { + if (_switchLegacyToggledLock) { + return; + } + _switchLegacyToggledLock = true; // Lock mutex. + + try { + // Toggle enableLegacyAddresses in wallet info. + await ref + .read(pWalletInfo(widget.walletId)) + .updateOtherData( + newEntries: { + WalletInfoKeys.enableLegacyAddresses: !ref + .read(pWalletInfo(widget.walletId)) + .isLegacyAddressesEnabled, + }, + isar: ref.read(mainDBProvider).isar, + ); + } catch (e, s) { + Logging.instance.f( + "Failed to update enableLegacyAddresses for wallet", + error: e, + stackTrace: s, + ); + } finally { + // ensure _switchLegacyToggledLock is set to false no matter what + _switchLegacyToggledLock = false; + } + } + bool _switchReuseAddressToggledLock = false; // Mutex. Future _switchReuseAddressToggled() async { if (_switchReuseAddressToggledLock) { @@ -103,7 +135,9 @@ class _WalletSettingsWalletSettingsViewState return StackDialog( title: "Warning!", message: - "Reusing addresses reduces your privacy and security. Are you sure you want to reuse addresses by default?", + "Reusing addresses reduces your privacy and " + "security. Are you sure you want to reuse " + "addresses by default?", leftButton: TextButton( style: Theme.of(context) .extension()! @@ -156,8 +190,9 @@ class _WalletSettingsWalletSettingsViewState return StackDialog( title: "Notice", message: - "Activating MWEB requires synchronizing on-chain MWEB related data. " - "This currently requires about 800 MB of storage.", + "Activating MWEB requires synchronizing on-chain MWEB " + "related data. This currently requires about " + "800 MB of storage.", leftButton: SecondaryButton( onPressed: () { Navigator.of(context).pop(false); @@ -261,7 +296,6 @@ class _WalletSettingsWalletSettingsViewState RoundedWhiteContainer( padding: const EdgeInsets.all(0), child: RawMaterialButton( - // splashColor: Theme.of(context).extension()!.highlight, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -331,7 +365,6 @@ class _WalletSettingsWalletSettingsViewState RoundedWhiteContainer( padding: const EdgeInsets.all(0), child: RawMaterialButton( - // splashColor: Theme.of(context).extension()!.highlight, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( @@ -381,7 +414,6 @@ class _WalletSettingsWalletSettingsViewState RoundedWhiteContainer( padding: const EdgeInsets.all(0), child: RawMaterialButton( - // splashColor: Theme.of(context).extension()!.highlight, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( @@ -476,6 +508,56 @@ class _WalletSettingsWalletSettingsViewState ), ), ), + if (wallet is BitcoinWallet) const SizedBox(height: 8), + if (wallet is BitcoinWallet) + RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + child: RawMaterialButton( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: _switchLegacyToggled, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 20, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Enable legacy addresses", + style: STextStyles.titleBold12(context), + textAlign: TextAlign.left, + ), + SizedBox( + height: 20, + width: 40, + child: IgnorePointer( + child: DraggableSwitch( + value: + ref.watch( + pWalletInfo( + widget.walletId, + ).select( + (value) => value.otherData, + ), + )[WalletInfoKeys + .enableLegacyAddresses] + as bool? ?? + false, + onChanged: (_) => (), + ), + ), + ), + ], + ), + ), + ), + ), if (wallet is SparkInterface && !wallet.isViewOnly) const SizedBox(height: 8), if (wallet is SparkInterface && !wallet.isViewOnly) @@ -510,9 +592,9 @@ class _WalletSettingsWalletSettingsViewState ), ), ), - if (wallet is LibMoneroWallet || wallet is LibSalviumWallet) + if (wallet is CryptonoteWallet || wallet is EpiccashWallet) const SizedBox(height: 8), - if (wallet is LibMoneroWallet || wallet is LibSalviumWallet) + if (wallet is CryptonoteWallet || wallet is EpiccashWallet) RoundedWhiteContainer( padding: const EdgeInsets.all(0), child: RawMaterialButton( @@ -548,7 +630,6 @@ class _WalletSettingsWalletSettingsViewState RoundedWhiteContainer( padding: const EdgeInsets.all(0), child: RawMaterialButton( - // splashColor: Theme.of(context).extension()!.highlight, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -560,65 +641,60 @@ class _WalletSettingsWalletSettingsViewState showDialog( barrierDismissible: true, context: context, - builder: - (_) => StackDialog( - title: - "Do you want to delete ${ref.read(pWalletName(widget.walletId))}?", - leftButton: TextButton( - style: Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context), - onPressed: () { - Navigator.pop(context); - }, - child: Text( - "Cancel", - style: STextStyles.button(context).copyWith( - color: - Theme.of(context) - .extension()! - .accentColorDark, - ), - ), + builder: (_) => StackDialog( + title: + "Do you want to delete " + "${ref.read(pWalletName(widget.walletId))}?", + leftButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + onPressed: () { + Navigator.pop(context); + }, + child: Text( + "Cancel", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, ), - rightButton: TextButton( - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - onPressed: () { - Navigator.pop(context); - Navigator.push( - context, - RouteGenerator.getRoute( - shouldUseMaterialRoute: - RouteGenerator.useMaterialPageRoute, - builder: - (_) => LockscreenView( - routeOnSuccessArguments: - widget.walletId, - showBackButton: true, - routeOnSuccess: - DeleteWalletWarningView - .routeName, - biometricsCancelButtonString: - "CANCEL", - biometricsLocalizedReason: - "Authenticate to delete wallet", - biometricsAuthenticationTitle: - "Delete wallet", - ), - settings: const RouteSettings( - name: "/deleteWalletLockscreen", - ), - ), - ); - }, - child: Text( - "Delete", - style: STextStyles.button(context), + ), + ), + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + onPressed: () { + Navigator.pop(context); + Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: + RouteGenerator.useMaterialPageRoute, + builder: (_) => LockscreenView( + routeOnSuccessArguments: widget.walletId, + showBackButton: true, + routeOnSuccess: + DeleteWalletWarningView.routeName, + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: + "Authenticate to delete wallet", + biometricsAuthenticationTitle: + "Delete wallet", + ), + settings: const RouteSettings( + name: "/deleteWalletLockscreen", + ), ), - ), + ); + }, + child: Text( + "Delete", + style: STextStyles.button(context), ), + ), + ), ); }, child: Padding( diff --git a/lib/pages/shopinbit/shopinbit_car_fee_view.dart b/lib/pages/shopinbit/shopinbit_car_fee_view.dart new file mode 100644 index 0000000000..d2a9475684 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart @@ -0,0 +1,451 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/shopinbit/shopinbit_request_draft.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../services/shopinbit/shopinbit_service.dart'; +import '../../services/shopinbit/src/models/address.dart'; +import '../../services/shopinbit/src/models/car_research.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/detail_item.dart'; +import '../../widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart'; +import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../../widgets/textfields/adaptive_text_field.dart'; +import '../home_view/home_view.dart'; +import 'shopinbit_car_research_payment_view.dart'; +import 'shopinbit_step_2.dart'; + +class ShopInBitCarFeeView extends ConsumerStatefulWidget { + const ShopInBitCarFeeView({super.key, required this.draft}); + + static const String routeName = "/shopInBitCarFee"; + + final ShopinbitRequestDraft draft; + + @override + ConsumerState createState() => + _ShopInBitCarFeeViewState(); +} + +class _ShopInBitCarFeeViewState extends ConsumerState { + late final TextEditingController _billingFirstNameController; + late final TextEditingController _billingLastNameController; + late final TextEditingController _billingStreetController; + late final TextEditingController _billingCityController; + late final TextEditingController _billingPostalCodeController; + + String _displayedFee = "223.00 EUR"; + bool _submitting = false; + + bool _canContinue = false; + + void _validate() { + final valid = + _billingFirstNameController.text.trim().isNotEmpty && + _billingLastNameController.text.trim().isNotEmpty && + _billingStreetController.text.trim().isNotEmpty && + _billingCityController.text.trim().isNotEmpty && + _billingPostalCodeController.text.trim().isNotEmpty && + widget.draft.deliveryCountryCode.isNotEmpty; + + if (_canContinue != valid && mounted) { + setState(() { + _canContinue = valid; + }); + } + } + + @override + void initState() { + super.initState(); + + _billingFirstNameController = TextEditingController(); + _billingLastNameController = TextEditingController(); + _billingStreetController = TextEditingController(); + _billingCityController = TextEditingController(); + _billingPostalCodeController = TextEditingController(); + } + + @override + void dispose() { + _billingFirstNameController.dispose(); + _billingLastNameController.dispose(); + _billingStreetController.dispose(); + _billingCityController.dispose(); + _billingPostalCodeController.dispose(); + super.dispose(); + } + + void _popToStep2() { + Navigator.of(context).popUntil((route) { + final name = route.settings.name; + if (name == ShopInBitStep2.routeName) { + return true; + } + if (route.isFirst || name == HomeView.routeName) { + return true; + } + return false; + }); + } + + Future _createInvoice() async { + if (_submitting) return; + setState(() => _submitting = true); + try { + final customerKey = await ref.read(pShopinBitService).ensureCustomerKey(); + + final billing = Address( + firstName: _billingFirstNameController.text.trim(), + lastName: _billingLastNameController.text.trim(), + street: _billingStreetController.text.trim(), + zip: _billingPostalCodeController.text.trim(), + city: _billingCityController.text.trim(), + country: widget.draft.deliveryCountryCode, + state: widget.draft.requiresState ? widget.draft.deliveryState! : null, + ); + + // Cache the car request alongside billing so the backend failsafe can + // create the real car research ticket once the fee is paid. + final request = CarResearchRequest( + customerPseudonym: kShopInBitCustomerPseudonym, + comment: widget.draft.requestDescription, + deliveryCountry: widget.draft.deliveryCountryCode, + deliveryState: billing.state, + ); + + final resp = await ref + .read(pShopinBitService) + .client + .createCarResearchInvoice( + billing: billing, + request: request, + customerKey: customerKey, + ); + + if (resp.hasError || resp.value == null) { + Logging.instance.e( + "Failed to create invoice", + error: resp.exception, + stackTrace: StackTrace.current, + ); + + if (mounted) { + setState(() => _submitting = false); + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to create invoice", + maxWidth: Util.isDesktop ? 500 : null, + message: resp.exception?.message, + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + return; + } + + final invoice = resp.value!; + + // No local persistence: an unfinished fee is recovered server-side via + // `GET /car-research/invoices/current` (see the requests list). + + // Best-effort fee fetch; do not block navigation on fee parse failure. + await _loadFee(invoice, customerKey); + + if (!mounted) return; + + unawaited( + Navigator.of(context).pushNamed( + ShopInBitCarResearchPaymentView.routeName, + arguments: (invoice: invoice, customerKey: customerKey), + ), + ); + } catch (e, s) { + Logging.instance.e("Create invoice failed", error: e, stackTrace: s); + if (mounted) { + setState(() => _submitting = false); + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to create invoice", + maxWidth: Util.isDesktop ? 500 : null, + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } + } + + String? _parseBip21Amount(String uri) { + try { + // Parse amount from payment URI query params. + final qIdx = uri.indexOf('?'); + if (qIdx < 0) return null; + final query = uri.substring(qIdx + 1); + final params = Uri.splitQueryString(query); + return params['amount'] ?? params['tx_amount']; + } catch (_) { + return null; + } + } + + Future _loadFee(CarResearchInvoice invoice, String customerKey) async { + // Still hit status for logging; it has no fee field, so the amount comes + // from the BIP21 payment URIs. + try { + final resp = await ref + .read(pShopinBitService) + .client + .getCarResearchInvoiceStatus( + invoice.btcpayInvoice, + customerKey: customerKey, + ); + if (resp.hasError || resp.value == null) { + Logging.instance.i( + "CarResearch status response (car_fee_view): error " + "${resp.exception?.message}", + ); + } else { + Logging.instance.i( + "CarResearch status response (car_fee_view): ${resp.value}", + ); + } + } catch (e) { + Logging.instance.i( + "CarResearch status response (car_fee_view): threw $e", + ); + } + + // Primary fee source: parse BIP21 `amount` query param from paymentLinks. + Logging.instance.i( + "CarResearch paymentLinks (car_fee_view): ${invoice.paymentLinks}", + ); + try { + for (final entry in invoice.paymentLinks.entries) { + final parsed = _parseBip21Amount(entry.value); + if (parsed != null && parsed.isNotEmpty) { + if (mounted) { + setState( + () => _displayedFee = "$parsed ${entry.key.toUpperCase()}", + ); + } + return; + } + } + } catch (_) { + // Leave placeholder in place. + } + // No parse succeeded: leave the existing "223.00 EUR" business-rule + // placeholder in place rather than showing "--". + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final spacing = SizedBox(height: isDesktop ? 16 : 12); + + final content = Column( + mainAxisSize: .min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Car research fee", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + RoundedWhiteContainer( + borderColor: isDesktop + ? Theme.of(context).extension()!.textFieldDefaultBG + : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Research fee", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + Text( + _displayedFee, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + ], + ), + ), + SizedBox(height: isDesktop ? 24 : 16), + Text( + "Billing address", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + SizedBox(height: isDesktop ? 16 : 12), + AdaptiveTextField( + controller: _billingFirstNameController, + labelText: "First name", + autocorrect: false, + enableSuggestions: false, + onChangedComprehensive: (_) => _validate(), + ), + spacing, + AdaptiveTextField( + controller: _billingLastNameController, + labelText: "Last name", + autocorrect: false, + enableSuggestions: false, + onChangedComprehensive: (_) => _validate(), + ), + spacing, + AdaptiveTextField( + controller: _billingStreetController, + labelText: "Street address", + autocorrect: false, + enableSuggestions: false, + onChangedComprehensive: (_) => _validate(), + ), + spacing, + Row( + children: [ + Expanded( + child: AdaptiveTextField( + controller: _billingCityController, + labelText: "City", + autocorrect: false, + enableSuggestions: false, + onChangedComprehensive: (_) => _validate(), + ), + ), + SizedBox(width: isDesktop ? 16 : 12), + Expanded( + child: AdaptiveTextField( + controller: _billingPostalCodeController, + labelText: "Postal code", + autocorrect: false, + enableSuggestions: false, + onChangedComprehensive: (_) => _validate(), + ), + ), + ], + ), + + spacing, + DetailItem( + title: "Country", + detail: + "${widget.draft.deliveryCountryName} " + "(${widget.draft.deliveryCountryCode})", + disableSelectableText: true, + ), + if (widget.draft.requiresState) spacing, + if (widget.draft.requiresState) + DetailItem(title: "State", detail: widget.draft.deliveryState!), + if (!isDesktop) const Spacer(), + if (isDesktop) const SizedBox(height: 24), + PrimaryButton( + label: "Pay research fee", + enabled: _canContinue && !_submitting, + onPressed: (_canContinue && !_submitting) + ? () => unawaited(_createInvoice()) + : null, + ), + ], + ); + + if (isDesktop) { + return SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "ShopinBit", + style: STextStyles.desktopH3(context), + ), + ), + DesktopDialogCloseButton( + onPressedOverride: () => + NestedNavigatorDialog.of(context).close(), + ), + ], + ), + Flexible( + child: Padding( + padding: const .only( + left: 32, + right: 32, + bottom: 32, + top: 16, + ), + child: content, + ), + ), + ], + ), + ), + ); + } + + return Background( + child: PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, dynamic result) { + if (!didPop) { + _popToStep2(); + } + }, + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton(onPressed: _popToStep2), + title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight(child: content), + ), + ), + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart new file mode 100644 index 0000000000..dcd4553b01 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -0,0 +1,706 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app_config.dart'; +import '../../db/drift/shared_db/shared_database.dart'; +import '../../models/shopinbit/shopinbit_enums.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../services/shopinbit/shopinbit_api.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../home_view/home_view.dart'; +import 'shopinbit_order_created.dart'; +import 'shopinbit_payment_method_list.dart'; +import 'shopinbit_payment_shared.dart'; +import 'shopinbit_tickets_view.dart'; + +enum _PaymentFlowState { idle, polling, finalizing, complete } + +class ShopInBitCarResearchPaymentView extends ConsumerStatefulWidget { + const ShopInBitCarResearchPaymentView({ + super.key, + required this.invoice, + required this.customerKey, + }); + + static const String routeName = "/shopInBitCarResearchPayment"; + + final CarResearchInvoice invoice; + final String customerKey; + + @override + ConsumerState createState() => + _ShopInBitCarResearchPaymentViewState(); +} + +class _ShopInBitCarResearchPaymentViewState + extends ConsumerState { + Timer? _pollTimer; + int _statusRequestId = 0; + + static const Duration _kBasePollInterval = Duration(seconds: 15); + static const Duration _kMaxPollInterval = Duration(seconds: 120); + Duration _pollInterval = _kBasePollInterval; + + CarResearchInvoiceStatus? _status; + _PaymentFlowState _flowState = _PaymentFlowState.idle; + String _statusString = "ready_to_pay"; + String? _additional; + bool _finalized = false; + // The real car ticket id (the customer chat) from the finalized status. + int? _realTicketId; + late String _invoiceId; + Map _paymentLinks = {}; + List _methods = []; + List _addresses = []; + int _selectedMethod = 0; + + String get _currentAddress => + _selectedMethod < _addresses.length ? _addresses[_selectedMethod] : ""; + + // Trust the `finalized` flag; fall back to the status/additional heuristic. + bool get _isTerminal => + _finalized || carResearchIsFinalized(_statusString, _additional); + + String get _normalizedStatus => _statusString.toLowerCase().trim(); + + bool get _needsReplacement => + !_isTerminal && + const {'expired', 'underpaid_expired'}.contains(_normalizedStatus); + + bool get _payNowEnabled => + !_isTerminal && + !_needsReplacement && + _methods.isNotEmpty && + _flowState == _PaymentFlowState.idle; + + void _setPaymentLinks(Map links) { + _paymentLinks = Map.from(links); + _methods = links.keys.map((k) => k.toUpperCase()).toList(); + _addresses = links.values.toList(); + if (_selectedMethod >= _methods.length) { + _selectedMethod = 0; + } + } + + Future _confirmPayment() async { + // Keep polling while the user is in the send flow. + final method = _methods[_selectedMethod]; + final ticker = method.toUpperCase(); + + final target = parseShopInBitPaymentTarget( + paymentUri: _currentAddress, + ticker: ticker, + coin: AppConfig.getCryptoCurrencyForTicker(ticker), + ); + + final navigated = await tryNavigateToShopInBitWalletSend( + ref: ref, + context: context, + ticker: ticker, + paymentUri: _currentAddress, + address: target.address, + amount: target.amount, + // The car research fee is paid before any ticket exists. + apiTicketId: 0, + // After the wallet send, pop back here so polling can continue. + routeOnSuccessName: ShopInBitCarResearchPaymentView.routeName, + ); + + if (navigated) return; + if (!mounted) return; + + // No compatible wallet coin found: surface an info flushbar and keep + // the user on this screen so they can pay externally and then use the + // "CHECK FOR PAYMENT" button. + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: + "No compatible wallet for $method. " + "Pay externally, then tap CHECK FOR PAYMENT.", + context: context, + ), + ); + } + + Future _checkForPayment() async { + if (_flowState != _PaymentFlowState.idle) return; + setState(() => _flowState = _PaymentFlowState.polling); + try { + await _pollStatus(); + if (!mounted) return; + if (!_isTerminal && + !_needsReplacement && + _flowState != _PaymentFlowState.finalizing) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: + "Payment not yet confirmed. " + "Please wait a moment and try again.", + context: context, + ), + ); + } + } finally { + if (mounted && _flowState == _PaymentFlowState.polling) { + setState(() => _flowState = _PaymentFlowState.idle); + } + } + } + + String? _parseBip21Amount(String uri) { + try { + // Parse amount from payment URI query params. + final qIdx = uri.indexOf('?'); + if (qIdx < 0) return null; + final query = uri.substring(qIdx + 1); + final params = Uri.splitQueryString(query); + return params['amount'] ?? params['tx_amount']; + } catch (_) { + return null; + } + } + + String get _displayedFee { + if (_needsReplacement) { + return "Invoice expired"; + } + // The status endpoint has no fee field, so parse the amount from the + // selected method's BIP21 URI, falling back to the 223.00 EUR business + // rule. + final links = _paymentLinks; + if (_selectedMethod < _methods.length) { + final methodKey = _methods[_selectedMethod]; + // _methods holds upper-cased keys; links map may be case-sensitive. + String? uri = links[methodKey]; + if (uri == null) { + for (final entry in links.entries) { + if (entry.key.toUpperCase() == methodKey) { + uri = entry.value; + break; + } + } + } + if (uri != null) { + final parsed = _parseBip21Amount(uri); + if (parsed != null && parsed.isNotEmpty) { + return "$parsed $methodKey"; + } + } + } + return _normalizedStatus == "underpaid" + ? "See payment option" + : "223.00 EUR"; + } + + String get _statusLabel { + switch (_normalizedStatus) { + case "payment_processing": + return "Confirming..."; + case "underpaid": + return "Additional payment required"; + case "expired": + case "underpaid_expired": + return "Invoice expired"; + case "paid": + case "paid_over": + case "paid_late": + return "Paid ✓"; + case "ready_to_pay": + default: + return "Waiting for payment"; + } + } + + @override + void initState() { + super.initState(); + _invoiceId = widget.invoice.btcpayInvoice; + _setPaymentLinks(widget.invoice.paymentLinks); + // Kick off an immediate poll then start periodic polling. + unawaited(_pollStatus()); + _scheduleNextPoll(); + } + + @override + void dispose() { + _pollTimer?.cancel(); + super.dispose(); + } + + void _scheduleNextPoll() { + _pollTimer?.cancel(); + _pollTimer = Timer(_pollInterval, _pollTick); + } + + /// Periodic driver: poll once, then reschedule with backoff on failure and + /// reset on success. Stops once the flow is terminal or finalizing. + Future _pollTick() async { + final bool ok = await _pollStatus(); + if (!mounted) return; + if (_isTerminal || + _needsReplacement || + _flowState == _PaymentFlowState.finalizing || + _flowState == _PaymentFlowState.complete) { + return; + } + _pollInterval = ok + ? _kBasePollInterval + : ShopInBitClient.nextPollBackoff(_pollInterval, _kMaxPollInterval); + _scheduleNextPoll(); + } + + void _popToTickets() { + Navigator.of(context).popUntil((route) { + final name = route.settings.name; + if (name == ShopInBitTicketsView.routeName) { + return true; + } + if (route.isFirst || name == HomeView.routeName) { + return true; + } + return false; + }); + } + + void _goToMyRequests() { + final navigator = Navigator.of(context); + bool landedOnTickets = false; + navigator.popUntil((route) { + final name = route.settings.name; + if (name == ShopInBitTicketsView.routeName) { + landedOnTickets = true; + return true; + } + return route.isFirst || name == HomeView.routeName; + }); + if (!landedOnTickets) { + unawaited(navigator.pushNamed(ShopInBitTicketsView.routeName)); + } + } + + Future _showFinalizingFallback() async { + final goToRequests = await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackDialog( + title: "Payment received", + message: + "We're finalizing your car research request. It will appear in " + "My Requests shortly.", + width: Util.isDesktop ? 580 : null, + leftButton: SecondaryButton( + label: "Close", + buttonHeight: Util.isDesktop ? .l : null, + onPressed: () => Navigator.of(context).pop(false), + ), + rightButton: PrimaryButton( + label: "My Requests", + buttonHeight: Util.isDesktop ? .l : null, + onPressed: () => Navigator.of(context).pop(true), + ), + ), + ); + if (!mounted) return; + if (goToRequests == true) { + _goToMyRequests(); + } else { + _popToTickets(); + } + } + + Future _refreshInvoice() async { + if (_flowState != _PaymentFlowState.idle || !_needsReplacement) return; + _pollTimer?.cancel(); + final oldInvoiceId = _invoiceId; + final requestId = ++_statusRequestId; + setState(() => _flowState = _PaymentFlowState.polling); + try { + final resp = await ref + .read(pShopinBitService) + .client + .retryCarResearchInvoice( + invoiceId: oldInvoiceId, + customerKey: widget.customerKey, + ); + if (!mounted || + requestId != _statusRequestId || + oldInvoiceId != _invoiceId) { + return; + } + final invoice = resp.valueOrThrow; + setState(() { + _invoiceId = invoice.btcpayInvoice; + _status = null; + _statusString = "ready_to_pay"; + _additional = null; + _finalized = false; + _realTicketId = null; + _setPaymentLinks(invoice.paymentLinks); + _flowState = _PaymentFlowState.idle; + }); + _pollInterval = _kBasePollInterval; + _scheduleNextPoll(); + } catch (e, s) { + if (!mounted || + requestId != _statusRequestId || + oldInvoiceId != _invoiceId) { + return; + } + Logging.instance.e( + "Car research invoice refresh failed", + error: e, + stackTrace: s, + ); + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: e.toString(), + context: context, + ), + ); + } + } finally { + if (mounted && _flowState == _PaymentFlowState.polling) { + setState(() => _flowState = _PaymentFlowState.idle); + } + } + } + + /// Fetch invoice status once and apply it. Returns false on any failure so + /// the periodic driver can back off instead of polling at full rate. + Future _pollStatus() async { + final requestedInvoiceId = _invoiceId; + final requestId = ++_statusRequestId; + try { + final service = ref.read(pShopinBitService); + + final resp = await service.client.getCarResearchInvoiceStatus( + requestedInvoiceId, + customerKey: widget.customerKey, + ); + if (!mounted || + requestId != _statusRequestId || + requestedInvoiceId != _invoiceId) { + return true; + } + if (resp.hasError || resp.value == null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + resp.exception?.message ?? "Failed to fetch invoice status", + context: context, + ), + ); + return false; + } + + final apiTicketId = resp.value!.realTicketId; + if (apiTicketId != null) { + // we may not have the ticket in the db yet. Lets check + final ticket = await service.db.shopInBitTicketsDao.getByApiId( + apiTicketId, + ); + + // not found, so lets fix that + if (ticket == null) { + final invoiceStatus = resp.value!; + + final response = await service.client.getTicketFull( + apiTicketId, + customerKey: invoiceStatus.externalCustomerKey, + ); + + if (response.hasError || response.value == null) { + Logging.instance.e( + "$runtimeType get full ticket for car failed", + error: response.exception, + stackTrace: .current, + ); + } else { + final fullTicket = response.value!; + + // TODO: clean this up a bit some day but for now... + await service.db.transaction(() async { + // get ticket again to ensure this is an atomic insert operation + // in the db transaction + final ticket = await service.db.shopInBitTicketsDao.getByApiId( + apiTicketId, + ); + + if (ticket == null) { + const ticketState = TicketState.newTicket; + // insert bare minimum - will be updated automatically later + await service.db.shopInBitTicketsDao.insertTicket( + ShopInBitTicketsCompanion.insert( + apiTicketId: apiTicketId, + customerKey: invoiceStatus.externalCustomerKey, + ticketNumber: invoiceStatus.realTicketNumber!, + category: .car, + requestDescription: fullTicket.productName ?? "", + deliveryCountry: fullTicket.deliveryCountry, + status: ShopInBitOrderStatus.fromTicketState(ticketState)!, + statusRaw: ticketState.value, + ), + ); + } + }); + } + } + } + + if (!mounted || + requestId != _statusRequestId || + requestedInvoiceId != _invoiceId) { + return true; + } + Logging.instance.i( + "CarResearch status response (payment_view): ${resp.value}", + ); + Logging.instance.i( + "CarResearch paymentLinks (payment_view): " + "${resp.value!.paymentLinks}", + ); + setState(() { + _status = resp.value!; + _statusString = _status!.status.isNotEmpty + ? _status!.status + : _statusString; + _additional = _status!.additional; + _finalized = _status!.finalized; + _realTicketId = _status!.realTicketId; + if (_needsReplacement) { + _setPaymentLinks(const {}); + } else if (_normalizedStatus == 'underpaid') { + _setPaymentLinks(_status!.paymentLinks); + } else if (_status!.paymentLinks.isNotEmpty) { + _setPaymentLinks(_status!.paymentLinks); + } + }); + if (_isTerminal) { + _pollTimer?.cancel(); + await _finalizePayment(); + } else if (_needsReplacement) { + _pollTimer?.cancel(); + } + return true; + } catch (e, s) { + if (!mounted || + requestId != _statusRequestId || + requestedInvoiceId != _invoiceId) { + return true; + } + Logging.instance.e( + "ticket status polling issue", + error: e, + stackTrace: s, + ); + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: e.toString(), + context: context, + ), + ); + } + return false; + } + } + + Future _finalizePayment() async { + if (_flowState == _PaymentFlowState.finalizing || + _flowState == _PaymentFlowState.complete) { + return; + } + + final int? realId = _realTicketId; + if (realId == null) { + setState(() => _flowState = _PaymentFlowState.finalizing); + await _showFinalizingFallback(); + return; + } + + setState(() => _flowState = _PaymentFlowState.complete); + unawaited( + Navigator.of( + context, + ).pushNamed(ShopInBitOrderCreated.routeName, arguments: realId), + ); + } + + void _onOwnedCoinTap(int methodIndex) { + if (!_payNowEnabled) return; + if (methodIndex >= _methods.length) return; + setState(() => _selectedMethod = methodIndex); + unawaited(_confirmPayment()); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, + children: [ + Text( + "Car research payment", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Research fee", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + Text( + _displayedFee, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + ], + ), + ), + SizedBox(height: isDesktop ? 16 : 8), + RoundedWhiteContainer( + child: Row( + children: [ + Text( + "Status:", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const SizedBox(width: 8), + Text( + _statusLabel, + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: _isTerminal + ? Theme.of( + context, + ).extension()!.accentColorGreen + : null, + fontWeight: _isTerminal ? FontWeight.w600 : null, + ), + ), + ], + ), + ), + SizedBox(height: isDesktop ? 24 : 16), + if (_needsReplacement) + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "This invoice expired. Refresh it to continue payment.", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 8), + SecondaryButton( + label: "Refresh Invoice", + onPressed: _flowState == _PaymentFlowState.idle + ? _refreshInvoice + : null, + ), + ], + ), + ) + else + ShopInBitPaymentMethodList( + methods: _methods, + addresses: _addresses, + enabled: _payNowEnabled, + onPayFromWallet: _onOwnedCoinTap, + onCheckForPayment: (methodIndex) { + _selectedMethod = methodIndex; + unawaited(_checkForPayment()); + }, + ), + if (_flowState == _PaymentFlowState.polling || + _flowState == _PaymentFlowState.finalizing) ...[ + SizedBox(height: isDesktop ? 24 : 16), + PrimaryButton( + label: _flowState == _PaymentFlowState.polling + ? (_needsReplacement ? "Refreshing..." : "Checking...") + : "Processing...", + enabled: false, + onPressed: null, + ), + ], + ], + ); + + if (isDesktop) { + return SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "ShopinBit", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: Padding( + padding: const .only( + left: 32, + right: 32, + bottom: 32, + top: 16, + ), + child: content, + ), + ), + ], + ), + ), + ); + } + + return ShopInBitPaymentMobileScaffold( + onBack: _popToTickets, + child: content, + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_confirm_send_view.dart b/lib/pages/shopinbit/shopinbit_confirm_send_view.dart new file mode 100644 index 0000000000..83781fdf24 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_confirm_send_view.dart @@ -0,0 +1,764 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/isar/models/isar_models.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../providers/providers.dart'; +import '../../route_generator.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/amount/amount_formatter.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/models/tx_data.dart'; +import '../../wallets/wallet/impl/ethereum_wallet.dart'; +import '../../wallets/wallet/wallet.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/rounded_container.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../pinpad_views/lock_screen_view.dart'; +import '../send_view/sub_widgets/sending_transaction_dialog.dart'; +import '../wallet_view/wallet_view.dart'; + +class ShopInBitConfirmSendView extends ConsumerStatefulWidget { + const ShopInBitConfirmSendView({ + super.key, + required this.txData, + required this.walletId, + this.routeOnSuccessName = WalletView.routeName, + required this.apiTicketId, + this.tokenContract, + this.popThroughRouteName, + }); + + static const String routeName = "/shopInBitConfirmSend"; + + final TxData txData; + final String walletId; + final String routeOnSuccessName; + final int apiTicketId; + final EthContract? tokenContract; + final String? popThroughRouteName; + + @override + ConsumerState createState() => + _ShopInBitConfirmSendViewState(); +} + +class _ShopInBitConfirmSendViewState + extends ConsumerState { + late final String walletId; + late final String routeOnSuccessName; + late final int apiTicketId; + + final isDesktop = Util.isDesktop; + + Future _attemptSend(BuildContext context) async { + final parentWallet = ref.read(pWallets).getWallet(walletId); + final coin = parentWallet.info.coin; + + final sendProgressController = ProgressAndSuccessController(); + + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return SendingTransactionDialog( + coin: coin, + controller: sendProgressController, + ); + }, + ), + ); + + final time = Future.delayed(const Duration(milliseconds: 2500)); + + late String txid; + Future txidFuture; + + final String note = widget.txData.note ?? ""; + + try { + final wallet = widget.tokenContract != null + ? Wallet.loadTokenWallet( + ethWallet: parentWallet as EthereumWallet, + contract: widget.tokenContract!, + ) + : parentWallet; + + txidFuture = wallet.confirmSend(txData: widget.txData); + + unawaited(wallet.refresh()); + + final results = await Future.wait([txidFuture, time]); + + sendProgressController.triggerSuccess?.call(); + await Future.delayed(const Duration(seconds: 5)); + + txid = (results.first as TxData).txid!; + + // save note + await ref + .read(mainDBProvider) + .putTransactionNote( + TransactionNote(walletId: walletId, txid: txid, value: note), + ); + + // The server (and the BTCPay webhook) own ticket + payment state from + // here, so there's nothing to persist locally; just nudge a refresh so + // the ticket row reflects the new payment status promptly. + if (apiTicketId != 0) { + unawaited(ref.read(pShopinBitService).refreshOne(apiTicketId)); + } + + // pop back to wallet + if (context.mounted) { + final popThroughRouteName = widget.popThroughRouteName; + if (popThroughRouteName != null) { + final navigator = Navigator.of(context, rootNavigator: true); + navigator.popUntil( + ModalRoute.withName(popThroughRouteName), + ); + navigator.pop(); + } else { + // pop sending dialog (pushed via showDialog which uses root navigator) + Navigator.of(context, rootNavigator: true).pop(); + + if (Util.isDesktop) { + // pop the confirm send desktop dialog + Navigator.of(context, rootNavigator: true).pop(); + } + + Navigator.of( + context, + ).popUntil(ModalRoute.withName(routeOnSuccessName)); + } + } + } catch (e, s) { + Logging.instance.e( + "Broadcast transaction failed: ", + error: e, + stackTrace: s, + ); + + if (context.mounted) { + // pop sending dialog (pushed via showDialog which uses root navigator) + Navigator.of(context, rootNavigator: true).pop(); + + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Broadcast transaction failed", + message: e.toString(), + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, + ), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ); + }, + ); + } + } + } + + Future _confirmSend() async { + final dynamic unlocked; + + final coin = ref.read(pWalletCoin(walletId)); + + if (Util.isDesktop) { + unlocked = await showDialog( + context: context, + builder: (context) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [DesktopDialogCloseButton()], + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: DesktopAuthSend( + coin: coin, + tokenTicker: widget.tokenContract?.symbol, + ), + ), + ], + ), + ), + ); + } else { + unlocked = await Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => const LockscreenView( + showBackButton: true, + popOnSuccess: true, + routeOnSuccessArguments: true, + routeOnSuccess: "", + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: "Authenticate to send transaction", + biometricsAuthenticationTitle: "Confirm Transaction", + ), + settings: const RouteSettings(name: "/confirmsendlockscreen"), + ), + ); + } + + if (unlocked is bool && mounted) { + if (unlocked) { + await _attemptSend(context); + } else { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid passphrase", + context: context, + ), + ); + } + } + } + + @override + void initState() { + walletId = widget.walletId; + routeOnSuccessName = widget.routeOnSuccessName; + apiTicketId = widget.apiTicketId; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final ticketNumber = + ref.watch(pShopInBitTicket(apiTicketId)).asData?.value?.ticketNumber ?? + ""; + return ConditionalParent( + condition: !isDesktop, + builder: (child) { + return Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, + leading: AppBarBackButton( + onPressed: () async { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Confirm transaction", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return Padding( + padding: const EdgeInsets.only( + left: 12, + top: 12, + right: 12, + ), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 24, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(4), + child: child, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + }, + child: ConditionalParent( + condition: isDesktop, + builder: (child) => DesktopDialog( + maxHeight: double.infinity, + maxWidth: 580, + child: Column( + children: [ + Row( + children: [ + const SizedBox(width: 6), + const AppBarBackButton(isCompact: true, iconSize: 23), + const SizedBox(width: 12), + Text( + "Confirm ${widget.tokenContract?.symbol ?? ref.watch(pWalletCoin(walletId)).ticker} transaction", + style: STextStyles.desktopH3(context), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: Column( + children: [ + RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + borderColor: Theme.of( + context, + ).extension()!.background, + child: child, + ), + const SizedBox(height: 16), + Row( + children: [ + Text( + "Transaction fee", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + ], + ), + const SizedBox(height: 10), + RoundedContainer( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + ref + .watch( + pAmountFormatter( + ref.watch(pWalletCoin(walletId)), + ), + ) + .format(widget.txData.fee!), + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + RoundedContainer( + color: Theme.of( + context, + ).extension()!.snackBarBackSuccess, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Total amount", + style: STextStyles.titleBold12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), + ), + Builder( + builder: (context) { + final coin = ref.read(pWalletCoin(walletId)); + final fee = widget.txData.fee!; + final amount = widget.txData.amountWithoutChange!; + + if (widget.tokenContract != null) { + final amountStr = + "${amount.decimal.toStringAsFixed(widget.tokenContract!.decimals)} ${widget.tokenContract!.symbol}"; + final feeStr = ref + .watch(pAmountFormatter(coin)) + .format(fee); + return Text( + "$amountStr + $feeStr", + style: STextStyles.itemSubtitle12(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), + textAlign: TextAlign.right, + ); + } + + final total = amount + fee; + return Text( + ref.watch(pAmountFormatter(coin)).format(total), + style: STextStyles.itemSubtitle12(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), + textAlign: TextAlign.right, + ); + }, + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: ButtonHeight.l, + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Send", + buttonHeight: isDesktop ? ButtonHeight.l : null, + onPressed: _confirmSend, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ConditionalParent( + condition: isDesktop, + builder: (child) => Container( + decoration: BoxDecoration( + color: Theme.of(context).extension()!.background, + borderRadius: BorderRadius.vertical( + top: Radius.circular(Constants.size.circularBorderRadius), + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row(children: [child]), + ), + ), + child: Text( + "Send ${widget.tokenContract?.symbol ?? ref.watch(pWalletCoin(walletId)).ticker}", + style: isDesktop + ? STextStyles.desktopTextMedium(context) + : STextStyles.pageTitleH1(context), + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text("Send from", style: STextStyles.smallMed12(context)), + const SizedBox(height: 4), + Text( + widget.tokenContract != null + ? "${ref.watch(pWalletName(walletId))} (${widget.tokenContract!.symbol})" + : ref.watch(pWalletName(walletId)), + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "ShopinBit address", + style: STextStyles.smallMed12(context), + ), + const SizedBox(height: 4), + Text( + widget.txData.recipients!.first.address, + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Amount", style: STextStyles.smallMed12(context)), + ConditionalParent( + condition: isDesktop, + builder: (child) => Row( + children: [ + child, + if (widget.tokenContract == null) + Builder( + builder: (context) { + final coin = ref.watch(pWalletCoin(walletId)); + final price = ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getPrice(coin), + ), + ); + final String extra; + if (price == null) { + extra = ""; + } else { + final amountWithoutChange = + widget.txData.amountWithoutChange!; + final value = + (price.value * amountWithoutChange.decimal) + .toAmount(fractionDigits: 2); + final currency = ref.watch( + prefsChangeNotifierProvider.select( + (value) => value.currency, + ), + ); + final locale = ref.watch( + localeServiceChangeNotifierProvider.select( + (value) => value.locale, + ), + ); + + extra = + " | ${value.fiatString(locale: locale)} $currency"; + } + + return Text( + extra, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle2, + ), + ); + }, + ), + ], + ), + child: Text( + widget.tokenContract != null + ? "${widget.txData.amountWithoutChange!.decimal.toStringAsFixed(widget.tokenContract!.decimals)} ${widget.tokenContract!.symbol}" + : ref + .watch( + pAmountFormatter( + ref.watch(pWalletCoin(walletId)), + ), + ) + .format(widget.txData.amountWithoutChange!), + style: STextStyles.itemSubtitle12(context), + textAlign: TextAlign.right, + ), + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Transaction fee", + style: STextStyles.smallMed12(context), + ), + Text( + ref + .watch( + pAmountFormatter(ref.read(pWalletCoin(walletId))), + ) + .format(widget.txData.fee!), + style: STextStyles.itemSubtitle12(context), + textAlign: TextAlign.right, + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text("Note", style: STextStyles.smallMed12(context)), + const SizedBox(height: 4), + Text( + widget.txData.note ?? "", + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Request ID", style: STextStyles.smallMed12(context)), + Text( + ticketNumber, + style: STextStyles.itemSubtitle12(context), + textAlign: TextAlign.right, + ), + ], + ), + ), + if (!isDesktop) const SizedBox(height: 12), + if (!isDesktop) + RoundedContainer( + color: Theme.of( + context, + ).extension()!.snackBarBackSuccess, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Total amount", + style: STextStyles.titleBold12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textConfirmTotalAmount, + ), + ), + Builder( + builder: (context) { + final coin = ref.watch(pWalletCoin(walletId)); + final fee = widget.txData.fee!; + final amount = widget.txData.amountWithoutChange!; + + if (widget.tokenContract != null) { + final amountStr = + "${amount.decimal.toStringAsFixed(widget.tokenContract!.decimals)} ${widget.tokenContract!.symbol}"; + final feeStr = ref + .watch(pAmountFormatter(coin)) + .format(fee); + return Text( + "$amountStr + $feeStr", + style: STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), + textAlign: TextAlign.right, + ); + } + + final total = amount + fee; + return Text( + ref.watch(pAmountFormatter(coin)).format(total), + style: STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textConfirmTotalAmount, + ), + textAlign: TextAlign.right, + ); + }, + ), + ], + ), + ), + if (!isDesktop) const SizedBox(height: 16), + if (!isDesktop) const Spacer(), + if (!isDesktop) + PrimaryButton( + label: "Send", + buttonHeight: isDesktop ? ButtonHeight.l : null, + onPressed: _confirmSend, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_offer_view.dart b/lib/pages/shopinbit/shopinbit_offer_view.dart new file mode 100644 index 0000000000..9232821a46 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_offer_view.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/show_loading.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import 'shopinbit_shipping_view.dart'; + +class ShopInBitOfferView extends ConsumerWidget { + const ShopInBitOfferView({super.key, required this.apiTicketId}); + + static const String routeName = "/shopInBitOffer"; + + final int apiTicketId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDesktop = Util.isDesktop; + final ticket = ref.watch(pShopInBitTicket(apiTicketId)).asData?.value; + + final content = Column( + mainAxisSize: .min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Review offer", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + Text( + "ShopinBit has found a match for your request.", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + SizedBox(height: isDesktop ? 16 : 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Product", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 4), + Text( + ticket?.offerProductName ?? "N/A", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], + ), + ), + SizedBox(height: isDesktop ? 12 : 8), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Price (incl. service fee and VAT)", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 4), + Text( + "${ticket?.offerPrice ?? '0'} EUR", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], + ), + ), + isDesktop ? const SizedBox(height: 40) : const Spacer(), + BranchedParent( + condition: isDesktop, + conditionBranchBuilder: (children) => Row( + children: [ + Expanded(child: children[1]), + const SizedBox(width: 16), + Expanded(child: children[0]), + ], + ), + otherBranchBuilder: (children) => Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [children[0], const SizedBox(height: 16), children[1]], + ), + children: [ + PrimaryButton( + label: "Accept offer", + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: () async { + final deliveryCountry = ticket?.deliveryCountry ?? ""; + + final shopinBitApi = ref.read(pShopinBitService).client; + final response = await showLoading( + context: context, + rootNavigator: true, + message: "Checking available countries", + whileFuture: shopinBitApi.getCountries(), + delay: const Duration( + seconds: 1, + ), // at least 1 sec to prevent ui flashing + ); + + if (!context.mounted) return; + + String? errorMessage; + + if (response?.value == null) { + errorMessage = + response?.exception?.toString() ?? + "Failed to fetch countries data"; + } else if (response!.value! + .where((c) => c['iso'] == deliveryCountry) + .length != + 1) { + errorMessage = + "Delivery country code \"" + "$deliveryCountry" + "\" is invalid"; + } + + if (errorMessage != null) { + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "ShopinBit API error", + maxWidth: Util.isDesktop ? 500 : null, + message: errorMessage, + desktopPopRootNavigator: Util.isDesktop, + ), + ); + return; + } + + if (context.mounted) { + await Navigator.of(context).pushNamed( + ShopInBitShippingView.routeName, + arguments: (ticket: ticket!, countries: response!.value!), + ); + } + }, + ), + SecondaryButton( + label: "Cancel", + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ], + ); + + if (isDesktop) { + return SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "ShopinBit", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: Padding( + padding: const .only( + left: 32, + right: 32, + bottom: 32, + top: 16, + ), + child: content, + ), + ), + ], + ), + ), + ); + } + + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight(child: content), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_order_created.dart b/lib/pages/shopinbit/shopinbit_order_created.dart new file mode 100644 index 0000000000..0e202b8a83 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_order_created.dart @@ -0,0 +1,246 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart'; +import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../home_view/home_view.dart'; +import 'shopinbit_ticket_detail.dart'; + +class ShopInBitOrderCreated extends ConsumerWidget { + const ShopInBitOrderCreated({super.key, required this.apiTicketId}); + + static const String routeName = "/shopInBitOrderCreated"; + + final int apiTicketId; + + static void _popToServices(BuildContext context) { + Navigator.of(context).popUntil((route) { + if (route.settings.name == HomeView.routeName) { + return true; + } + if (route.isFirst) { + return true; + } + return false; + }); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDesktop = Util.isDesktop; + final ticket = ref.watch(pShopInBitTicket(apiTicketId)).asData?.value; + + return ConditionalParent( + condition: isDesktop, + builder: (child) => SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "ShopinBit", + style: STextStyles.desktopH3(context), + ), + ), + DesktopDialogCloseButton( + onPressedOverride: () => NestedNavigatorDialog.of( + context, + ).close(args: const .noWarning()), + ), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + top: 16, + ), + child: child, + ), + ), + ], + ), + ), + ), + + child: ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, dynamic result) { + if (!didPop) { + _popToServices(context); + } + }, + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => _popToServices(context), + ), + title: Text( + "ShopinBit", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight(child: child), + ), + ), + ); + }, + ), + ), + ), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (!isDesktop) const Spacer(), + SvgPicture.asset( + Assets.svg.checkCircle, + width: isDesktop ? 64 : 48, + height: isDesktop ? 64 : 48, + color: Theme.of( + context, + ).extension()!.accentColorGreen, + ), + SizedBox(height: isDesktop ? 24 : 16), + Text( + "Request created!", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + Text( + "Your request has been submitted.", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + textAlign: TextAlign.center, + ), + SizedBox(height: isDesktop ? 32 : 24), + RoundedWhiteContainer( + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Request ID", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + Text( + ticket?.ticketNumber ?? "N/A", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], + ), + SizedBox(height: isDesktop ? 12 : 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Status", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + Text( + "Pending review", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], + ), + ], + ), + ), + isDesktop ? const SizedBox(height: 40) : const Spacer(), + BranchedParent( + condition: isDesktop, + conditionBranchBuilder: (children) => Row( + children: [ + Expanded(child: children[2]), + children[1], + Expanded(child: children[0]), + ], + ), + otherBranchBuilder: (children) => Column( + crossAxisAlignment: .stretch, + mainAxisSize: .min, + children: children, + ), + children: [ + PrimaryButton( + label: "View request", + buttonHeight: isDesktop ? .l : null, + onPressed: () { + Navigator.of(context).pushNamed( + ShopInBitTicketDetail.routeName, + arguments: apiTicketId, + ); + }, + ), + const SizedBox(height: 16, width: 24), + SecondaryButton( + label: "Back to services", + buttonHeight: isDesktop ? .l : null, + onPressed: () { + if (Util.isDesktop) { + NestedNavigatorDialog.of( + context, + ).close(args: const .noWarning()); + } else { + _popToServices(context); + } + }, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_payment_method_list.dart b/lib/pages/shopinbit/shopinbit_payment_method_list.dart new file mode 100644 index 0000000000..58af371eaf --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_payment_method_list.dart @@ -0,0 +1,399 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../app_config.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../providers/providers.dart'; +import '../../themes/coin_icon_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/address_utils.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/dialogs/simple_mobile_dialog.dart'; +import '../../widgets/icon_widgets/copy_icon.dart'; +import '../../widgets/qr.dart'; +import '../../widgets/rounded_container.dart'; +import '../../widgets/rounded_white_container.dart'; +import 'shopinbit_payment_shared.dart'; + +class ShopInBitPaymentMethodList extends ConsumerStatefulWidget { + const ShopInBitPaymentMethodList({ + super.key, + required this.methods, + required this.addresses, + required this.enabled, + required this.onPayFromWallet, + required this.onCheckForPayment, + }); + + final List methods; + final List addresses; + final bool enabled; + final ValueChanged onPayFromWallet; + final ValueChanged onCheckForPayment; + + @override + ConsumerState createState() => + _ShopInBitPaymentMethodListState(); +} + +class _ShopInBitPaymentMethodListState + extends ConsumerState { + int? _openIndex; + String? _openTicker; + String? _openAddress; + BuildContext? _dialogContext; + bool _dismissScheduled = false; + + bool get _openPaymentIsCurrent { + final index = _openIndex; + return mounted && + widget.enabled && + index != null && + index < widget.methods.length && + index < widget.addresses.length && + widget.methods[index].toUpperCase() == _openTicker && + widget.addresses[index] == _openAddress; + } + + void _dismissPaymentDetails() { + final dialogContext = _dialogContext; + if (dialogContext == null || _dismissScheduled) return; + _dismissScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _dismissScheduled = false; + final route = ModalRoute.of(dialogContext); + if (dialogContext.mounted && route != null && route.isActive) { + Navigator.of(dialogContext).removeRoute(route); + } + }); + } + + @override + void didUpdateWidget(ShopInBitPaymentMethodList oldWidget) { + super.didUpdateWidget(oldWidget); + if (_dialogContext != null && !_openPaymentIsCurrent) { + _dismissPaymentDetails(); + } + } + + @override + void dispose() { + _dismissPaymentDetails(); + super.dispose(); + } + + String? _parseAmount(String paymentUri) { + final parsed = AddressUtils.parsePaymentUri(paymentUri); + String? amount = parsed?.amount; + if (amount == null || amount.isEmpty) { + amount = Uri.tryParse(paymentUri)?.queryParameters["amount"]; + } + return amount == null || amount.isEmpty ? null : amount; + } + + Future _showPaymentDetails( + BuildContext context, + int index, + String ticker, + String address, + ) async { + _openIndex = index; + _openTicker = ticker; + _openAddress = address; + try { + await showDialog( + context: context, + useRootNavigator: true, + builder: (ctx) { + _dialogContext = ctx; + return _ExternalPaymentDialog( + ticker: ticker, + address: address, + onCheckForPayment: () { + final isCurrent = _openPaymentIsCurrent; + Navigator.of(ctx).pop(); + if (isCurrent) { + widget.onCheckForPayment(index); + } + }, + ); + }, + ); + } finally { + _openIndex = null; + _openTicker = null; + _openAddress = null; + _dialogContext = null; + } + } + + @override + Widget build(BuildContext context) { + final methods = widget.methods; + final addresses = widget.addresses; + final enabled = widget.enabled; + final count = methods.length < addresses.length + ? methods.length + : addresses.length; + if (count == 0) { + return Padding( + padding: const EdgeInsets.all(32), + child: Text( + "No payment address available", + textAlign: TextAlign.center, + style: STextStyles.itemSubtitle(context), + ), + ); + } + + final wallets = ref.watch(pWallets); + final rows = []; + + for (var i = 0; i < count; i++) { + final ticker = methods[i].toUpperCase(); + final address = addresses[i]; + final coin = AppConfig.getCryptoCurrencyForTicker(ticker); + final hasAddress = address.isNotEmpty; + final hasWallet = hasShopInBitWalletForTicker( + wallets: wallets, + ticker: ticker, + paymentUri: address, + ); + final canPayNow = hasWallet && hasAddress; + final amount = hasAddress ? _parseAmount(address) : null; + + if (i > 0) { + rows.add(const SizedBox(height: 8)); + } + + rows.add( + RoundedWhiteContainer( + child: Opacity( + opacity: enabled && canPayNow ? 1 : 0.5, + child: InkWell( + onTap: !enabled || !hasAddress + ? null + : hasWallet + ? () => widget.onPayFromWallet(i) + : () => unawaited( + _showPaymentDetails(context, i, ticker, address), + ), + child: Row( + children: [ + if (coin != null) + SvgPicture.file( + File(ref.watch(coinIconProvider(coin))), + width: 24, + height: 24, + ) + else + SizedBox( + width: 24, + height: 24, + child: Center( + child: Text( + ticker.substring( + 0, + ticker.length > 2 ? 2 : ticker.length, + ), + style: STextStyles.itemSubtitle12(context), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(ticker, style: STextStyles.titleBold12(context)), + if (amount != null) + Text( + "$amount $ticker", + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + if (canPayNow) + Text("PAY NOW", style: STextStyles.link2(context)) + else + SvgPicture.asset( + Assets.svg.circleInfo, + width: 18, + height: 18, + color: Theme.of( + context, + ).extension()!.textSubtitle2, + ), + ], + ), + ), + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: rows, + ); + } +} + +class _ExternalPaymentDialog extends StatelessWidget { + const _ExternalPaymentDialog({ + required this.ticker, + required this.address, + required this.onCheckForPayment, + }); + + final String ticker; + final String address; + final VoidCallback onCheckForPayment; + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final showUsdtWarning = + ticker == "USDT" && !isShopInBitEthereumUsdtUri(address); + + final content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: QR(data: address, size: isDesktop ? 200 : 180), + ), + if (showUsdtWarning) SizedBox(height: isDesktop ? 24 : 16), + if (showUsdtWarning) + RoundedContainer( + color: Theme.of( + context, + ).extension()!.warningBackground, + child: Center( + child: Text( + "IMPORTANT: Only send USDT (TRC20) to this address, not TRX", + style: (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of( + context, + ).extension()!.warningForeground, + )), + ), + ), + ), + const SizedBox(height: 16), + GestureDetector( + onTap: () async { + await Clipboard.setData(ClipboardData(text: address)); + if (!context.mounted) return; + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + iconAsset: Assets.svg.copy, + context: context, + ), + ); + }, + child: RoundedWhiteContainer( + child: Column( + children: [ + Row( + children: [ + Text( + "$ticker address", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const Spacer(), + CopyIcon( + width: isDesktop ? 15 : 10, + height: isDesktop ? 15 : 10, + color: Theme.of( + context, + ).extension()!.infoItemIcons, + ), + const SizedBox(width: 4), + Text("Copy", style: STextStyles.link2(context)), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text( + address, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 16), + PrimaryButton(label: "CHECK FOR PAYMENT", onPressed: onCheckForPayment), + ], + ); + + if (!isDesktop) { + return SimpleMobileDialog( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text("$ticker Payment", style: STextStyles.pageTitleH2(context)), + const SizedBox(height: 16), + content, + ], + ), + ); + } + + return SDialog( + child: SizedBox( + width: 480, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "$ticker Payment", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(32, 8, 32, 32), + child: content, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart new file mode 100644 index 0000000000..c5b41564f9 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart @@ -0,0 +1,327 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app_config.dart'; +import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../providers/providers.dart'; +import '../../route_generator.dart'; +import '../../services/shopinbit/src/client.dart'; +import '../../services/shopinbit/src/models/payment.dart'; +import '../../services/wallets.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/address_utils.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/default_eth_tokens.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import 'shopinbit_send_from_view.dart'; + +final String kShopInBitUsdtContractAddress = DefaultTokens.list + .firstWhere((t) => t.symbol == "USDT") + .address; + +// Address + amount pulled out of one of the API's payment_links entries. +class ShopInBitPaymentTarget { + const ShopInBitPaymentTarget({required this.address, required this.amount}); + + final String address; + final Amount? amount; +} + +// Parses a BIP21-style payment URI (or a bare address) into a destination +// address and optional Amount. +ShopInBitPaymentTarget parseShopInBitPaymentTarget({ + required String paymentUri, + required String ticker, + CryptoCurrency? coin, +}) { + String address = ""; + final parsed = AddressUtils.parsePaymentUri(paymentUri); + + if (parsed?.address != null && parsed!.address.isNotEmpty) { + address = parsed.address; + } else { + final colonIdx = paymentUri.indexOf(':'); + if (colonIdx != -1) { + final afterScheme = paymentUri.substring(colonIdx + 1); + final qIdx = afterScheme.indexOf('?'); + address = qIdx != -1 ? afterScheme.substring(0, qIdx) : afterScheme; + } else { + address = paymentUri; + } + } + + String? amountStr = parsed?.amount; + if (amountStr == null || amountStr.isEmpty) { + final uri = Uri.tryParse(paymentUri); + if (uri != null) { + amountStr = uri.queryParameters['amount']; + } + } + final int fractionDigits; + if (coin != null) { + fractionDigits = coin.fractionDigits; + } else if (ticker == "USDT") { + fractionDigits = 6; + } else { + fractionDigits = 8; + } + + Amount? amount; + if (amountStr != null && amountStr.isNotEmpty) { + try { + amount = Amount.fromDecimal( + Decimal.parse(amountStr), + fractionDigits: fractionDigits, + ); + } catch (e, s) { + Logging.instance.e( + "Failed to parse ShopInBit payment amount '$amountStr'", + error: e, + stackTrace: s, + ); + } + } + + return ShopInBitPaymentTarget(address: address, amount: amount); +} + +// USDT exists on multiple chains (ERC-20, TRC-20, BEP-20, ...) and the +// ShopInBit API just keys the payment link as "USDT". Only treat it as +// ETH-USDT when the URI scheme is `ethereum:` or the address looks like a +// bare Ethereum hex address. Anything else (Tron, etc.) we don't support +// in-app and the user has to pay externally. +final RegExp _kEthAddressRegExp = RegExp(r'^0x[0-9a-fA-F]{40}$'); + +bool isShopInBitEthereumUsdtUri(String paymentUri) { + final trimmed = paymentUri.trim(); + final uri = Uri.tryParse(trimmed); + if (uri != null && uri.scheme.toLowerCase() == 'ethereum') { + return _kEthAddressRegExp.hasMatch(uri.path); + } + return _kEthAddressRegExp.hasMatch(trimmed); +} + +// True if any wallet in [wallets] can send the given upper-cased [ticker] +// for the given [paymentUri]. USDT is special-cased to look at Ethereum +// wallets' token contracts, gated on the URI actually being ETH-chain. +bool hasShopInBitWalletForTicker({ + required Wallets wallets, + required String ticker, + required String paymentUri, +}) { + if (ticker == "USDT") { + if (!isShopInBitEthereumUsdtUri(paymentUri)) return false; + return wallets.wallets.any( + (w) => + w.info.coin is Ethereum && + w.info.tokenContractAddresses.contains(kShopInBitUsdtContractAddress), + ); + } + final coin = AppConfig.getCryptoCurrencyForTicker(ticker); + if (coin == null) return false; + return wallets.wallets.any((e) => e.info.coin == coin); +} + +// Pushes the send-from view and awaits it. +Future _pushShopInBitSendFrom({ + required BuildContext context, + required CryptoCurrency coin, + required Amount? amount, + required String address, + required int apiTicketId, + EthContract? tokenContract, + String? routeOnSuccessName, +}) async { + if (Util.isDesktop) { + // Show the send-from dialog on top of the payment dialog. Do not pop the + // payment flow first: doing so tears down the whole nested-navigator + // dialog, so closing send-from would drop the user back to Services + // instead of returning to the payment view. + await showDialog( + context: context, + routeSettings: const RouteSettings(name: ShopInBitSendFromView.routeName), + builder: (_) => ShopInBitSendFromView( + coin: coin, + amount: amount, + address: address, + apiTicketId: apiTicketId, + shouldPopRoot: true, + tokenContract: tokenContract, + routeOnSuccessName: routeOnSuccessName, + ), + ); + } else { + await Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => ShopInBitSendFromView( + coin: coin, + amount: amount, + address: address, + apiTicketId: apiTicketId, + tokenContract: tokenContract, + routeOnSuccessName: routeOnSuccessName, + ), + settings: const RouteSettings(name: ShopInBitSendFromView.routeName), + ), + ); + } +} + +// Tries to launch the in-wallet send flow for [ticker]/[address]. +Future tryNavigateToShopInBitWalletSend({ + required WidgetRef ref, + required BuildContext context, + required String ticker, + required String paymentUri, + required String address, + required Amount? amount, + required int apiTicketId, + String? routeOnSuccessName, +}) async { + if (address.isEmpty) return false; + + final coin = AppConfig.getCryptoCurrencyForTicker(ticker); + if (coin != null) { + await _pushShopInBitSendFrom( + context: context, + coin: coin, + amount: amount, + address: address, + apiTicketId: apiTicketId, + routeOnSuccessName: routeOnSuccessName, + ); + return true; + } + + if (ticker == "USDT") { + if (!isShopInBitEthereumUsdtUri(paymentUri)) return false; + final tokenContract = ref + .read(mainDBProvider) + .getEthContractSync(kShopInBitUsdtContractAddress); + if (tokenContract != null) { + final ethCoin = AppConfig.getCryptoCurrencyForTicker("ETH"); + if (ethCoin != null) { + await _pushShopInBitSendFrom( + context: context, + coin: ethCoin, + amount: amount, + address: address, + apiTicketId: apiTicketId, + tokenContract: tokenContract, + routeOnSuccessName: routeOnSuccessName, + ); + return true; + } + } + } + + return false; +} + +// Fetches the live payment info for a ticket so the caller can pass it into +// the payment view as an arg (rather than loading it after the view is up). +// GET first to reuse an existing invoice per the spec's "page reload +// recovery" guidance. Retry stale invoices and create only when not started. +// Returns null on any failure so the view can fall back to polling. +Future fetchShopInBitPaymentInfo( + ShopInBitClient client, + int apiTicketId, + String customerKey, +) async { + try { + final getResp = await client.getPayment( + apiTicketId, + customerKey: customerKey, + ); + if (getResp.hasError || getResp.value == null) { + return null; + } + + final paymentInfo = getResp.value!; + final retry = const { + 'expired', + 'invalid', + 'underpaid_expired', + }.contains(paymentInfo.status); + if (!retry && paymentInfo.status != 'not_started') { + return paymentInfo; + } + + final putResp = await client.putPayment( + apiTicketId, + customerKey: customerKey, + retry: retry, + ); + if (!putResp.hasError && putResp.value != null) { + return putResp.value; + } + } catch (e, s) { + Logging.instance.w( + "fetchShopInBitPaymentInfo failed, degrading to polling-only", + error: e, + stackTrace: s, + ); + } + return null; +} + +// Shared mobile chrome for the two ShopInBit payment views: Background + +// PopScope (back goes through [onBack]) + AppBar + scrollable, intrinsic +// height body. +class ShopInBitPaymentMobileScaffold extends StatelessWidget { + const ShopInBitPaymentMobileScaffold({ + super.key, + required this.onBack, + required this.child, + }); + + final VoidCallback onBack; + final Widget child; + + @override + Widget build(BuildContext context) { + return Background( + child: PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, dynamic result) { + if (!didPop) { + onBack(); + } + }, + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton(onPressed: onBack), + title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight(child: child), + ), + ), + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart new file mode 100644 index 0000000000..e0d8b6fed9 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -0,0 +1,609 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../app_config.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../providers/providers.dart'; +import '../../services/shopinbit/src/client.dart'; +import '../../services/shopinbit/src/models/payment.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/show_loading.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../home_view/home_view.dart'; +import 'shopinbit_payment_method_list.dart'; +import 'shopinbit_payment_shared.dart'; +import 'shopinbit_ticket_detail.dart'; +import 'shopinbit_tickets_view.dart'; + +class ShopInBitPaymentView extends ConsumerStatefulWidget { + const ShopInBitPaymentView({ + super.key, + required this.apiTicketId, + required this.paymentInfo, + }); + + static const String routeName = "/shopInBitPayment"; + + final int apiTicketId; + + // Caller loads this before pushing, so we always open with usable addresses. + final PaymentInfo paymentInfo; + + @override + ConsumerState createState() => + _ShopInBitPaymentViewState(); +} + +class _ShopInBitPaymentViewState extends ConsumerState { + int _selectedMethod = 0; + Timer? _pollTimer; + int _paymentRequestId = 0; + + static const Duration _kBasePollInterval = Duration(seconds: 15); + static const Duration _kMaxPollInterval = Duration(seconds: 120); + Duration _pollInterval = _kBasePollInterval; + + PaymentInfo? _paymentInfo; + + // Derived from API payment_links keys, fallback to defaults + List _methods = ["BTC", "XMR", "USDT"]; + List _addresses = ["", "", ""]; + + String get _currentAddress => + _selectedMethod < _addresses.length ? _addresses[_selectedMethod] : ""; + + String get _totalPrice => _paymentInfo?.customerPrice ?? "0"; + + String get _status => _paymentInfo?.status ?? 'ready_to_pay'; + + bool get _isExpiredOrInvalid => + const {'expired', 'invalid', 'underpaid_expired'}.contains(_status); + + // Voucher/credit fully covers the amount: no wallet options, nothing to pay. + bool get _isNoPaymentRequired => _status == 'no_payment_required'; + + bool get _isTerminal => const { + 'paid', + 'paid_over', + 'paid_late', + 'payment_processing', + }.contains(_status); + + bool get _payNowEnabled => + !_isExpiredOrInvalid && !_isTerminal && !_isNoPaymentRequired; + + String? _customerKeyCache; + + Future get _customerKey async { + _customerKeyCache ??= + (await ref + .read(pSharedDrift) + .shopInBitTicketsDao + .getByApiId(widget.apiTicketId))! + .customerKey; + return _customerKeyCache!; + } + + @override + void initState() { + super.initState(); + _applyPaymentInfo(widget.paymentInfo); + if (widget.apiTicketId != 0) { + _startPolling(); + } + } + + @override + void dispose() { + _pollTimer?.cancel(); + super.dispose(); + } + + void _applyPaymentInfo(PaymentInfo info) { + _paymentInfo = info; + final links = info.paymentLinks; + if (!_isExpiredOrInvalid && links.isNotEmpty) { + _methods = links.keys.map((k) => k.toUpperCase()).toList(); + _addresses = links.values.toList(); + if (_selectedMethod >= _methods.length) { + _selectedMethod = 0; + } + } else { + _methods = []; + _addresses = []; + _selectedMethod = 0; + } + } + + void _startPolling() { + _pollTimer?.cancel(); + _paymentRequestId++; + _pollInterval = _kBasePollInterval; + _scheduleNextPoll(); + } + + void _scheduleNextPoll() { + _pollTimer?.cancel(); + _pollTimer = Timer(_pollInterval, _pollPayment); + } + + Future _pollPayment() async { + final requestId = ++_paymentRequestId; + bool ok = false; + try { + final customerKey = await _customerKey; + if (!mounted || requestId != _paymentRequestId) return; + + final resp = await ref + .read(pShopinBitService) + .client + .getPayment(widget.apiTicketId, customerKey: customerKey); + if (!mounted || requestId != _paymentRequestId) return; + + if (!resp.hasError && resp.value != null) { + ok = true; + setState(() => _applyPaymentInfo(resp.value!)); + } + } catch (e, s) { + Logging.instance.w( + "ShopInBit payment poll failed", + error: e, + stackTrace: s, + ); + } + if (!mounted || requestId != _paymentRequestId) return; + if (_isTerminal || _isExpiredOrInvalid) { + _pollTimer?.cancel(); + return; + } + // Back off on failure (e.g. a 429), reset to base on success, so a rate + // limit slows us down instead of getting hammered every 15s. + _pollInterval = ok + ? _kBasePollInterval + : ShopInBitClient.nextPollBackoff(_pollInterval, _kMaxPollInterval); + _scheduleNextPoll(); + } + + Future _refreshInvoice() async { + _pollTimer?.cancel(); + final requestId = ++_paymentRequestId; + + final customerKey = await _customerKey; + if (!mounted || requestId != _paymentRequestId) return; + + final resp = await showLoading( + whileFuture: ref + .read(pShopinBitService) + .client + .putPayment( + widget.apiTicketId, + customerKey: customerKey, + retry: true, + ), + context: context, + message: "Refreshing invoice", + rootNavigator: true, + ); + if (!mounted || requestId != _paymentRequestId) return; + if (resp != null && !resp.hasError && resp.value != null) { + setState(() => _applyPaymentInfo(resp.value!)); + } + if (!_isExpiredOrInvalid) { + _startPolling(); + } + } + + Future _checkForPayment() async { + _pollTimer?.cancel(); + final requestId = ++_paymentRequestId; + + final customerKey = await _customerKey; + if (!mounted || requestId != _paymentRequestId) return; + + final resp = await showLoading( + whileFuture: ref + .read(pShopinBitService) + .client + .getPayment(widget.apiTicketId, customerKey: customerKey), + context: context, + message: "Checking for payment", + rootNavigator: true, + ); + if (!mounted || requestId != _paymentRequestId) return; + + if (resp != null && !resp.hasError && resp.value != null) { + setState(() => _applyPaymentInfo(resp.value!)); + final status = resp.value!.status; + if (const { + 'paid', + 'paid_over', + 'paid_late', + 'payment_processing', + }.contains(status)) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Payment received!", + context: context, + ), + ); + } else if (status == 'underpaid') { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "Additional payment is required. " + "Use one of the updated payment options.", + context: context, + ), + ); + } else { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "No payment detected yet.", + context: context, + ), + ); + } + } else { + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to check payment", + maxWidth: Util.isDesktop ? 500 : null, + message: resp?.exception?.message, + desktopPopRootNavigator: Util.isDesktop, + ), + ); + if (!mounted || requestId != _paymentRequestId) return; + } + + if (!_isTerminal && !_isExpiredOrInvalid) { + _startPolling(); + } + } + + Future _confirmPayment() async { + _pollTimer?.cancel(); + _paymentRequestId++; + final method = _methods[_selectedMethod]; + final ticker = method.toUpperCase(); + + final target = parseShopInBitPaymentTarget( + paymentUri: _currentAddress, + ticker: ticker, + coin: AppConfig.getCryptoCurrencyForTicker(ticker), + ); + + final navigated = await tryNavigateToShopInBitWalletSend( + ref: ref, + context: context, + ticker: ticker, + paymentUri: _currentAddress, + address: target.address, + amount: target.amount, + apiTicketId: widget.apiTicketId, + ); + if (!mounted) return; + if (!_isTerminal) _startPolling(); + if (navigated) return; + + // Couldn't launch the in-wallet send. + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "Payment details for $ticker aren't ready yet. " + "Please wait a moment or refresh the invoice.", + context: context, + ), + ); + } + + void _popToTickets() { + Navigator.of(context).pop(); + } + + bool get _canReturnToRequest => widget.apiTicketId != 0; + void _backToRequest() { + final navigator = Navigator.of(context); + bool landedOnRequest = false; + navigator.popUntil((route) { + final name = route.settings.name; + if (name == ShopInBitTicketDetail.routeName) { + landedOnRequest = true; + return true; + } + return route.isFirst || + name == ShopInBitTicketsView.routeName || + name == HomeView.routeName; + }); + if (!landedOnRequest) { + unawaited( + navigator.pushNamed( + ShopInBitTicketDetail.routeName, + arguments: widget.apiTicketId, + ), + ); + } + } + + void _goToMyRequests() { + final navigator = Navigator.of(context); + bool landedOnTickets = false; + navigator.popUntil((route) { + final name = route.settings.name; + if (name == ShopInBitTicketsView.routeName) { + landedOnTickets = true; + return true; + } + return route.isFirst || name == HomeView.routeName; + }); + if (!landedOnTickets) { + unawaited(navigator.pushNamed(ShopInBitTicketsView.routeName)); + } + } + + void _onOwnedCoinTap(int methodIndex) { + if (!_payNowEnabled) return; + if (_addresses[methodIndex].isEmpty) return; + _selectedMethod = methodIndex; + unawaited(_confirmPayment()); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Payment", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Total", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + Text( + "$_totalPrice EUR", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], + ), + ), + // Status banner + if (_status == 'underpaid') ...[ + SizedBox(height: isDesktop ? 16 : 8), + RoundedWhiteContainer( + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.alertCircle, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.accentColorOrange, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "Additional payment is required. " + "Please use one of the updated payment options.", + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: Theme.of( + context, + ).extension()!.accentColorOrange, + ), + ), + ), + ], + ), + ), + ], + if (_isExpiredOrInvalid) ...[ + SizedBox(height: isDesktop ? 16 : 8), + RoundedWhiteContainer( + child: Column( + children: [ + Row( + children: [ + SvgPicture.asset( + Assets.svg.alertCircle, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.accentColorRed, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "Invoice expired. Refresh it to continue payment.", + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: Theme.of( + context, + ).extension()!.accentColorRed, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + SecondaryButton( + label: "Refresh Invoice", + onPressed: _refreshInvoice, + ), + ], + ), + ), + ], + if (_isTerminal) ...[ + SizedBox(height: isDesktop ? 16 : 8), + RoundedWhiteContainer( + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.checkCircle, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.accentColorGreen, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "Payment received.", + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: Theme.of( + context, + ).extension()!.accentColorGreen, + ), + ), + ), + ], + ), + ), + SizedBox(height: isDesktop ? 16 : 12), + PrimaryButton( + label: _canReturnToRequest ? "Back to Request" : "View My Requests", + onPressed: _canReturnToRequest ? _backToRequest : _goToMyRequests, + ), + ], + if (_isNoPaymentRequired) ...[ + SizedBox(height: isDesktop ? 16 : 8), + RoundedWhiteContainer( + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.checkCircle, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.accentColorGreen, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "No payment required. Your order is fully covered.", + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: Theme.of( + context, + ).extension()!.accentColorGreen, + ), + ), + ), + ], + ), + ), + SizedBox(height: isDesktop ? 16 : 12), + PrimaryButton( + label: _canReturnToRequest ? "Back to Request" : "View My Requests", + onPressed: _canReturnToRequest ? _backToRequest : _goToMyRequests, + ), + ], + SizedBox(height: isDesktop ? 24 : 16), + if (!_isExpiredOrInvalid && !_isNoPaymentRequired) + ShopInBitPaymentMethodList( + methods: _methods, + addresses: _addresses, + enabled: _payNowEnabled, + onPayFromWallet: _onOwnedCoinTap, + onCheckForPayment: (_) => unawaited(_checkForPayment()), + ), + ], + ); + + if (isDesktop) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 750, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "ShopinBit", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 8, + ), + child: SingleChildScrollView(child: content), + ), + ), + ], + ), + ); + } + + return ShopInBitPaymentMobileScaffold( + onBack: _popToTickets, + child: content, + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_send_from_view.dart b/lib/pages/shopinbit/shopinbit_send_from_view.dart new file mode 100644 index 0000000000..fb54b1f41b --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_send_from_view.dart @@ -0,0 +1,526 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../app_config.dart'; +import '../../models/isar/models/blockchain_data/address.dart'; +import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../pages_desktop_specific/desktop_home_view.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../providers/providers.dart'; +import '../../route_generator.dart'; +import '../../themes/coin_icon_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../themes/theme_providers.dart'; +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 '../../utilities/util.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/isar/providers/eth/token_balance_provider.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/models/tx_data.dart'; +import '../../wallets/wallet/impl/ethereum_wallet.dart'; +import '../../wallets/wallet/intermediate/external_wallet.dart'; +import '../../wallets/wallet/wallet.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../home_view/home_view.dart'; +import '../send_view/sub_widgets/building_transaction_dialog.dart'; +import 'shopinbit_confirm_send_view.dart'; + +class ShopInBitSendFromView extends ConsumerStatefulWidget { + const ShopInBitSendFromView({ + super.key, + required this.coin, + required this.apiTicketId, + this.amount, + required this.address, + this.shouldPopRoot = false, + this.tokenContract, + this.routeOnSuccessName, + }); + + static const String routeName = "/shopInBitSendFrom"; + + final CryptoCurrency coin; + final Amount? amount; + final String address; + final int apiTicketId; + final bool shouldPopRoot; + final EthContract? tokenContract; + // If set, overrides the default success route (HomeView/DesktopHomeView). + final String? routeOnSuccessName; + + @override + ConsumerState createState() => + _ShopInBitSendFromViewState(); +} + +class _ShopInBitSendFromViewState extends ConsumerState { + late final CryptoCurrency coin; + late final Amount? amount; + late final String address; + late final int apiTicketId; + late final EthContract? tokenContract; + + @override + void initState() { + coin = widget.coin; + address = widget.address; + amount = widget.amount; + apiTicketId = widget.apiTicketId; + tokenContract = widget.tokenContract; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final List walletIds; + if (tokenContract != null) { + walletIds = ref + .watch(pWallets) + .wallets + .where( + (w) => + w.info.coin == coin && + w.info.tokenContractAddresses.contains(tokenContract!.address), + ) + .map((e) => e.walletId) + .toList(); + } else { + walletIds = ref + .watch(pWallets) + .wallets + .where((e) => e.info.coin == coin) + .map((e) => e.walletId) + .toList(); + } + + final isDesktop = Util.isDesktop; + + return ConditionalParent( + condition: !isDesktop, + builder: (child) { + return Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text("Send from", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ), + ); + }, + child: ConditionalParent( + condition: isDesktop, + builder: (child) => DesktopDialog( + maxHeight: double.infinity, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Send from ${AppConfig.prefix}", + style: STextStyles.desktopH3(context), + ), + ), + DesktopDialogCloseButton( + onPressedOverride: Navigator.of( + context, + rootNavigator: widget.shouldPopRoot, + ).pop, + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: child, + ), + ], + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Row( + children: [ + Text( + amount != null + ? tokenContract != null + ? "You need to send ${amount!.decimal.toStringAsFixed(tokenContract!.decimals)} ${tokenContract!.symbol}" + : "You need to send ${ref.watch(pAmountFormatter(coin)).format(amount!)}" + : "Select a wallet to pay", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle(context), + ), + ], + ), + const SizedBox(height: 16), + ConditionalParent( + condition: !isDesktop, + builder: (child) => Expanded(child: child), + child: ListView.builder( + primary: isDesktop ? false : null, + shrinkWrap: isDesktop, + itemCount: walletIds.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: ShopInBitSendFromCard( + walletId: walletIds[index], + amount: amount, + address: address, + apiTicketId: apiTicketId, + tokenContract: tokenContract, + routeOnSuccessName: widget.routeOnSuccessName, + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class ShopInBitSendFromCard extends ConsumerStatefulWidget { + const ShopInBitSendFromCard({ + super.key, + required this.walletId, + this.amount, + required this.address, + required this.apiTicketId, + this.tokenContract, + this.routeOnSuccessName, + }); + + final String walletId; + final Amount? amount; + final String address; + final int apiTicketId; + final EthContract? tokenContract; + final String? routeOnSuccessName; + + @override + ConsumerState createState() => + _ShopInBitSendFromCardState(); +} + +class _ShopInBitSendFromCardState extends ConsumerState { + late final String walletId; + late final Amount? amount; + late final String address; + late final int apiTicketId; + late final EthContract? tokenContract; + + Future _send() async { + final coin = ref.read(pWalletCoin(walletId)); + + final int fractionDigits = tokenContract != null + ? tokenContract!.decimals + : coin.fractionDigits; + + Amount? sendAmount = amount; + if (sendAmount == null) { + if (ref.read(pShopinBitService).client.sandbox) { + sendAmount = Amount( + rawValue: BigInt.from(10000), + fractionDigits: fractionDigits, + ); + } else { + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Transaction failed", + message: "Payment amount not available yet", + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, + ), + ), + onPressed: () => Navigator.of(context).pop(), + ), + ); + }, + ); + return; + } + } + + bool wasCancelled = false; + + try { + final parentWallet = ref.read(pWallets).getWallet(walletId); + + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopDialog( + maxWidth: 400, + maxHeight: double.infinity, + child: Padding(padding: const EdgeInsets.all(32), child: child), + ), + child: BuildingTransactionDialog( + coin: coin, + isSpark: false, + onCancel: () { + wasCancelled = true; + + Navigator.of(context).pop(); + }, + ), + ); + }, + ), + ); + + if (parentWallet is ExternalWallet) { + await parentWallet.init(); + await parentWallet.open(); + } + + final time = Future.delayed(const Duration(milliseconds: 2500)); + + TxData txData; + + // Use token wallet for ERC-20 tokens, parent wallet otherwise + final wallet = tokenContract != null + ? Wallet.loadTokenWallet( + ethWallet: parentWallet as EthereumWallet, + contract: tokenContract!, + ) + : parentWallet; + + if (tokenContract != null) { + await wallet.init(); + } + + final addressType = + wallet.cryptoCurrency.getAddressType(address) ?? + parentWallet.cryptoCurrency.getAddressType(address) ?? + AddressType.ethereum; + + final recipient = TxRecipient( + address: address, + amount: sendAmount, + isChange: false, + addressType: addressType, + ); + + final txDataFuture = wallet.prepareSend( + txData: TxData( + recipients: [recipient], + feeRateType: FeeRateType.average, + ), + ); + + final results = await Future.wait([txDataFuture, time]); + + txData = results.first as TxData; + + if (!wasCancelled) { + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + + txData = txData.copyWith(note: "ShopinBit payment"); + + if (mounted) { + await Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => ShopInBitConfirmSendView( + txData: txData, + walletId: walletId, + routeOnSuccessName: + widget.routeOnSuccessName ?? + (Util.isDesktop + ? DesktopHomeView.routeName + : HomeView.routeName), + apiTicketId: apiTicketId, + tokenContract: tokenContract, + popThroughRouteName: + Util.isDesktop && widget.routeOnSuccessName != null + ? ShopInBitSendFromView.routeName + : null, + ), + settings: const RouteSettings( + name: ShopInBitConfirmSendView.routeName, + ), + ), + ); + } + } + } catch (e, s) { + Logging.instance.e("$e\n$s", error: e, stackTrace: s); + if (mounted && !wasCancelled) { + Navigator.of(context, rootNavigator: true).pop(); + + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Transaction failed", + message: e.toString(), + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, + ), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ); + }, + ); + } + } + } + + @override + void initState() { + walletId = widget.walletId; + amount = widget.amount; + address = widget.address; + apiTicketId = widget.apiTicketId; + tokenContract = widget.tokenContract; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final coin = ref.watch(pWalletCoin(walletId)); + + return RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + child: MaterialButton( + splashColor: Theme.of(context).extension()!.highlight, + key: Key("walletsSheetItemButtonKey_$walletId"), + padding: const EdgeInsets.all(8), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: () async { + if (mounted) { + unawaited(_send()); + } + }, + child: Row( + children: [ + Container( + decoration: BoxDecoration( + color: ref.watch(pCoinColor(coin)).withOpacity(0.5), + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Padding( + padding: const EdgeInsets.all(6), + child: SvgPicture.file( + File(ref.watch(coinIconProvider(coin))), + width: 24, + height: 24, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tokenContract != null + ? "${ref.watch(pWalletName(walletId))} (${tokenContract!.symbol})" + : ref.watch(pWalletName(walletId)), + style: STextStyles.titleBold12(context), + ), + const SizedBox(height: 2), + if (tokenContract != null) + Builder( + builder: (context) { + final balance = ref.watch( + pTokenBalance(( + walletId: walletId, + contractAddress: tokenContract!.address, + )), + ); + return Text( + "${balance.spendable.decimal.toStringAsFixed(tokenContract!.decimals)} ${tokenContract!.symbol}", + style: STextStyles.itemSubtitle(context), + ); + }, + ) + else + Text( + ref + .watch(pAmountFormatter(coin)) + .format( + ref.watch(pWalletBalance(walletId)).spendable, + ), + style: STextStyles.itemSubtitle(context), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_settings_view.dart b/lib/pages/shopinbit/shopinbit_settings_view.dart new file mode 100644 index 0000000000..edd209fc99 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_settings_view.dart @@ -0,0 +1,799 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../notifications/show_flush_bar.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../providers/providers.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/rounded_container.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../../widgets/textfields/adaptive_text_field.dart'; + +class ShopInBitSettingsView extends ConsumerStatefulWidget { + const ShopInBitSettingsView({super.key}); + + static const String routeName = "/shopInBitSettings"; + + @override + ConsumerState createState() => + _ShopInBitSettingsViewState(); +} + +class _ShopInBitSettingsViewState extends ConsumerState { + final _manualKeyController = TextEditingController(); + + String? _currentKey; + bool _loading = false; + + @override + void initState() { + super.initState(); + + () async { + final settings = await ref + .read(pSharedDrift) + .shopInBitSettingsDao + .getCurrentSettings(); + if (mounted) { + setState(() => _currentKey = settings?.customerKey); + } + }(); + } + + @override + void dispose() { + _manualKeyController.dispose(); + super.dispose(); + } + + Future _generate() async { + if (_currentKey != null) { + final proceed = await _showChangeWarning(); + if (proceed != true) return; + } + + setState(() => _loading = true); + try { + final String key; + if (_currentKey != null) { + key = await ref.read(pShopinBitService).generateCustomerKey(); + } else { + key = await ref.read(pShopinBitService).ensureCustomerKey(); + } + setState(() => _currentKey = key); + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Customer key generated", + context: context, + ), + ); + } + } catch (e, s) { + Logging.instance.e( + "Failed to generate ShopInBit customer key", + error: e, + stackTrace: s, + ); + if (mounted) { + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to generate key", + maxWidth: Util.isDesktop ? 500 : null, + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } finally { + // Awaiting the error dialog above means the widget can unmount before + // we get here. + if (mounted) setState(() => _loading = false); + } + } + + Future _setManualKey() async { + final newKey = _manualKeyController.text.trim(); + if (newKey.isEmpty) return; + + if (_currentKey != null) { + final proceed = await _showChangeWarning(); + if (proceed != true) return; + } + + setState(() => _loading = true); + try { + await ref.read(pShopinBitService).recoverCustomerKey(newKey); + setState(() { + _currentKey = newKey; + _manualKeyController.clear(); + }); + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Customer key set", + context: context, + ), + ); + } + } catch (e, s) { + Logging.instance.e( + "Failed to set ShopInBit customer key", + error: e, + stackTrace: s, + ); + if (mounted) { + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to set key", + maxWidth: Util.isDesktop ? 500 : null, + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } finally { + // Awaiting the error dialog above means the widget can unmount before + // we get here. + if (mounted) setState(() => _loading = false); + } + } + + Future _showChangeWarning() async { + final confirmSaved = await showDialog( + context: context, + builder: (context) { + // TODO: this conditional can probably be merged when we have time + if (Util.isDesktop) { + return DesktopDialog( + maxWidth: 550, + maxHeight: double.infinity, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(32), + child: Text( + "Save your current key", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Your current customer key is:", + style: STextStyles.desktopTextExtraExtraSmall(context), + ), + const SizedBox(height: 8), + RoundedWhiteContainer( + borderColor: Theme.of( + context, + ).extension()!.textSubtitle6, + child: SelectableText( + _currentKey!, + style: STextStyles.desktopTextSmall(context), + ), + ), + const SizedBox(height: 16), + Text( + "Changing your key will disconnect you from " + "existing ShopinBit requests. Make sure " + "you have saved your current key before " + "proceeding.", + style: STextStyles.desktopTextExtraExtraSmall(context), + ), + const SizedBox(height: 32), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: ButtonHeight.l, + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(), + ), + ), + const SizedBox(width: 24), + Expanded( + child: PrimaryButton( + label: "I saved my key", + buttonHeight: ButtonHeight.l, + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(true), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } else { + return StackDialogBase( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Save your current key", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 8), + SelectableText( + "Your current customer key is:", + style: STextStyles.smallMed14(context), + ), + const SizedBox(height: 8), + RoundedContainer( + color: Theme.of( + context, + ).extension()!.warningBackground, + child: SelectableText( + _currentKey!, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of( + context, + ).extension()!.warningForeground, + ), + ), + ), + const SizedBox(height: 8), + SelectableText( + "Changing your key will disconnect you from " + "existing ShopinBit conversations. Make sure " + "you have saved your current key before " + "proceeding.", + style: STextStyles.smallMed14(context), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + onPressed: () => Navigator.of(context).pop(), + child: Text( + "Cancel", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextButton( + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + onPressed: () => Navigator.of(context).pop(true), + child: Text( + "I saved my key", + style: STextStyles.button(context), + ), + ), + ), + ], + ), + ], + ), + ); + } + }, + ); + + if (confirmSaved != true || !mounted) return false; + + return showDialog( + context: context, + barrierDismissible: true, + builder: (_) => _VerifyKeyDialog(currentKey: _currentKey!), + ); + } + + @override + Widget build(BuildContext context) { + // TODO: this conditional can probably be merged when we have time + if (Util.isDesktop) { + return SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(right: 30), + child: RoundedWhiteContainer( + radiusMultiplier: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + + child: Container( + decoration: BoxDecoration( + color: const Color(0xFFE0E3E3), + borderRadius: .circular(54), + ), + width: 48, + height: 48, + child: Center( + child: SizedBox( + width: 28, + height: 28, + child: SvgPicture.asset( + Assets.svg.key, + colorFilter: const .mode(Colors.black, .srcIn), + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Customer Key", + style: STextStyles.desktopTextSmall(context), + ), + const SizedBox(height: 16), + Text( + "Your customer key identifies you to ShopinBit. " + "Save it to restore access to your conversations " + "on another device. If you change it, you will " + "lose access to existing conversations.", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + const SizedBox(height: 20), + if (_currentKey != null) ...[ + Text( + "Current key", + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + SelectableText( + _currentKey!, + style: STextStyles.desktopTextSmall(context), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: () async { + await Clipboard.setData( + ClipboardData(text: _currentKey!), + ); + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "Key copied to clipboard", + context: context, + ), + ); + } + }, + child: SvgPicture.asset( + Assets.svg.copy, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.textDark3, + ), + ), + ], + ), + const SizedBox(height: 20), + ] else + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Text( + "No key set", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + ), + PrimaryButton( + width: 210, + buttonHeight: ButtonHeight.m, + enabled: !_loading, + label: _currentKey == null + ? "Generate key" + : "Generate new key", + onPressed: _generate, + ), + const SizedBox(height: 20), + Text( + "Restore key", + style: STextStyles.desktopTextSmall(context), + ), + const SizedBox(height: 8), + Text( + "Enter a previously saved customer key to " + "restore access to your ShopinBit " + "conversations.", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: 512, + child: AdaptiveTextField( + labelText: "Enter customer key", + controller: _manualKeyController, + onChangedComprehensive: (_) => setState(() {}), + ), + ), + const SizedBox(height: 16), + PrimaryButton( + width: 210, + buttonHeight: ButtonHeight.m, + enabled: + !_loading && + _manualKeyController.text.trim().isNotEmpty, + label: "Set key", + onPressed: _setManualKey, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } else { + return Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.only(left: 12, top: 12, right: 12), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 24, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Customer Key", + style: STextStyles.titleBold12(context), + ), + const SizedBox(height: 8), + Text( + "Your customer key identifies you " + "to ShopinBit. Save it to restore " + "access to your conversations on " + "another device. If you change it, " + "you will lose access to existing " + "conversations.", + style: STextStyles.itemSubtitle12( + context, + ), + ), + const SizedBox(height: 16), + if (_currentKey != null) ...[ + RoundedContainer( + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + child: Row( + children: [ + Expanded( + child: SelectableText( + _currentKey!, + style: STextStyles.field( + context, + ), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () async { + await Clipboard.setData( + ClipboardData( + text: _currentKey!, + ), + ); + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: + "Key copied to clipboard", + context: context, + ), + ); + } + }, + child: SvgPicture.asset( + Assets.svg.copy, + width: 20, + height: 20, + color: Theme.of(context) + .extension()! + .textDark3, + ), + ), + ], + ), + ), + ] else + Text( + "No key set", + style: STextStyles.itemSubtitle( + context, + ), + ), + const SizedBox(height: 16), + PrimaryButton( + label: _currentKey == null + ? "Generate key" + : "Generate new key", + enabled: !_loading, + onPressed: _generate, + ), + ], + ), + ), + const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Restore key", + style: STextStyles.titleBold12(context), + ), + const SizedBox(height: 8), + Text( + "Enter a previously saved customer " + "key to restore access to your " + "ShopinBit conversations.", + style: STextStyles.itemSubtitle12( + context, + ), + ), + const SizedBox(height: 12), + AdaptiveTextField( + labelText: "Enter customer key", + controller: _manualKeyController, + onChangedComprehensive: (_) => + setState(() {}), + ), + const SizedBox(height: 12), + PrimaryButton( + label: "Set key", + enabled: + !_loading && + _manualKeyController.text + .trim() + .isNotEmpty, + onPressed: _setManualKey, + ), + ], + ), + ), + const SizedBox(height: 12), + ], + ), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } + } +} + +class _VerifyKeyDialog extends StatefulWidget { + const _VerifyKeyDialog({super.key, required this.currentKey}); + + final String currentKey; + + @override + State<_VerifyKeyDialog> createState() => _VerifyKeyDialogState(); +} + +class _VerifyKeyDialogState extends State<_VerifyKeyDialog> { + final _verifyKeyController = TextEditingController(); + + bool _confirmEnabled = false; + + @override + void dispose() { + _verifyKeyController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(32), + child: Text( + "Verify your key", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: child, + ), + ], + ), + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => StackDialogBase( + keyboardPaddingAmount: MediaQuery.of(context).viewInsets.bottom, + child: Column( + mainAxisSize: .min, + children: [ + Text("Verify your key", style: STextStyles.pageTitleH2(context)), + const SizedBox(height: 24), + child, + ], + ), + ), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text( + "Enter your current customer key to " + "confirm you have saved it.", + style: Util.isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.smallMed14(context), + ), + Util.isDesktop + ? const SizedBox(height: 32) + : const SizedBox(height: 16), + AdaptiveTextField( + labelText: "Enter current key", + controller: _verifyKeyController, + onChangedComprehensive: (_) { + if (_verifyKeyController.text == widget.currentKey) { + if (!_confirmEnabled) setState(() => _confirmEnabled = true); + } else { + if (_confirmEnabled) setState(() => _confirmEnabled = false); + } + }, + ), + Util.isDesktop + ? const SizedBox(height: 32) + : const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: () => Navigator.of( + context, + rootNavigator: Util.isDesktop, + ).pop(false), + ), + ), + Util.isDesktop + ? const SizedBox(width: 24) + : const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Confirm", + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + enabled: _confirmEnabled, + onPressed: _confirmEnabled + ? () => Navigator.of( + context, + rootNavigator: Util.isDesktop, + ).pop(true) + : null, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_setup_view.dart b/lib/pages/shopinbit/shopinbit_setup_view.dart new file mode 100644 index 0000000000..6e6ba90fb6 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_setup_view.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../notifications/show_flush_bar.dart'; +import '../../providers/db/drift_provider.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/text_styles.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/icon_widgets/copy_icon.dart'; +import '../../widgets/rounded_white_container.dart'; +import 'shopinbit_step_2.dart'; + +class ShopInBitSetupView extends ConsumerStatefulWidget { + const ShopInBitSetupView({super.key}); + + static const String routeName = "/shopInBitSetup"; + + @override + ConsumerState createState() => _ShopInBitSetupViewState(); +} + +class _ShopInBitSetupViewState extends ConsumerState { + late final Future _keyFuture; + String? _key; + + @override + void initState() { + super.initState(); + _keyFuture = ref.read(pShopinBitService).ensureCustomerKey(); + () async { + final key = await _keyFuture; + if (mounted) setState(() => _key = key); + }(); + } + + Future _completeSetup() async { + final key = _key; + if (key == null) return; + await ref + .read(pSharedDrift) + .shopInBitSettingsDao + .setSetupComplete(key, true); + + if (mounted) { + await Navigator.of( + context, + ).pushReplacementNamed(ShopInBitStep2.routeName); + } + } + + @override + Widget build(BuildContext context) { + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Your ShopinBit Customer Key", + style: STextStyles.pageTitleH1(context), + ), + const SizedBox(height: 8), + Text( + "This is your ShopinBit customer key. Save it " + "somewhere safe: you'll need it to recover " + "your ShopinBit account on a new device.", + style: STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 16), + FutureBuilder( + future: _keyFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != + ConnectionState.done) { + return const Center( + child: CircularProgressIndicator(), + ); + } + if (snapshot.hasError) { + return Text( + "Failed to generate key. Please try again.", + style: STextStyles.itemSubtitle(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textError, + ), + ); + } + final key = snapshot.data!; + return RoundedWhiteContainer( + child: Row( + children: [ + Expanded( + child: SelectableText( + key, + style: STextStyles.itemSubtitle12( + context, + ), + ), + ), + IconButton( + icon: const CopyIcon( + width: 20, + height: 20, + ), + onPressed: () { + Clipboard.setData( + ClipboardData(text: key), + ); + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard!", + context: context, + ); + }, + ), + ], + ), + ); + }, + ), + const Spacer(), + PrimaryButton( + label: "Complete Setup", + enabled: _key != null, + onPressed: _key != null ? _completeSetup : null, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart new file mode 100644 index 0000000000..03e12208b0 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -0,0 +1,607 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../db/drift/shared_db/shared_database.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../services/shopinbit/src/models/address.dart'; +import '../../services/shopinbit/src/models/payment.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/detail_item.dart'; +import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/stack_dialog.dart'; +import '../../widgets/textfields/adaptive_text_field.dart'; +import 'shopinbit_payment_shared.dart'; +import 'shopinbit_payment_view.dart'; +import 'step_4_components/shopinbit_country_picker.dart'; +import 'step_4_components/shopinbit_state_picker.dart'; + +class ShopInBitShippingView extends ConsumerStatefulWidget { + const ShopInBitShippingView({ + super.key, + required this.ticket, + required this.countries, + }); + + static const String routeName = "/shopInBitShipping"; + + final ShopInBitTicket ticket; + final List> countries; + + @override + ConsumerState createState() => + _ShopInBitShippingViewState(); +} + +class _ShopInBitShippingViewState extends ConsumerState { + late final TextEditingController _nameFirstController; + late final TextEditingController _nameLastController; + late final TextEditingController _streetController; + late final TextEditingController _cityController; + late final TextEditingController _postalCodeController; + late final FocusNode _nameFirstFocusNode; + late final FocusNode _nameLastFocusNode; + late final FocusNode _streetFocusNode; + late final FocusNode _cityFocusNode; + late final FocusNode _postalCodeFocusNode; + + // Billing address controllers + late final TextEditingController _billingFirstNameController; + late final TextEditingController _billingLastNameController; + late final TextEditingController _billingStreetController; + late final TextEditingController _billingCityController; + late final TextEditingController _billingPostalCodeController; + late final FocusNode _billingFirstNameFocusNode; + late final FocusNode _billingLastNameFocusNode; + late final FocusNode _billingStreetFocusNode; + late final FocusNode _billingCityFocusNode; + late final FocusNode _billingPostalCodeFocusNode; + + String? _billingSelectedCountryIso; + bool _differentBilling = false; + + late final String _selectedCountryIso; + late final String _deliveryCountryLabel; + + late final String? _selectedState; + + String? _selectedBillingState; + + late bool _requiresState; + + bool _submitting = false; + + bool get _canContinue { + if (_submitting) return false; + final shippingValid = + _nameFirstController.text.trim().isNotEmpty && + _nameLastController.text.trim().isNotEmpty && + _streetController.text.trim().isNotEmpty && + _cityController.text.trim().isNotEmpty && + _postalCodeController.text.trim().isNotEmpty; + if (!shippingValid) return false; + if (_differentBilling) { + return _billingFirstNameController.text.trim().isNotEmpty && + _billingLastNameController.text.trim().isNotEmpty && + _billingStreetController.text.trim().isNotEmpty && + _billingCityController.text.trim().isNotEmpty && + _billingPostalCodeController.text.trim().isNotEmpty && + _billingSelectedCountryIso != null; + } + return true; + } + + @override + void initState() { + super.initState(); + _nameFirstController = TextEditingController(); + _nameLastController = TextEditingController(); + _streetController = TextEditingController(); + _cityController = TextEditingController(); + _postalCodeController = TextEditingController(); + _nameFirstFocusNode = FocusNode(); + _nameLastFocusNode = FocusNode(); + _streetFocusNode = FocusNode(); + _cityFocusNode = FocusNode(); + _postalCodeFocusNode = FocusNode(); + + _billingFirstNameController = TextEditingController(); + _billingLastNameController = TextEditingController(); + _billingStreetController = TextEditingController(); + _billingCityController = TextEditingController(); + _billingPostalCodeController = TextEditingController(); + _billingFirstNameFocusNode = FocusNode(); + _billingLastNameFocusNode = FocusNode(); + _billingStreetFocusNode = FocusNode(); + _billingCityFocusNode = FocusNode(); + _billingPostalCodeFocusNode = FocusNode(); + + _selectedCountryIso = widget.ticket.deliveryCountry; + + _requiresState = switch (_selectedCountryIso) { + "US" || "CA" => widget.ticket.category != .travel, + _ => false, + }; + + if (_requiresState) { + final parts = widget.ticket.messages.firstOrNull?.content.split("\n"); + if (parts == null) { + Logging.instance.f("Missing state/province where required!"); + throw ArgumentError("Missing first ticket message"); + } + + final line = parts + .where( + (e) => e.startsWith("Delivery state:") || e.startsWith("State:"), + ) + .firstOrNull; + if (line == null) { + Logging.instance.f("Missing state/province in first message!"); + throw ArgumentError("Missing state/province in first ticket message"); + } + + _selectedState = line + .replaceFirst("Delivery state:", "") + .replaceFirst("State:", "") + .trim(); + } else { + _selectedState = null; + } + + // firstWhere should never fail here as the caller of this widget must + // check that countries contains the expected value. Failure here should be + // considered unrecoverable/fatal as it indicates a bug elsewhere + _deliveryCountryLabel = + widget.countries.firstWhere( + (e) => e["iso"] == _selectedCountryIso, + )["label"] + as String; + + for (final node in [ + _nameFirstFocusNode, + _nameLastFocusNode, + _streetFocusNode, + _cityFocusNode, + _postalCodeFocusNode, + _billingFirstNameFocusNode, + _billingLastNameFocusNode, + _billingStreetFocusNode, + _billingCityFocusNode, + _billingPostalCodeFocusNode, + ]) { + node.addListener(() => setState(() {})); + } + } + + @override + void dispose() { + _nameFirstController.dispose(); + _nameLastController.dispose(); + _streetController.dispose(); + _cityController.dispose(); + _postalCodeController.dispose(); + _nameFirstFocusNode.dispose(); + _nameLastFocusNode.dispose(); + _streetFocusNode.dispose(); + _cityFocusNode.dispose(); + _postalCodeFocusNode.dispose(); + _billingFirstNameController.dispose(); + _billingLastNameController.dispose(); + _billingStreetController.dispose(); + _billingCityController.dispose(); + _billingPostalCodeController.dispose(); + _billingFirstNameFocusNode.dispose(); + _billingLastNameFocusNode.dispose(); + _billingStreetFocusNode.dispose(); + _billingCityFocusNode.dispose(); + _billingPostalCodeFocusNode.dispose(); + super.dispose(); + } + + Future _continue() async { + final nameFirst = _nameFirstController.text.trim(); + final nameLast = _nameLastController.text.trim(); + final street = _streetController.text.trim(); + final city = _cityController.text.trim(); + final postalCode = _postalCodeController.text.trim(); + final country = _selectedCountryIso; + + PaymentInfo? paymentInfo; + setState(() => _submitting = true); + try { + Address? billingAddress; + if (_differentBilling) { + billingAddress = Address( + firstName: _billingFirstNameController.text.trim(), + lastName: _billingLastNameController.text.trim(), + street: _billingStreetController.text.trim(), + zip: _billingPostalCodeController.text.trim(), + city: _billingCityController.text.trim(), + country: _requiresState ? country : _billingSelectedCountryIso!, + state: _requiresState ? _selectedState : _selectedBillingState, + ); + } + + final resp = await ref + .read(pShopinBitService) + .client + .submitAddress( + widget.ticket.apiTicketId, + shipping: Address( + firstName: nameFirst, + lastName: nameLast, + street: street, + zip: postalCode, + city: city, + country: country, + state: _requiresState ? _selectedState! : null, + ), + billing: billingAddress, + customerKey: widget.ticket.customerKey, + ); + + if (resp.hasError) { + // Sandbox may fail here; continue anyway. + Logging.instance.w("submitAddress failed", error: resp.exception); + } + + paymentInfo = await fetchShopInBitPaymentInfo( + ref.read(pShopinBitService).client, + widget.ticket.apiTicketId, + widget.ticket.customerKey, + ); + } catch (e, s) { + Logging.instance.e("submitAddress threw", error: e, stackTrace: s); + } finally { + if (mounted) setState(() => _submitting = false); + } + + if (!mounted) return; + + // no_payment_required legitimately has empty payment_links (voucher/credit + // covers it): open the payment view, which shows a "covered" state. + if (paymentInfo == null || + (paymentInfo.paymentLinks.isEmpty && + paymentInfo.status != 'no_payment_required')) { + // No live invoice; don't open a payment view with empty addresses. + await _showPaymentLoadError( + "We couldn't load the payment details for this order. " + "Please try again in a moment.", + ); + return; + } + + await Navigator.of(context).pushNamed( + ShopInBitPaymentView.routeName, + arguments: ( + apiTicketId: widget.ticket.apiTicketId, + paymentInfo: paymentInfo, + ), + ); + } + + Future _showPaymentLoadError(String message) async { + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Couldn't load payment details", + maxWidth: Util.isDesktop ? 500 : null, + message: message, + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final spacing = SizedBox(height: isDesktop ? 16 : 12); + + final content = Column( + mainAxisSize: .min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Shipping address", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + Text( + "Where should we deliver your order?", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + SizedBox(height: isDesktop ? 32 : 24), + AdaptiveTextField( + controller: _nameFirstController, + focusNode: _nameFirstFocusNode, + labelText: "First name", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), + ), + spacing, + AdaptiveTextField( + controller: _nameLastController, + focusNode: _nameLastFocusNode, + labelText: "Last name", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), + ), + spacing, + AdaptiveTextField( + controller: _streetController, + focusNode: _streetFocusNode, + labelText: "Street address", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), + ), + spacing, + Row( + children: [ + Expanded( + child: AdaptiveTextField( + controller: _cityController, + focusNode: _cityFocusNode, + labelText: "City", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), + ), + ), + SizedBox(width: isDesktop ? 16 : 12), + Expanded( + child: AdaptiveTextField( + controller: _postalCodeController, + focusNode: _postalCodeFocusNode, + labelText: "Postal code", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), + ), + ), + ], + ), + if (_requiresState) spacing, + if (_requiresState) DetailItem(title: "State", detail: _selectedState!), + spacing, + DetailItem(title: "Country", detail: _deliveryCountryLabel), + spacing, + // Billing address toggle. + GestureDetector( + onTap: () { + setState(() { + _differentBilling = !_differentBilling; + if (!_differentBilling) { + // Clear billing fields. + _billingFirstNameController.clear(); + _billingLastNameController.clear(); + _billingStreetController.clear(); + _billingCityController.clear(); + _billingPostalCodeController.clear(); + _billingSelectedCountryIso = null; + _selectedBillingState = null; + } + }); + }, + child: Row( + children: [ + SizedBox( + width: 24, + height: 24, + child: Checkbox( + value: _differentBilling, + onChanged: (v) { + setState(() { + _differentBilling = v ?? false; + if (!_differentBilling) { + _billingFirstNameController.clear(); + _billingLastNameController.clear(); + _billingStreetController.clear(); + _billingCityController.clear(); + _billingPostalCodeController.clear(); + _billingSelectedCountryIso = null; + _selectedBillingState = null; + } + }); + }, + activeColor: Theme.of( + context, + ).extension()!.accentColorBlue, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + "Different billing address?", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + ), + ], + ), + ), + // Billing fields (expanded). + if (_differentBilling) ...[ + SizedBox(height: isDesktop ? 24 : 16), + Text( + "Billing address", + style: isDesktop + ? STextStyles.desktopTextMedium(context) + : STextStyles.titleBold12(context), + ), + spacing, + AdaptiveTextField( + controller: _billingFirstNameController, + labelText: "First name", + focusNode: _billingFirstNameFocusNode, + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), + ), + spacing, + AdaptiveTextField( + controller: _billingLastNameController, + labelText: "Last name", + focusNode: _billingLastNameFocusNode, + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), + ), + spacing, + AdaptiveTextField( + controller: _billingStreetController, + focusNode: _billingStreetFocusNode, + labelText: "Street address", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), + ), + spacing, + Row( + children: [ + Expanded( + child: AdaptiveTextField( + controller: _billingCityController, + focusNode: _billingCityFocusNode, + labelText: "City", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), + ), + ), + SizedBox(width: isDesktop ? 16 : 12), + Expanded( + child: AdaptiveTextField( + controller: _billingPostalCodeController, + focusNode: _billingPostalCodeFocusNode, + labelText: "Postal code", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), + ), + ), + ], + ), + spacing, + + if (_requiresState) ...[ + DetailItem(title: "Billing state", detail: _selectedState!), + spacing, + DetailItem(title: "Billing country", detail: _deliveryCountryLabel), + ], + + if (!_requiresState) ...[ + ShopInBitStatePicker( + countryIso: _billingSelectedCountryIso!, + selectedState: _selectedBillingState, + onChanged: (state) { + if (state != _selectedBillingState && mounted) { + setState(() { + _selectedBillingState = state; + }); + } + }, + ), + spacing, + ShopInBitCountryPicker( + hintText: "Billing country", + selectedIso: _billingSelectedCountryIso, + onChanged: (data) => setState(() { + _billingSelectedCountryIso = data?.code; + _requiresState = data?.requiresState ?? false; + }), + ), + ], + ], + const SizedBox(height: 24), + PrimaryButton( + label: _submitting ? "Submitting..." : "Continue to payment", + enabled: _canContinue, + onPressed: _canContinue ? _continue : null, + ), + ], + ); + + if (isDesktop) { + return SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "ShopinBit", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: Padding( + padding: const .only( + left: 32, + right: 32, + bottom: 32, + top: 16, + ), + child: SingleChildScrollView(child: content), + ), + ), + ], + ), + ), + ); + } + + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight(child: content), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_step_2.dart b/lib/pages/shopinbit/shopinbit_step_2.dart new file mode 100644 index 0000000000..742f198e78 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_step_2.dart @@ -0,0 +1,284 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../db/drift/shared_db/shared_database.dart'; +import '../../models/shopinbit/shopinbit_enums.dart'; +import '../../providers/providers.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart'; +import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/rounded_container.dart'; +import '../exchange_view/sub_widgets/step_row.dart'; +import 'shopinbit_step_3.dart'; +import 'shopinbit_step_4.dart'; + +class ShopInBitStep2 extends ConsumerStatefulWidget { + const ShopInBitStep2({super.key, this.isActuallyFirstStep = false}); + + static const String routeName = "/shopInBitStep2"; + + final bool isActuallyFirstStep; + + @override + ConsumerState createState() => _ShopInBitStep2State(); +} + +class _ShopInBitStep2State extends ConsumerState { + ShopInBitCategory? _selected; + + Future _continue() async { + final category = _selected!; + + final settings = await ref + .read(pSharedDrift) + .shopInBitSettingsDao + .getCurrentSettings(); + + if (settings == null) { + throw Exception("Shopinbit settings should never be null here. Fixme"); + } + + if (!mounted) return; + + final skipGuidelines = settings.guidelinesAcceptedFor(category); + + if (skipGuidelines) { + await Navigator.of( + context, + ).pushNamed(ShopInBitStep4.routeName, arguments: category); + } else { + await Navigator.of(context).pushNamed( + ShopInBitStep3.routeName, + arguments: (category: category, customerKey: settings.customerKey), + ); + } + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + return ConditionalParent( + condition: isDesktop, + builder: (content) => SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + widget.isActuallyFirstStep + ? const SizedBox(width: 32) + : const AppBarBackButton( + isCompact: true, + iconSize: 23, + ), + Text("ShopinBit", style: STextStyles.desktopH3(context)), + ], + ), + DesktopDialogCloseButton( + onPressedOverride: () => + NestedNavigatorDialog.of(context).close( + args: widget.isActuallyFirstStep + ? const .noWarning() + : const .genericWarning(), + ), + ), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: content, + ), + ), + ], + ), + ), + ), + child: ConditionalParent( + condition: !isDesktop, + builder: (content) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: const AppBarBackButton(), + title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight(child: content), + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!isDesktop) + StepRow( + count: 4, + current: 1, + width: MediaQuery.of(context).size.width - 32, + ), + const SizedBox(height: 14), + Text( + "Choose a service", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + Text( + "Select the type of service you need.", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + SizedBox(height: isDesktop ? 32 : 24), + _CategoryCard( + category: .concierge, + title: "Concierge", + description: "Purchase products and services online.", + iconAsset: Assets.svg.dollarSign, + isSelected: _selected == .concierge, + onTap: (value) => setState(() => _selected = value), + ), + SizedBox(height: isDesktop ? 16 : 12), + _CategoryCard( + category: .travel, + title: "Travel", + description: "Book flights, hotels, and more.", + iconAsset: Assets.svg.circleArrowUpRight, + isSelected: _selected == .travel, + onTap: (value) => setState(() => _selected = value), + ), + SizedBox(height: isDesktop ? 16 : 12), + _CategoryCard( + category: .car, + title: "Car", + description: "Find and purchase vehicles.", + iconAsset: Assets.svg.boxAuto, + isSelected: _selected == .car, + onTap: (value) => setState(() => _selected = value), + ), + isDesktop ? const SizedBox(height: 32) : const Spacer(), + PrimaryButton( + label: "Next", + enabled: _selected != null, + onPressed: _selected != null ? _continue : null, + ), + if (isDesktop) const SizedBox(height: 32), + ], + ), + ), + ); + } +} + +class _CategoryCard extends StatelessWidget { + const _CategoryCard({ + super.key, + required this.category, + required this.title, + required this.description, + required this.iconAsset, + required this.isSelected, + required this.onTap, + }); + + final ShopInBitCategory category; + final String title; + final String description; + final String iconAsset; + final bool isSelected; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) { + final StackColors colors = Theme.of(context).extension()!; + final isDesktop = Util.isDesktop; + + return RoundedContainer( + color: colors.popupBG, + borderColor: colors.textFieldDefaultBG, + onPressed: () => onTap(category), + child: Row( + children: [ + Container( + width: isDesktop ? 48 : 40, + height: isDesktop ? 48 : 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colors.textDark.withOpacity(0.1), + ), + alignment: Alignment.center, + child: SvgPicture.asset( + iconAsset, + width: isDesktop ? 24 : 20, + height: isDesktop ? 24 : 20, + color: colors.textDark, + ), + ), + SizedBox(width: isDesktop ? 16 : 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + const SizedBox(height: 4), + Text( + description, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12( + context, + ).copyWith(color: colors.textSubtitle1), + ), + ], + ), + ), + if (isSelected) + SvgPicture.asset( + Assets.svg.checkCircle, + width: isDesktop ? 24 : 20, + height: isDesktop ? 24 : 20, + colorFilter: ColorFilter.mode(colors.textDark, .srcIn), + ), + ], + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_step_3.dart b/lib/pages/shopinbit/shopinbit_step_3.dart new file mode 100644 index 0000000000..eb997111e8 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_step_3.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/shopinbit/shopinbit_enums.dart'; +import '../../providers/providers.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../exchange_view/sub_widgets/step_row.dart'; +import 'shopinbit_step_4.dart'; + +class ShopInBitStep3 extends ConsumerStatefulWidget { + const ShopInBitStep3({ + super.key, + required this.category, + required this.customerKey, + }); + + static const String routeName = "/shopInBitStep3"; + + final ShopInBitCategory category; + final String customerKey; + + @override + ConsumerState createState() => _ShopInBitStep3State(); +} + +class _ShopInBitStep3State extends ConsumerState { + bool _agreed = false; + + String _guidelinesText() { + switch (widget.category) { + case ShopInBitCategory.concierge: + return "Concierge Service Guidelines:\n\n" + "\u2022 Minimum: fee of 100 EUR or minimum order " + "value of 1,000 EUR.\n\n" + "\u2022 Service Fee: 10% of the order total.\n\n" + "\u2022 Only legal products and services are allowed.\n\n" + "\u2022 Prohibited: precious metals, prescription " + "medicine, live animals, weapons, adult " + "entertainment, EU real estate.\n\n" + "\u2022 Provide a clear and detailed description of the " + "product or service you want to purchase.\n\n" + "\u2022 Include links to the exact item when possible."; + case ShopInBitCategory.travel: + return "Travel Service Guidelines:\n\n" + "\u2022 Recommended budget: 2,500 EUR and above " + "for custom trips.\n\n" + "\u2022 Minimum: fee of 100 EUR or booking value " + "of 1,000 EUR.\n\n" + "\u2022 Service Fee: 10% of the booking amount.\n\n" + "\u2022 Only legal travel services are allowed.\n\n" + "\u2022 Prohibited: sanctioned destinations, illegal " + "bookings, adult entertainment, real estate " + "disguised as travel.\n\n" + "\u2022 Provide full details of your travel request " + "including dates, destinations, and preferences."; + case ShopInBitCategory.car: + return "Car Service Guidelines:\n\n" + "\u2022 Minimum Order: \u20AC20,000.\n\n" + "\u2022 Research Fee: \u20AC223 (incl. VAT) \u2014 " + "one-time, credited toward purchase.\n\n" + "\u2022 Service Fee: 10% of the vehicle value.\n\n" + "\u2022 Only legal vehicle transactions are allowed.\n\n" + "\u2022 Prohibited: export to sanctioned regions, " + "armored/military vehicles without licensing, " + "weapons/tactical accessories, real estate " + "disguised as vehicle purchases.\n\n" + "\u2022 Provide details about the make, model, year, " + "and any specific requirements."; + } + } + + Future _continue() async { + await ref + .read(pSharedDrift) + .shopInBitSettingsDao + .setGuidelinesAccepted(widget.customerKey, widget.category, true); + + if (!mounted) return; + await Navigator.of( + context, + ).pushNamed(ShopInBitStep4.routeName, arguments: widget.category); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!isDesktop) + StepRow( + count: 4, + current: 2, + width: MediaQuery.of(context).size.width - 32, + ), + if (!isDesktop) const SizedBox(height: 14), + Text( + "Service guidelines", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + Text( + "Please read the following carefully before continuing.", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + SizedBox(height: isDesktop ? 24 : 16), + Flexible( + child: RoundedWhiteContainer( + child: SingleChildScrollView( + child: Text( + _guidelinesText(), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ) + : STextStyles.itemSubtitle12(context), + ), + ), + ), + ), + CheckboxListTile( + value: _agreed, + onChanged: (v) => setState(() => _agreed = v ?? false), + title: Text( + "I have read and agree to the Service Guidelines", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + activeColor: Theme.of( + context, + ).extension()!.accentColorBlue, + ), + SizedBox(height: isDesktop ? 24 : 16), + PrimaryButton( + label: "Next", + enabled: _agreed, + onPressed: _agreed ? _continue : null, + ), + ], + ); + + if (isDesktop) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 650, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const AppBarBackButton(isCompact: true, iconSize: 23), + Text("ShopinBit", style: STextStyles.desktopH3(context)), + ], + ), + DesktopDialogCloseButton( + onPressedOverride: () => + NestedNavigatorDialog.of(context).close(), + ), + ], + ), + Expanded( + child: Padding( + padding: const .only(bottom: 32, left: 32, right: 32, top: 16), + child: content, + ), + ), + ], + ), + ); + } + + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: const AppBarBackButton(), + title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight(child: content), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_step_4.dart b/lib/pages/shopinbit/shopinbit_step_4.dart new file mode 100644 index 0000000000..80cf61316b --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_step_4.dart @@ -0,0 +1,117 @@ +import "package:flutter/material.dart"; + +import "../../models/shopinbit/shopinbit_enums.dart"; +import "../../themes/stack_colors.dart"; +import "../../utilities/text_styles.dart"; +import "../../utilities/util.dart"; +import "../../widgets/background.dart"; +import "../../widgets/conditional_parent.dart"; +import "../../widgets/custom_buttons/app_bar_icon_button.dart"; +import "../../widgets/desktop/desktop_dialog_close_button.dart"; +import "../../widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart"; +import "../../widgets/dialogs/s_dialog.dart"; +import "step_4_components/shopinbit_car_research_form.dart"; +import "step_4_components/shopinbit_concierge_form.dart"; +import "step_4_components/shopinbit_travel_form.dart"; + +class ShopInBitStep4 extends StatelessWidget { + const ShopInBitStep4({super.key, required this.category}); + + static const String routeName = "/shopInBitStep4"; + + final ShopInBitCategory category; + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => _ShopInBitStep4DesktopShell(content: child), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => _ShopInBitStep4MobileShell(content: child), + child: switch (category) { + ShopInBitCategory.concierge => const ShopInBitConciergeForm(), + ShopInBitCategory.car => const ShopInBitCarResearchForm(), + ShopInBitCategory.travel => const ShopInBitTravelForm(), + }, + ), + ); + } +} + +class _ShopInBitStep4DesktopShell extends StatelessWidget { + const _ShopInBitStep4DesktopShell({required this.content}); + + final Widget content; + + @override + Widget build(BuildContext context) { + return SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const AppBarBackButton(isCompact: true, iconSize: 23), + Text("ShopinBit", style: STextStyles.desktopH3(context)), + ], + ), + DesktopDialogCloseButton( + onPressedOverride: () => + NestedNavigatorDialog.of(context).close(), + ), + ], + ), + Flexible( + child: Padding( + padding: const .only(left: 32, right: 32, bottom: 32, top: 16), + child: content, + ), + ), + ], + ), + ), + ); + } +} + +class _ShopInBitStep4MobileShell extends StatelessWidget { + const _ShopInBitStep4MobileShell({required this.content}); + + final Widget content; + + @override + Widget build(BuildContext context) { + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: const AppBarBackButton(), + title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight(child: content), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart new file mode 100644 index 0000000000..2a558ef5dc --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -0,0 +1,1266 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:intl/intl.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../db/drift/shared_db/shared_database.dart'; +import '../../models/shopinbit/shopinbit_enums.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../providers/db/drift_provider.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../services/shopinbit/shopinbit_service.dart'; +import '../../services/shopinbit/src/api_response.dart'; +import '../../services/shopinbit/src/client.dart'; +import '../../services/shopinbit/src/models/message.dart'; +import '../../services/shopinbit/src/models/ticket.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/show_loading.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/custom_buttons/blue_text_button.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/detail_item.dart'; +import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/loading_indicator.dart'; +import '../../widgets/refresh_control.dart'; +import '../../widgets/rounded_container.dart'; +import '../../widgets/rounded_white_container.dart'; +import 'shopinbit_offer_view.dart'; + +class ShopInBitTicketDetail extends ConsumerStatefulWidget { + const ShopInBitTicketDetail({super.key, required this.apiTicketId}); + + static const String routeName = "/shopInBitTicketDetail"; + + final int apiTicketId; + + @override + ConsumerState createState() => + _ShopInBitTicketDetailState(); +} + +class _ShopInBitTicketDetailState extends ConsumerState + with WidgetsBindingObserver { + late final TextEditingController _messageController; + late final ShopInBitService _shopinBitService; + + static const Duration _kBasePollInterval = Duration(seconds: 5); + static const Duration _kMaxPollInterval = Duration(seconds: 120); + Duration _pollInterval = _kBasePollInterval; + + // True while a `_poll` is awaiting a refresh. `_startPolling` bails when a + // poll is already running so app-resume/lifecycle events can't start a second + // loop on top of the first. + bool _pollInFlight = false; + + // True while the app is backgrounded. A poll already in flight when we get + // backgrounded checks this before re-arming its timer, so polling actually + // stops instead of quietly continuing in the background. + bool _paused = false; + + // Optimistically-shown messages the user just sent, kept until the next + // refresh folds them into the persisted ticket row. + final List _pending = []; + + bool _sending = false; + + int get _id => widget.apiTicketId; + + @override + void initState() { + super.initState(); + + _shopinBitService = ref.read(pShopinBitService); + + _messageController = TextEditingController(); + WidgetsBinding.instance.addObserver(this); + + // start with a refresh right away and then start polling for updates + unawaited(_refresh().then((_) => _startPolling())); + } + + @override + void dispose() { + if (_shopinBitService.viewingTicketId == _id) { + unawaited(_shopinBitService.markTicketRead(_id)); + _shopinBitService.viewingTicketId = null; + } + WidgetsBinding.instance.removeObserver(this); + _pollingTimer?.cancel(); + _pollingTimer = null; + _messageController.dispose(); + super.dispose(); + } + + bool get _isChatVisible { + final lifecycle = WidgetsBinding.instance.lifecycleState; + final foregrounded = + lifecycle == null || lifecycle == AppLifecycleState.resumed; + return mounted && + foregrounded && + (ModalRoute.of(context)?.isCurrent ?? true); + } + + void _syncViewingFlag() { + if (_isChatVisible) { + _shopinBitService.viewingTicketId = _id; + } else if (_shopinBitService.viewingTicketId == _id) { + _shopinBitService.viewingTicketId = null; + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _syncViewingFlag(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _syncViewingFlag(); + + // Always continue polling on desktop + if (Util.isDesktop) return; + + // Don't poll while backgrounded; resume fresh when we come back. + if (state == AppLifecycleState.resumed) { + _paused = false; + _startPolling(); + } else { + _paused = true; + _pollingTimer?.cancel(); + } + } + + Timer? _pollingTimer; + Future _poll() async { + _pollInFlight = true; + _syncViewingFlag(); + bool ok = false; + try { + await _refresh(); + ok = true; + } catch (e, s) { + Logging.instance.w( + "ShopInBit ticket poll failed", + error: e, + stackTrace: s, + ); + } + _pollInFlight = false; + if (!mounted) return; + // Backgrounded while this poll was awaiting its refresh: don't re-arm. + // Resume will restart polling (`_pollInFlight` is already cleared above, so + // `_startPolling` won't be blocked). + if (_paused) return; + + final ticket = ref.read(pShopInBitTicket(_id)).asData?.value; + + // The user is viewing this ticket, so treat the conversation as read. + if (_isChatVisible && ticket != null && ticket.hasUnreadAgentMessage) { + unawaited(_shopinBitService.markTicketRead(_id)); + } + + final isTerminal = + ticket != null && TicketState.fromString(ticket.statusRaw).isTerminal; + // Just check terminal tickets less often. Was hitting limits in testing. + final baseInterval = isTerminal ? _kMaxPollInterval : _kBasePollInterval; + + // Back off on failure (e.g. a 429), reset to the base interval on success. + _pollInterval = ok + ? baseInterval + : ShopInBitClient.nextPollBackoff(_pollInterval, _kMaxPollInterval); + _pollingTimer = Timer(_pollInterval, _poll); + } + + void _startPolling() { + // A poll is already running and will re-arm itself; don't start a second + // loop on top of it. + if (_pollInFlight) return; + _pollingTimer?.cancel(); + _pollInterval = _kBasePollInterval; + unawaited(_poll()); + } + + Future _refresh() => _shopinBitService.refreshOne(_id); + + List? _currentSelectedAttachments; + + /// Pick files and validate them with the same rules the client enforces at + /// send time (type whitelist, per-category size caps, 50 MB combined), so + /// a doomed selection is rejected here with a specific reason instead of + /// failing later behind a generic "Message failed to send". + Future _pickAttachments() async { + if (_sending) return; + + // TODO verify this works on android and ios + final result = await FilePicker.platform.pickFiles( + allowMultiple: true, + type: .custom, + allowedExtensions: kAllowedAttachmentExtensions, + lockParentWindow: true, + ); + if (result == null || !mounted) return; + + final accepted = [...?_currentSelectedAttachments]; + int combinedBytes = 0; + for (final file in accepted) { + combinedBytes += await file.length(); + } + + String? rejection; + for (final picked in result.files) { + final path = picked.path; + if (path == null || accepted.any((f) => f.path == path)) continue; + + final file = File(path); + final fileName = file.uri.pathSegments.last; + final resolved = resolveAttachmentType(fileName); + if (resolved == null) { + rejection = "$fileName is not a supported file type"; + continue; + } + + final sizeBytes = await file.length(); + if (sizeBytes > resolved.category.maxBytes) { + rejection = + "$fileName is larger than the " + "${resolved.category.maxBytes ~/ 1000000} MB " + "${resolved.category.name} limit"; + continue; + } + if (combinedBytes + sizeBytes > kCombinedAttachmentMaxBytes) { + rejection = + "Combined attachment size exceeds the " + "${kCombinedAttachmentMaxBytes ~/ 1000000} MB limit"; + continue; + } + + combinedBytes += sizeBytes; + accepted.add(file); + } + + if (!mounted) return; + setState(() { + _currentSelectedAttachments = accepted.isEmpty ? null : accepted; + }); + if (rejection != null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: rejection, + context: context, + ), + ); + } + } + + void _removeAttachment(File file) { + final current = _currentSelectedAttachments; + if (current == null) return; + setState(() { + current.remove(file); + if (current.isEmpty) _currentSelectedAttachments = null; + }); + } + + Future _sendMessage() async { + final text = _messageController.text.trim(); + // Capture the selection now: the field is cleared optimistically below, + // so a re-pick while this send is in flight can't be lost or orphaned. + final attachmentsToSend = _currentSelectedAttachments; + if ((text.isEmpty && attachmentsToSend == null) || _sending) return; + + // The server's copy will carry real attachment links after processing; + // until then the optimistic bubble just notes the count. + final optimisticContent = switch ((text, attachmentsToSend?.length)) { + (final t, null) => t, + ("", final int n) => "$n attachment(s)", + (final t, final int n) => "$t ($n attachment(s))", + }; + + final optimistic = TicketMessage( + timestamp: DateTime.now(), + fromAgent: false, + content: optimisticContent, + ); + setState(() { + _sending = true; + _pending.add(optimistic); + _currentSelectedAttachments = null; + }); + _messageController.clear(); + + var sent = false; + try { + final thisTicket = await ref + .read(pSharedDrift) + .shopInBitTicketsDao + .getByApiId(_id); + final customerKey = thisTicket?.customerKey; + if (customerKey != null) { + sent = await ref + .read(pShopinBitService) + .sendMessage( + _id, + text, + customerKey, + attachments: attachmentsToSend, + ); + } + } catch (_) { + sent = false; + } + + if (sent) { + // Delivered. sendMessage already scheduled its own refresh and the poll + // loop reconciles regardless, so a failure pulling the server's copy in + // here must not roll the (already sent) message back. Fold it in if we + // can; otherwise leave the optimistic bubble for the next refresh. + // forceMessages: the user's own message doesn't move lastAgentMessageAt, + // so an ungated refresh would skip the fetch and the bubble's removal + // below would make the just-sent message vanish from the conversation. + try { + await _shopinBitService.refreshOne(_id, forceUpdateMessages: true); + } catch (_) {} + if (mounted) setState(() => _pending.remove(optimistic)); + } else { + // The send didn't go through: roll the optimistic message back, restore + // the text and attachments so nothing is lost, and let the user know. + _pending.remove(optimistic); + if (mounted) { + if (_messageController.text.isEmpty) _messageController.text = text; + // Don't clear a selection the user made while this send was + // in flight; only restore into an empty slot. + _currentSelectedAttachments ??= attachmentsToSend; + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: 'Message failed to send', + context: context, + ), + ); + } + } + if (mounted) setState(() => _sending = false); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final ShopInBitTicket? ticket = ref + .watch(pShopInBitTicket(_id)) + .asData + ?.value; + + final ticketNumber = ticket?.ticketNumber ?? "Request"; + final customerKey = ticket?.customerKey; + final status = ticket?.status ?? ShopInBitOrderStatus.pending; + final isCarResearch = ticket?.category == ShopInBitCategory.car; + final messages = [...?ticket?.messages, ..._pending]; + + final trackingLinks = splitTrackingLinks(ticket?.trackingLink).toList(); + + final statusBar = Padding( + padding: .only(bottom: isDesktop ? 12 : 8), + child: RoundedWhiteContainer( + borderColor: isDesktop + ? Theme.of(context).extension()!.textFieldDefaultBG + : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SelectableText( + ticketNumber, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: status + .getColor(Theme.of(context).extension()!) + .withOpacity(0.2), + ), + child: Text( + status.label, + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: status.getColor( + Theme.of(context).extension()!, + ), + ), + ), + ), + ], + ), + ), + ); + + Future _pushOfferView() async { + if (_id != 0) { + await showLoading( + whileFutureAlt: _refresh, + context: context, + message: "Checking offer...", + rootNavigator: Util.isDesktop, + delay: const Duration(seconds: 1), + onException: (e) { + Logging.instance.w( + "Failed to refresh ShopInBit offer $_id, " + "using cached data", + error: e, + ); + }, + ); + } + if (context.mounted) { + await Navigator.of( + context, + ).pushNamed(ShopInBitOfferView.routeName, arguments: _id); + } + } + + final offerBanner = status == ShopInBitOrderStatus.offerAvailable + ? Padding( + padding: .only(bottom: isDesktop ? 12 : 8), + child: RoundedWhiteContainer( + borderColor: isDesktop + ? Theme.of( + context, + ).extension()!.textFieldDefaultBG + : null, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => Row( + children: [ + Expanded(child: child), + PrimaryButton( + label: "Review offer", + width: 220, + buttonHeight: ButtonHeight.l, + onPressed: _pushOfferView, + ), + ], + ), + child: Column( + crossAxisAlignment: .start, + mainAxisSize: .min, + children: [ + Text( + "Offer available", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + const SizedBox(height: 4), + Text( + "${ticket?.offerProductName ?? 'Item'} — " + "${ticket?.offerPrice ?? '0'} EUR (incl. VAT)", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + if (!Util.isDesktop) const SizedBox(height: 12), + if (!Util.isDesktop) + PrimaryButton( + label: "Review offer", + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: _pushOfferView, + ), + ], + ), + ), + ), + ) + : const SizedBox.shrink(); + + final chatList = ListView.builder( + reverse: true, + padding: const EdgeInsets.all(8), + physics: const AlwaysScrollableScrollPhysics(), + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages[messages.length - 1 - index]; + return _ChatBubble( + // Stable per-message identity so the list (which grows/shrinks as + // optimistic and polled messages come and go) keeps each bubble's + // state (e.g. a proxy image's fetched URL) with the right message. + // Value-stable across polls (objects are rebuilt each poll) but a + // cheap hash, so we don't allocate/compare the whole content (which + // can be a multi-MB inline image) on every itemBuilder call. + key: ValueKey( + Object.hash( + message.fromAgent, + message.timestamp.microsecondsSinceEpoch, + message.content.hashCode, + ), + ), + message: message, + isDesktop: isDesktop, + customerKey: customerKey, + ); + }, + ); + + final chatArea = Expanded( + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => RoundedContainer( + padding: .zero, + color: Theme.of(context).extension()!.textFieldActiveBG, + child: child, + ), + child: RefreshControl(onRefresh: _refresh, child: chatList), + ), + ); + + final inputBar = RoundedContainer( + padding: Util.isDesktop ? .zero : const .all(8), + color: Theme.of(context).extension()!.popupBG, + child: Row( + children: [ + IconButton( + onPressed: _sending ? null : _pickAttachments, + tooltip: "Attach files", + icon: SvgPicture.asset( + Assets.svg.file, + width: 20, + height: 20, + colorFilter: ColorFilter.mode( + Theme.of(context).extension()!.textSubtitle1, + .srcIn, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _messageController, + style: + (isDesktop + ? STextStyles.desktopTextExtraSmall(context) + : STextStyles.field(context)) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + decoration: InputDecoration( + hintText: "Type a message...", + hintStyle: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.fieldLabel(context), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + onSubmitted: (_) => _sendMessage(), + ), + ), + if (!Util.isDesktop) const SizedBox(width: 8), + if (!Util.isDesktop) + IconButton( + onPressed: _sendMessage, + icon: SvgPicture.asset( + Assets.svg.send, + width: 24, + height: 24, + color: Theme.of( + context, + ).extension()!.accentColorBlue, + ), + ), + ], + ), + ); + + final requestDetailsSection = + isCarResearch && (ticket?.requestDescription.isNotEmpty ?? false) + ? Padding( + padding: EdgeInsets.only(bottom: isDesktop ? 12 : 8), + child: RoundedWhiteContainer( + borderColor: isDesktop + ? Theme.of( + context, + ).extension()!.textFieldDefaultBG + : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Request details", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + const SizedBox(height: 8), + SelectableText( + ticket!.requestDescription, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + ) + : const SizedBox.shrink(); + + final body = Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + statusBar, + offerBanner, + requestDetailsSection, + if (trackingLinks.isNotEmpty) + Padding( + padding: EdgeInsets.only(bottom: isDesktop ? 12 : 8), + child: _TrackingLinks(trackingLinks: trackingLinks), + ), + chatArea, + if (_currentSelectedAttachments != null) ...[ + SizedBox(height: isDesktop ? 8 : 6), + _SelectedAttachmentChips( + files: _currentSelectedAttachments!, + enabled: !_sending, + onRemove: _removeAttachment, + ), + ], + SizedBox(height: isDesktop ? 12 : 8), + inputBar, + ], + ); + + return ConditionalParent( + condition: isDesktop, + builder: (child) => SDialog( + contentCanScroll: false, + child: SizedBox( + width: 600, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Request", + style: STextStyles.desktopH3(context), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + RefreshButton(isRefreshing: false, onPressed: _refresh), + const SizedBox(width: 8), + const DesktopDialogCloseButton(), + ], + ), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + ), + child: child, + ), + ), + ], + ), + ), + ), + child: ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: Text( + ticketNumber, + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ), + ), + child: body, + ), + ); + } +} + +// Chat bubble / attachment layout dimensions. +const double _kBubbleMaxWidthDesktop = 380; +const double _kBubbleMaxWidthMobile = 260; +const double _kAttachmentMaxHeight = 220; +// Decode images down to ~2x the display height to cap decode/memory cost. +const int _kAttachmentDecodeHeight = 440; +const double _kAttachmentLoaderHeight = 80; +const double _kAttachmentLoaderWidth = 40; +// Selected-attachment chip layout. +const double _kChipIconSize = 12; +const double _kChipMaxNameWidth = 140; + +/// Chips for attachments that are selected but not yet sent, each removable +/// until the send starts. +class _SelectedAttachmentChips extends StatelessWidget { + const _SelectedAttachmentChips({ + required this.files, + required this.enabled, + required this.onRemove, + }); + + final List files; + final bool enabled; + final void Function(File) onRemove; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerLeft, + child: Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final File file in files) + _AttachmentChip( + file: file, + enabled: enabled, + onRemove: () => onRemove(file), + ), + ], + ), + ); + } +} + +class _AttachmentChip extends StatelessWidget { + const _AttachmentChip({ + required this.file, + required this.enabled, + required this.onRemove, + }); + + final File file; + final bool enabled; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).extension()!; + final String fileName = file.uri.pathSegments.last; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colors.textFieldDefaultBG, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: .min, + children: [ + SvgPicture.asset( + Assets.svg.file, + width: _kChipIconSize, + height: _kChipIconSize, + colorFilter: ColorFilter.mode(colors.textSubtitle1, .srcIn), + ), + const SizedBox(width: 4), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _kChipMaxNameWidth), + child: Text( + fileName, + style: STextStyles.itemSubtitle12(context), + maxLines: 1, + overflow: .ellipsis, + ), + ), + const SizedBox(width: 6), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: enabled ? onRemove : null, + child: SvgPicture.asset( + Assets.svg.x, + width: _kChipIconSize, + height: _kChipIconSize, + colorFilter: ColorFilter.mode(colors.textSubtitle1, .srcIn), + ), + ), + ), + ], + ), + ); + } +} + +/// Renders an authenticated `/attachment-proxy/` image. +/// +/// The signed URL future is built once in [initState] (and only rebuilt when +/// [proxyPath] or [customerKey] actually change) so the surrounding 30s poll +/// can't re-fire `getAttachmentUrl`/re-fetch the image on every rebuild. +class _ProxyImage extends StatefulWidget { + const _ProxyImage({ + required this.client, + required this.proxyPath, + required this.customerKey, + required this.fallback, + }); + + final ShopInBitClient client; + final String proxyPath; + final String customerKey; + final Widget Function() fallback; + + @override + State<_ProxyImage> createState() => _ProxyImageState(); +} + +class _ProxyImageState extends State<_ProxyImage> { + late Future> _urlFuture; + + Future> _buildFuture() => widget.client.getAttachmentUrl( + widget.proxyPath, + useQueryAuth: true, + customerKey: widget.customerKey, + ); + + @override + void initState() { + super.initState(); + _urlFuture = _buildFuture(); + } + + @override + void didUpdateWidget(covariant _ProxyImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.proxyPath != widget.proxyPath || + oldWidget.customerKey != widget.customerKey) { + _urlFuture = _buildFuture(); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: _kAttachmentMaxHeight), + child: FutureBuilder>( + future: _urlFuture, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox( + height: _kAttachmentLoaderHeight, + child: LoadingIndicator(width: _kAttachmentLoaderWidth), + ); + } + final resp = snapshot.data!; + if (resp.hasError || resp.value == null) { + return widget.fallback(); + } + return Image.network( + resp.value!.toString(), + fit: BoxFit.contain, + cacheHeight: _kAttachmentDecodeHeight, + semanticLabel: "Image attachment", + errorBuilder: (_, _, _) => widget.fallback(), + ); + }, + ), + ), + ), + ); + } +} + +String _formatTime(DateTime dt) { + final local = dt.toLocal(); + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); + final hm = "$hour:$minute"; + final now = DateTime.now(); + final isToday = + local.year == now.year && + local.month == now.month && + local.day == now.day; + return isToday ? hm : "${DateFormat('MMM d').format(local)} $hm"; +} + +/// A single chat message bubble: the message body plus its timestamp. +class _ChatBubble extends StatelessWidget { + const _ChatBubble({ + super.key, + required this.message, + required this.isDesktop, + required this.customerKey, + }); + + final TicketMessage message; + final bool isDesktop; + final String? customerKey; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).extension()!; + final isFromUser = !message.fromAgent; + final textColor = isFromUser + ? colors.buttonTextPrimary + : colors.buttonTextSecondary; + + return Align( + alignment: isFromUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + constraints: BoxConstraints( + maxWidth: isDesktop + ? _kBubbleMaxWidthDesktop + : _kBubbleMaxWidthMobile, + ), + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: isFromUser + ? colors.buttonBackPrimary + : colors.buttonBackSecondary, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(12), + topRight: const Radius.circular(12), + bottomLeft: isFromUser ? const Radius.circular(12) : Radius.zero, + bottomRight: isFromUser ? Radius.zero : const Radius.circular(12), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _MessageBody( + message: message, + isDesktop: isDesktop, + textColor: textColor, + customerKey: customerKey, + ), + const SizedBox(height: 4), + Text( + _formatTime(message.timestamp), + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context)) + .copyWith( + fontSize: 10, + color: isFromUser + ? colors.buttonTextPrimary.withOpacity(0.7) + : colors.textSubtitle1.withOpacity(0.7), + ), + ), + ], + ), + ), + ); + } +} + +/// Renders a ticket message's HTML [TicketMessage.content] as a column of text, +/// inline base64 images, proxy images, and file links. +class _MessageBody extends ConsumerWidget { + const _MessageBody({ + required this.message, + required this.isDesktop, + required this.textColor, + required this.customerKey, + }); + + final TicketMessage message; + final bool isDesktop; + final Color? textColor; + final String? customerKey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final textStyle = + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context)) + .copyWith(color: textColor); + + final widgets = []; + + // Render segments in document order. Proxy images and file links need the + // customer key to fetch; a loaded ticket always has one, so when it's null + // (e.g. an optimistic message) those segments are simply skipped. + final key = customerKey; + final client = key == null ? null : ref.read(pShopinBitService).client; + + for (final segment in message.contentSegments) { + switch (segment) { + case MessageTextSegment(:final text): + widgets.add(Text(text, style: textStyle)); + case MessageImageSegment(:final bytes): + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: _kAttachmentMaxHeight, + ), + child: Image.memory( + bytes, + fit: BoxFit.contain, + cacheHeight: _kAttachmentDecodeHeight, + semanticLabel: "Image", + // The decoded bytes are cached and reused across polls, so + // the provider stays equal; keep the last frame if it ever + // does reload (e.g. cache eviction) instead of flashing. + gaplessPlayback: true, + ), + ), + ), + ), + ); + case MessageProxyImageSegment(:final proxyPath, :final filename): + if (key != null && client != null) { + widgets.add( + _ProxyImage( + client: client, + proxyPath: proxyPath, + customerKey: key, + fallback: () => _AttachmentImageFallback(filename: filename), + ), + ); + } + case MessageFileLinkSegment(:final proxyPath, :final filename): + if (key != null) { + widgets.add( + _AttachmentFileLink( + proxyPath: proxyPath, + customerKey: key, + filename: filename, + textStyle: textStyle, + ), + ); + } + } + } + + if (widgets.isEmpty) { + widgets.add(Text('', style: textStyle)); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: widgets, + ); + } +} + +/// A tappable `/attachment-proxy/` file link, opened in the browser. +class _AttachmentFileLink extends ConsumerWidget { + const _AttachmentFileLink({ + required this.proxyPath, + required this.customerKey, + required this.filename, + required this.textStyle, + }); + + final String proxyPath; + final String customerKey; + final String? filename; + final TextStyle textStyle; + + Future _open(BuildContext context, ShopInBitService service) async { + // Resolving the signed URL hits the token manager (and possibly the + // network), so show the loading overlay and surface any failure rather than + // doing nothing. + await showLoading( + whileFuture: _resolveAndLaunch(service), + context: context, + message: "Opening attachment", + rootNavigator: Util.isDesktop, + onException: (e) { + Logging.instance.w("ShopInBit open attachment failed", error: e); + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Could not open attachment", + context: context, + ); + }, + ); + } + + Future _resolveAndLaunch(ShopInBitService service) async { + final resp = await service.client.getAttachmentUrl( + proxyPath, + useQueryAuth: true, + customerKey: customerKey, + ); + if (resp.hasError || resp.value == null) { + throw resp.exception ?? Exception("Could not resolve attachment URL"); + } + final launched = await launchUrl( + resp.value!, + mode: LaunchMode.externalApplication, + ); + if (!launched) throw Exception("Could not open attachment"); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final linkStyle = textStyle.copyWith( + decoration: TextDecoration.underline, + decorationColor: textStyle.color, + ); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: MouseRegion( + cursor: SystemMouseCursors.click, + // TODO: Make sure we warn about browsing. + child: Semantics( + button: true, + label: filename ?? 'attachment', + excludeSemantics: true, + child: GestureDetector( + onTap: () => _open(context, ref.read(pShopinBitService)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + Assets.svg.file, + width: 16, + height: 16, + color: textStyle.color, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + filename ?? 'attachment', + style: linkStyle, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Shown in place of a proxy image that failed to load. +class _AttachmentImageFallback extends StatelessWidget { + const _AttachmentImageFallback({required this.filename}); + + final String? filename; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).extension()!; + return Container( + padding: const EdgeInsets.all(8), + color: colors.textFieldDefaultBG, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + Assets.svg.alertCircle, + width: 16, + height: 16, + color: colors.textSubtitle1, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + filename ?? 'image', + style: STextStyles.itemSubtitle12(context), + ), + ), + ], + ), + ); + } +} + +class _TrackingLinks extends StatelessWidget { + const _TrackingLinks({super.key, required this.trackingLinks}); + + final List trackingLinks; + + @override + Widget build(BuildContext context) { + return DetailItemBase( + horizontal: true, + expandDetail: true, + crossAxisAlignment: .start, + title: Text( + "Tracking link(s)", + style: Util.isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + detail: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + ...trackingLinks.map( + (e) => CustomTextButton( + text: e, + overflow: .ellipsis, + onTap: () async { + try { + await launchUrl( + Uri.parse(e), + mode: LaunchMode.externalApplication, + ); + } catch (e, s) { + Logging.instance.e( + "Failed to open shipping tracking link", + error: e, + stackTrace: s, + ); + } + }, + ), + ), + ], + ), + borderColor: Util.isDesktop + ? Theme.of(context).extension()!.textFieldDefaultBG + : null, + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart new file mode 100644 index 0000000000..ed62427921 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart @@ -0,0 +1,442 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_svg/flutter_svg.dart"; + +import "../../db/drift/shared_db/shared_database.dart"; +import "../../models/shopinbit/shopinbit_enums.dart"; +import "../../providers/global/shopin_bit_service_provider.dart"; +import "../../services/shopinbit/src/models/car_research.dart"; +import "../../themes/stack_colors.dart"; +import "../../utilities/assets.dart"; +import "../../utilities/logger.dart"; +import "../../utilities/text_styles.dart"; +import "../../utilities/util.dart"; +import "../../widgets/background.dart"; +import "../../widgets/conditional_parent.dart"; +import "../../widgets/custom_buttons/app_bar_icon_button.dart"; +import "../../widgets/desktop/desktop_dialog_close_button.dart"; +import "../../widgets/dialogs/s_dialog.dart"; +import "../../widgets/loading_indicator.dart"; +import "../../widgets/refresh_control.dart"; +import "../../widgets/rounded_container.dart"; +import "../../widgets/stack_dialog.dart"; +import "shopinbit_car_research_payment_view.dart"; +import "shopinbit_ticket_detail.dart"; + +class ShopInBitTicketsView extends ConsumerStatefulWidget { + const ShopInBitTicketsView({super.key}); + + static const String routeName = "/shopInBitTickets"; + + @override + ConsumerState createState() => + _ShopInBitTicketsViewState(); +} + +class _ShopInBitTicketsViewState extends ConsumerState { + bool _refreshing = false; + bool _resuming = false; + + // Some unfinished car research fee invoices recovered from the server, if any. + // The fee is paid before any ticket exists, so this is the only way to let + // the user resume it — there is no local "pending" row anymore. + List? _resumableInvoices; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _refresh()); + } + + Future _refresh() async { + if (_refreshing) return; + if (mounted) setState(() => _refreshing = true); + try { + await Future.wait([ + ref.read(pShopinBitService).refreshAll(), + _loadResumableInvoice(), + ]); + } finally { + if (mounted) setState(() => _refreshing = false); + } + } + + bool _needsReplacement(CarResearchCurrentInvoice invoice) { + return !carResearchIsFinalized(invoice.status, invoice.additional) && + const { + 'expired', + 'underpaid_expired', + }.contains(invoice.status.toLowerCase().trim()); + } + + /// Pull still-payable car research invoices from + /// `GET /car-research/invoices/current` so they can be resumed. + Future _loadResumableInvoice() async { + final resumable = []; + try { + final customerKey = await ref.read(pShopinBitService).ensureCustomerKey(); + final resp = await ref + .read(pShopinBitService) + .client + .getCurrentCarResearchInvoices(customerKey: customerKey); + final invoices = resp.value; + if (invoices != null) { + for (final inv in invoices) { + final finalized = carResearchIsFinalized(inv.status, inv.additional); + final payable = + inv.expiresAt != null && + (finalized || + (inv.expiresAt! + .add(const Duration(hours: 24)) + .isAfter(DateTime.now()) && + (_needsReplacement(inv) + ? inv.hasRequestPayload + : inv.paymentLinks.isNotEmpty))); + if (payable) { + resumable.add(inv); + } + } + } + resumable.sort((a, b) { + final aNeedsReplacement = _needsReplacement(a); + final bNeedsReplacement = _needsReplacement(b); + if (aNeedsReplacement != bNeedsReplacement) { + return aNeedsReplacement ? 1 : -1; + } + final oldest = DateTime.fromMillisecondsSinceEpoch(0); + return (b.createdAt ?? oldest).compareTo(a.createdAt ?? oldest); + }); + } catch (e, s) { + Logging.instance.e( + "_loadResumableInvoice failed", + error: e, + stackTrace: s, + ); + // Leave _resumableInvoice unchanged on failure. + return; + } + if (mounted) { + setState(() => _resumableInvoices = resumable.isEmpty ? null : resumable); + } + } + + Future _resumeFlow(CarResearchCurrentInvoice currentInvoice) async { + if (_resuming) return; + setState(() => _resuming = true); + try { + final customerKey = await ref.read(pShopinBitService).ensureCustomerKey(); + CarResearchInvoice invoice; + if (_needsReplacement(currentInvoice)) { + final resp = await ref + .read(pShopinBitService) + .client + .retryCarResearchInvoice( + invoiceId: currentInvoice.invoiceId, + customerKey: customerKey, + ); + invoice = resp.valueOrThrow; + } else { + invoice = CarResearchInvoice( + btcpayInvoice: currentInvoice.invoiceId, + expiresAt: currentInvoice.expiresAt!, + paymentLinks: currentInvoice.paymentLinks, + ); + } + + if (mounted) { + await Navigator.of(context).pushNamed( + ShopInBitCarResearchPaymentView.routeName, + arguments: (invoice: invoice, customerKey: customerKey), + ); + if (mounted) { + await _loadResumableInvoice(); + } + } + } catch (e, s) { + Logging.instance.e("_resumeFlow failed", error: e, stackTrace: s); + if (mounted) { + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to resume payment", + maxWidth: Util.isDesktop ? 500 : null, + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } finally { + if (mounted) setState(() => _resuming = false); + } + } + + List _buildListChildren({ + required BuildContext context, + required bool isDesktop, + required List tickets, + required List? resumable, + }) { + if (resumable == null && tickets.isEmpty) { + return [ + const SizedBox(height: 80), + Center( + child: Text( + _refreshing ? "Loading requests..." : "No requests yet", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + ), + ]; + } + + final children = []; + if (resumable != null) { + for (var i = 0; i < resumable.length; i++) { + if (i > 0) children.add(SizedBox(height: isDesktop ? 16 : 12)); + final invoice = resumable[i]; + children.add( + RoundedContainer( + color: Theme.of(context).extension()!.popupBG, + onPressed: _resuming ? null : () => unawaited(_resumeFlow(invoice)), + child: _RequestRow( + title: "Car Research (In Progress)", + subtitle: _resuming + ? "Opening your car research payment..." + : "${invoice.status} • Invoice ${invoice.invoiceId}", + badgeText: "Resume", + badgeColor: Theme.of( + context, + ).extension()!.accentColorYellow, + loading: _resuming, + ), + ), + ); + } + if (tickets.isNotEmpty) { + children.add(SizedBox(height: isDesktop ? 16 : 12)); + } + } + for (var i = 0; i < tickets.length; i++) { + final ticket = tickets[i]; + if (i > 0) children.add(SizedBox(height: isDesktop ? 16 : 12)); + children.add( + RoundedContainer( + padding: EdgeInsets.all(isDesktop ? 16 : 12), + borderColor: isDesktop + ? Theme.of(context).extension()!.textFieldDefaultBG + : null, + color: Theme.of(context).extension()!.popupBG, + onPressed: () => Navigator.of(context).pushNamed( + ShopInBitTicketDetail.routeName, + arguments: ticket.apiTicketId, + ), + child: _RequestRow( + title: ticket.ticketNumber, + subtitle: + "${ticket.category.label} • " + "${ticket.requestDescription}", + badgeText: ticket.status.label, + badgeColor: ticket.status.getColor( + Theme.of(context).extension()!, + ), + ), + ), + ); + } + return children; + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final tickets = + ref.watch(pShopInBitTickets).asData?.value ?? const []; + final resumables = _resumableInvoices; + + return ConditionalParent( + condition: isDesktop, + builder: (child) => SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const .only(left: 32), + child: Text( + "My requests", + style: STextStyles.desktopH3(context), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + RefreshButton( + isRefreshing: _refreshing, + onPressed: _refresh, + ), + const SizedBox(width: 8), + const DesktopDialogCloseButton(), + ], + ), + ], + ), + Flexible( + child: Padding( + padding: const .only( + left: 32, + right: 32, + bottom: 32, + top: 16, + ), + child: child, + ), + ), + ], + ), + ), + ), + child: ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: Text( + "My requests", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: Padding(padding: const .all(16), child: child), + ), + ), + ), + child: RefreshControl( + onRefresh: _refresh, + child: ListView( + shrinkWrap: true, + physics: const AlwaysScrollableScrollPhysics(), + primary: isDesktop ? false : null, + children: [ + ..._buildListChildren( + context: context, + isDesktop: isDesktop, + tickets: tickets, + resumable: resumables, + ), + ], + ), + ), + ), + ); + } +} + +class _RequestRow extends StatelessWidget { + const _RequestRow({ + required this.title, + required this.subtitle, + required this.badgeText, + required this.badgeColor, + this.loading = false, + }); + + final String title; + final String subtitle; + final String badgeText; + final Color badgeColor; + final bool loading; + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final stackColors = Theme.of(context).extension()!; + + final titleStyle = isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context); + + final subtitleStyle = isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12( + context, + ).copyWith(color: stackColors.textSubtitle1); + + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: titleStyle), + _StatusBadge(text: badgeText, color: badgeColor), + ], + ), + const SizedBox(height: 4), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: subtitleStyle, + ), + ], + ), + ), + SizedBox(width: isDesktop ? 16 : 8), + loading + ? const SizedBox(width: 20, height: 20, child: LoadingIndicator()) + : SvgPicture.asset( + Assets.svg.chevronRight, + width: 20, + height: 20, + colorFilter: ColorFilter.mode( + stackColors.textSubtitle1, + .srcIn, + ), + ), + ], + ); + } +} + +class _StatusBadge extends StatelessWidget { + const _StatusBadge({required this.text, required this.color}); + + final String text; + final Color color; + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final style = + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context)) + .copyWith(color: color); + + return Container( + padding: const .symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: color.withOpacity(0.2), + ), + child: Text(text, style: style), + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_car_research_form.dart b/lib/pages/shopinbit/step_4_components/shopinbit_car_research_form.dart new file mode 100644 index 0000000000..d8e3fcdbec --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_car_research_form.dart @@ -0,0 +1,353 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_svg/flutter_svg.dart"; + +import "../../../models/shopinbit/shopinbit_request_draft.dart"; +import "../../../themes/stack_colors.dart"; +import "../../../utilities/assets.dart"; +import "../../../utilities/text_styles.dart"; +import "../../../utilities/util.dart"; +import "../../../widgets/conditional_parent.dart"; +import "../../../widgets/rounded_white_container.dart"; +import "../../../widgets/textfields/adaptive_text_field.dart"; +import "../shopinbit_car_fee_view.dart"; +import "shopinbit_country_picker.dart"; +import "shopinbit_labeled_checkbox.dart"; +import "shopinbit_privacy_checkbox.dart"; +import "shopinbit_state_picker.dart"; +import "shopinbit_step4_dropdown.dart"; +import "shopinbit_step4_header.dart"; +import "shopinbit_step4_submit_button.dart"; + +const List _carConditions = ["NEW", "PREOWNED"]; + +const int _minCarBudget = 20000; +const int _minCarFieldLength = 3; + +class ShopInBitCarResearchForm extends ConsumerStatefulWidget { + const ShopInBitCarResearchForm({super.key}); + + @override + ConsumerState createState() => + _ShopInBitCarResearchFormState(); +} + +class _ShopInBitCarResearchFormState + extends ConsumerState { + final TextEditingController _brandController = TextEditingController(); + final FocusNode _brandFocusNode = FocusNode(); + bool _brandTouched = false; + + final TextEditingController _modelController = TextEditingController(); + final FocusNode _modelFocusNode = FocusNode(); + bool _modelTouched = false; + + final TextEditingController _carDescriptionController = + TextEditingController(); + final FocusNode _carDescriptionFocusNode = FocusNode(); + bool _carDescriptionTouched = false; + + final TextEditingController _carBudgetController = TextEditingController(); + final FocusNode _carBudgetFocusNode = FocusNode(); + bool _carBudgetTouched = false; + + String? _selectedCarCondition; + bool _feeAcknowledged = false; + String? _selectedCountryIsoCode; + String? _selectedCountryName; + String? _selectedState; + bool? _requiresState; + bool _privacyAccepted = false; + bool _submitting = false; + + @override + void initState() { + super.initState(); + _wireTouchOnBlur(_brandFocusNode, () => _brandTouched = true); + _wireTouchOnBlur(_modelFocusNode, () => _modelTouched = true); + _wireTouchOnBlur( + _carDescriptionFocusNode, + () => _carDescriptionTouched = true, + ); + _wireTouchOnBlur(_carBudgetFocusNode, () => _carBudgetTouched = true); + } + + void _wireTouchOnBlur(FocusNode node, VoidCallback markTouched) { + node.addListener(() { + if (!node.hasFocus) markTouched(); + setState(() {}); + }); + } + + @override + void dispose() { + _brandController.dispose(); + _brandFocusNode.dispose(); + _modelController.dispose(); + _modelFocusNode.dispose(); + _carDescriptionController.dispose(); + _carDescriptionFocusNode.dispose(); + _carBudgetController.dispose(); + _carBudgetFocusNode.dispose(); + super.dispose(); + } + + bool get _canContinue { + final int? carBudgetValue = int.tryParse(_carBudgetController.text.trim()); + return !_submitting && + _privacyAccepted && + _feeAcknowledged && + _brandController.text.trim().length >= _minCarFieldLength && + _modelController.text.trim().length >= _minCarFieldLength && + _carDescriptionController.text.trim().length >= _minCarFieldLength && + _selectedCarCondition != null && + carBudgetValue != null && + carBudgetValue >= _minCarBudget && + _selectedCountryIsoCode != null && + _selectedCountryIsoCode!.isNotEmpty; + } + + Future _submit() async { + setState(() => _submitting = true); + try { + final countryIso = _selectedCountryIsoCode!; + + final sb = StringBuffer(); + sb.writeln("Brand: ${_brandController.text.trim()}"); + sb.writeln("Model: ${_modelController.text.trim()}"); + sb.writeln("Condition: $_selectedCarCondition"); + sb.writeln("Description: ${_carDescriptionController.text.trim()}"); + sb.writeln("Budget: ${_carBudgetController.text.trim()} EUR"); + if (_requiresState == true) { + sb.writeln("Delivery state: ${_selectedState!}"); + } + sb.writeln("Delivery country: $countryIso"); + + final draft = ShopinbitRequestDraft( + category: .car, + requestDescription: sb.toString(), + deliveryCountryCode: countryIso, + deliveryCountryName: _selectedCountryName!, + deliveryState: _requiresState == true ? _selectedState! : null, + voucherCode: null, + ); + + // Any unfinished car research fee is recovered from the server + // (`GET /car-research/invoices/current`) by the requests list, so there + // is no local "pending payment" state to guard against here. + if (!mounted) return; + + unawaited( + Navigator.of( + context, + ).pushNamed(ShopInBitCarFeeView.routeName, arguments: draft), + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final bool isDesktop = Util.isDesktop; + + final String? brandError = + _brandTouched && + _brandController.text.trim().length < _minCarFieldLength + ? "Minimum $_minCarFieldLength characters" + : null; + + final String? modelError = + _modelTouched && + _modelController.text.trim().length < _minCarFieldLength + ? "Minimum $_minCarFieldLength characters" + : null; + + final String? carDescriptionError = + _carDescriptionTouched && + _carDescriptionController.text.trim().length < _minCarFieldLength + ? "Minimum $_minCarFieldLength characters" + : null; + + final String carBudgetText = _carBudgetController.text.trim(); + final int? carBudgetValue = int.tryParse(carBudgetText); + final String? carBudgetError = + _carBudgetTouched && + (carBudgetText.isEmpty || + carBudgetValue == null || + carBudgetValue < _minCarBudget) + ? "Minimum budget is 20,000\u20AC" + : null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const ShopInBitStep4Header( + title: "Car Research request", + subtitle: "Tell us about the car you're looking for.", + ), + SizedBox(height: isDesktop ? 32 : 24), + ConditionalParent( + condition: _requiresState == true, + builder: (child) => Column( + mainAxisSize: .min, + children: [ + child, + SizedBox(height: isDesktop ? 24 : 16), + ShopInBitStatePicker( + countryIso: _selectedCountryIsoCode!, + selectedState: _selectedState, + onChanged: (state) { + if (state != _selectedState && mounted) { + setState(() { + _selectedState = state; + }); + } + }, + ), + ], + ), + child: ShopInBitCountryPicker( + selectedIso: _selectedCountryIsoCode, + onChanged: (data) => setState(() { + _selectedCountryIsoCode = data?.code; + _selectedCountryName = data?.name; + _requiresState = data?.requiresState; + }), + ), + ), + SizedBox(height: isDesktop ? 24 : 16), + AdaptiveTextField( + controller: _brandController, + focusNode: _brandFocusNode, + labelText: "Car brand (e.g., BMW, Mercedes, Toyota...)", + autocorrect: false, + enableSuggestions: false, + errorText: brandError, + onChanged: (_) => setState(() {}), + ), + SizedBox(height: isDesktop ? 24 : 16), + AdaptiveTextField( + controller: _modelController, + focusNode: _modelFocusNode, + labelText: "Car model (e.g., 3 Series, E-Class, Camry...)", + autocorrect: false, + enableSuggestions: false, + errorText: modelError, + onChanged: (_) => setState(() {}), + ), + SizedBox(height: isDesktop ? 24 : 16), + ShopInBitStep4Dropdown( + value: _selectedCarCondition, + items: _carConditions, + hintText: "Condition", + onChanged: (value) => setState(() => _selectedCarCondition = value), + ), + SizedBox(height: isDesktop ? 24 : 16), + AdaptiveTextField( + controller: _carDescriptionController, + focusNode: _carDescriptionFocusNode, + labelText: + "Describe your requirements " + "(year, mileage, features...)", + minLines: 3, + maxLines: 6, + autocorrect: false, + enableSuggestions: false, + errorText: carDescriptionError, + onChanged: (_) => setState(() {}), + ), + SizedBox(height: isDesktop ? 24 : 16), + AdaptiveTextField( + controller: _carBudgetController, + focusNode: _carBudgetFocusNode, + labelText: "Budget (\u20AC, minimum 20,000)", + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + suffixText: "\u20AC", + autocorrect: false, + enableSuggestions: false, + errorText: carBudgetError, + onChanged: (_) => setState(() {}), + ), + SizedBox(height: isDesktop ? 24 : 16), + _CarResearchFeeInfo(isDesktop: isDesktop), + SizedBox(height: isDesktop ? 16 : 12), + ShopInBitLabeledCheckbox( + value: _feeAcknowledged, + onChanged: (v) => setState(() => _feeAcknowledged = v), + label: "I acknowledge the \u20AC223 research fee", + ), + const SizedBox(height: 24), + ShopInBitPrivacyCheckbox( + value: _privacyAccepted, + onChanged: (v) => setState(() => _privacyAccepted = v), + ), + const SizedBox(height: 32), + ShopInBitStep4SubmitButton( + submitting: _submitting, + enabled: _canContinue, + onPressed: _submit, + ), + ], + ); + } +} + +/// Info box showing the €223 (incl. VAT) research fee disclosure. +class _CarResearchFeeInfo extends StatelessWidget { + const _CarResearchFeeInfo({required this.isDesktop}); + + final bool isDesktop; + + @override + Widget build(BuildContext context) { + final TextStyle baseStyle = isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.w500_14(context); + + return RoundedWhiteContainer( + borderColor: isDesktop + ? Theme.of(context).extension()!.textFieldDefaultBG + : null, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SvgPicture.asset( + Assets.svg.circleInfo, + width: 20, + height: 20, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.textFieldActiveSearchIconLeft, + .srcIn, + ), + ), + const SizedBox(width: 12), + Expanded( + child: RichText( + text: TextSpan( + style: baseStyle, + children: [ + TextSpan( + text: "Research fee: ", + style: baseStyle.copyWith(fontWeight: FontWeight.bold), + ), + const TextSpan( + text: + "\u20AC223 (incl. VAT): one-time payment, " + "credited toward your purchase.", + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dart b/lib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dart new file mode 100644 index 0000000000..d2fe7aeca4 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dart @@ -0,0 +1,229 @@ +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +import "../../../models/shopinbit/shopinbit_request_draft.dart"; +import "../../../providers/global/shopin_bit_service_provider.dart"; +import "../../../utilities/util.dart"; +import "../../../widgets/conditional_parent.dart"; +import "../../../widgets/textfields/adaptive_text_field.dart"; +import "shopinbit_country_picker.dart"; +import "shopinbit_labeled_checkbox.dart"; +import "shopinbit_privacy_checkbox.dart"; +import "shopinbit_state_picker.dart"; +import "shopinbit_step4_dropdown.dart"; +import "shopinbit_step4_header.dart"; +import "shopinbit_step4_submit.dart"; +import "shopinbit_step4_submit_button.dart"; + +const List _conciergeConditions = ["NEW", "USED"]; + +const int _minConciergeBudget = 1000; +const int _maxConciergeBudget = 100000; + +class ShopInBitConciergeForm extends ConsumerStatefulWidget { + const ShopInBitConciergeForm({super.key}); + + @override + ConsumerState createState() => + _ShopInBitConciergeFormState(); +} + +class _ShopInBitConciergeFormState + extends ConsumerState { + final TextEditingController _whatToPurchaseController = + TextEditingController(); + final FocusNode _whatToPurchaseFocusNode = FocusNode(); + bool _whatToPurchaseTouched = false; + + final TextEditingController _budgetController = TextEditingController( + text: "1000", + ); + final FocusNode _budgetFocusNode = FocusNode(); + bool _budgetTouched = false; + + String? _selectedCondition; + bool _noLimit = false; + String? _selectedCountryIsoCode; + String? _selectedCountryName; + String? _selectedState; + bool? _requiresState; + bool _privacyAccepted = false; + bool _submitting = false; + + @override + void initState() { + super.initState(); + _whatToPurchaseFocusNode.addListener(() { + if (!_whatToPurchaseFocusNode.hasFocus) _whatToPurchaseTouched = true; + setState(() {}); + }); + _budgetFocusNode.addListener(() { + if (!_budgetFocusNode.hasFocus) _budgetTouched = true; + setState(() {}); + }); + } + + @override + void dispose() { + _whatToPurchaseController.dispose(); + _whatToPurchaseFocusNode.dispose(); + _budgetController.dispose(); + _budgetFocusNode.dispose(); + super.dispose(); + } + + bool get _budgetIsValid { + final String text = _budgetController.text.trim(); + if (text.isEmpty) return false; + final int? value = int.tryParse(text); + return value != null && + value >= _minConciergeBudget && + value <= _maxConciergeBudget; + } + + bool get _canContinue => + !_submitting && + _privacyAccepted && + _whatToPurchaseController.text.trim().length >= 10 && + _selectedCondition != null && + (_noLimit || _budgetIsValid) && + _selectedCountryIsoCode != null; + + Future _submit() async { + setState(() => _submitting = true); + try { + final String countryIso = _selectedCountryIsoCode!; + final String budgetText = _noLimit + ? "No limit" + : "${_budgetController.text.trim()} EUR"; + + final sb = StringBuffer(); + sb.writeln("What to purchase: ${_whatToPurchaseController.text.trim()}"); + sb.writeln("Condition: $_selectedCondition"); + sb.writeln("Budget: $budgetText"); + if (_requiresState == true) sb.writeln("State: ${_selectedState!}"); + sb.writeln("Delivery country: $countryIso"); + + final draft = ShopinbitRequestDraft( + category: .concierge, + requestDescription: sb.toString(), + deliveryCountryCode: countryIso, + deliveryCountryName: _selectedCountryName!, + deliveryState: _requiresState == true ? _selectedState! : null, + voucherCode: null, + ); + + await submitShopInBitRequest(context, draft, ref.read(pShopinBitService)); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final bool isDesktop = Util.isDesktop; + + final String? whatToPurchaseError = + _whatToPurchaseTouched && + _whatToPurchaseController.text.trim().length < 10 + ? "Minimum 10 characters" + : null; + + final String? budgetError = _budgetTouched && !_noLimit && !_budgetIsValid + ? "Enter a value between 1,000 and 100,000" + : null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const ShopInBitStep4Header( + title: "What would you like to purchase?", + subtitle: + "Tell us what you're looking for and we'll find it " + "for you.", + ), + SizedBox(height: isDesktop ? 16 : 12), + AdaptiveTextField( + controller: _whatToPurchaseController, + focusNode: _whatToPurchaseFocusNode, + labelText: "Describe what you need or paste a LINK here", + minLines: 3, + maxLines: 6, + autocorrect: false, + enableSuggestions: false, + errorText: whatToPurchaseError, + onChanged: (_) => setState(() {}), + ), + SizedBox(height: isDesktop ? 24 : 16), + ShopInBitStep4Dropdown( + value: _selectedCondition, + items: _conciergeConditions, + hintText: "Condition", + onChanged: (value) => setState(() => _selectedCondition = value), + ), + SizedBox(height: isDesktop ? 24 : 16), + AdaptiveTextField( + controller: _budgetController, + focusNode: _budgetFocusNode, + labelText: "Budget (\u20AC)", + enabled: !_noLimit, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + suffixText: "\u20AC", + autocorrect: false, + enableSuggestions: false, + errorText: budgetError, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + ShopInBitLabeledCheckbox( + value: _noLimit, + onChanged: (v) => setState(() => _noLimit = v), + label: "No budget limit", + ), + SizedBox(height: isDesktop ? 24 : 20), + ConditionalParent( + condition: _requiresState == true, + builder: (child) => Column( + mainAxisSize: .min, + children: [ + child, + SizedBox(height: isDesktop ? 24 : 16), + ShopInBitStatePicker( + countryIso: _selectedCountryIsoCode!, + selectedState: _selectedState, + onChanged: (state) { + if (state != _selectedState && mounted) { + setState(() { + _selectedState = state; + }); + } + }, + ), + ], + ), + child: ShopInBitCountryPicker( + selectedIso: _selectedCountryIsoCode, + onChanged: (data) => setState(() { + _selectedCountryIsoCode = data?.code; + _selectedCountryName = data?.name; + _requiresState = data?.requiresState; + }), + ), + ), + SizedBox(height: isDesktop ? 16 : 24), + ShopInBitPrivacyCheckbox( + value: _privacyAccepted, + onChanged: (v) => setState(() => _privacyAccepted = v), + ), + const SizedBox(height: 32), + ShopInBitStep4SubmitButton( + submitting: _submitting, + enabled: _canContinue, + onPressed: _submit, + ), + ], + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_country_picker.dart b/lib/pages/shopinbit/step_4_components/shopinbit_country_picker.dart new file mode 100644 index 0000000000..9928ac8595 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_country_picker.dart @@ -0,0 +1,184 @@ +import "package:dropdown_button2/dropdown_button2.dart"; +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_svg/svg.dart"; + +import "../../../providers/global/shopin_bit_service_provider.dart"; +import "../../../themes/stack_colors.dart"; +import "../../../utilities/assets.dart"; +import "../../../utilities/constants.dart"; +import "../../../utilities/text_styles.dart"; +import "../../../utilities/util.dart"; + +class ShopInBitCountryPicker extends ConsumerStatefulWidget { + const ShopInBitCountryPicker({ + super.key, + required this.selectedIso, + required this.onChanged, + this.hintText = "Delivery country (Required)", + this.preLoadedCountries, + }); + + final String? selectedIso; + final ValueChanged<({String name, String code, bool requiresState})?> + onChanged; + final String hintText; + + final List>? preLoadedCountries; + + @override + ConsumerState createState() => + _ShopInBitCountryPickerState(); +} + +class _ShopInBitCountryPickerState + extends ConsumerState { + final TextEditingController _searchController = TextEditingController(); + List> _countries = []; + bool _loading = false; + + @override + void initState() { + super.initState(); + if (widget.preLoadedCountries != null) { + _countries = widget.preLoadedCountries!; + } else { + _fetchCountries(); + } + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _fetchCountries() async { + setState(() => _loading = true); + try { + final resp = await ref.read(pShopinBitService).client.getCountries(); + if (resp.hasError || resp.value == null) return; + _countries = resp.value!; + if (widget.selectedIso != null && + !_countries.any((c) => c["iso"] == widget.selectedIso)) { + widget.onChanged(null); + } + } catch (_) { + // Leave list empty; user will see no items. + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final StackColors stackColors = Theme.of(context).extension()!; + + final TextStyle itemStyle = Util.isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith(color: stackColors.textFieldActiveText) + : STextStyles.w500_14(context); + + final TextStyle hintStyle = Util.isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith(color: stackColors.textFieldDefaultSearchIconLeft) + : STextStyles.fieldLabel(context); + + return DropdownButtonHideUnderline( + child: DropdownButton2( + value: widget.selectedIso, + items: _countries + .map( + (c) => DropdownMenuItem( + value: c["iso"] as String, + child: Text(c["label"] as String, style: itemStyle), + ), + ) + .toList(), + onMenuStateChange: (isOpen) { + if (!isOpen) { + _searchController.clear(); + } + }, + onChanged: _loading + ? null + : (iso) { + if (iso == null) widget.onChanged(null); + + widget.onChanged(( + name: + _countries.firstWhere((e) => e["iso"] == iso)["label"] + as String, + code: iso!, + requiresState: iso == "CA" || iso == "US", + )); + }, + hint: Text( + _loading ? "Loading countries..." : widget.hintText, + style: hintStyle, + ), + isExpanded: true, + buttonStyleData: ButtonStyleData( + decoration: BoxDecoration( + color: stackColors.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + iconStyleData: IconStyleData( + icon: Padding( + padding: const EdgeInsets.only(right: 10), + child: SvgPicture.asset( + Assets.svg.chevronDown, + width: 12, + height: 6, + color: stackColors.textFieldActiveSearchIconRight, + ), + ), + ), + dropdownStyleData: DropdownStyleData( + offset: const Offset(0, -10), + elevation: 0, + maxHeight: 300, + decoration: BoxDecoration( + color: stackColors.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + dropdownSearchData: DropdownSearchData( + searchController: _searchController, + searchInnerWidgetHeight: 48, + searchInnerWidget: TextFormField( + controller: _searchController, + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + hintText: "Search...", + hintStyle: STextStyles.fieldLabel(context), + border: InputBorder.none, + ), + ), + searchMatchFn: (item, searchValue) { + final String? label = _countries + .where((c) => c["iso"] == item.value) + .map((c) => c["label"] as String) + .firstOrNull; + return label?.toLowerCase().contains(searchValue.toLowerCase()) ?? + false; + }, + ), + menuItemStyleData: const MenuItemStyleData( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_labeled_checkbox.dart b/lib/pages/shopinbit/step_4_components/shopinbit_labeled_checkbox.dart new file mode 100644 index 0000000000..6f4014f88b --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_labeled_checkbox.dart @@ -0,0 +1,49 @@ +import "package:flutter/material.dart"; + +import "../../../utilities/text_styles.dart"; +import "../../../utilities/util.dart"; + +class ShopInBitLabeledCheckbox extends StatelessWidget { + const ShopInBitLabeledCheckbox({ + super.key, + required this.value, + required this.onChanged, + required this.label, + }); + + final bool value; + final ValueChanged onChanged; + final String label; + + @override + Widget build(BuildContext context) { + final TextStyle labelStyle = Util.isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.w500_14(context); + + return GestureDetector( + onTap: () => onChanged(!value), + child: Container( + color: Colors.transparent, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 20, + height: 20, + child: IgnorePointer( + child: Checkbox( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: value, + onChanged: (_) {}, + ), + ), + ), + const SizedBox(width: 12), + Expanded(child: Text(label, style: labelStyle)), + ], + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_privacy_checkbox.dart b/lib/pages/shopinbit/step_4_components/shopinbit_privacy_checkbox.dart new file mode 100644 index 0000000000..5135a89a50 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_privacy_checkbox.dart @@ -0,0 +1,80 @@ +import "package:flutter/gestures.dart"; +import "package:flutter/material.dart"; + +import "../../../utilities/text_styles.dart"; +import "../../../utilities/util.dart"; +import "../../../widgets/dialogs/request_external_link_navigation_dialog.dart"; + +const String _shopInBitPrivacyUrl = + "https://api.shopinbit.com/static/policy/privacy.html"; + +class ShopInBitPrivacyCheckbox extends StatelessWidget { + const ShopInBitPrivacyCheckbox({ + super.key, + required this.value, + required this.onChanged, + }); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + return GestureDetector( + onTap: () => onChanged(!value), + child: Container( + color: Colors.transparent, + child: Row( + crossAxisAlignment: isDesktop + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(top: isDesktop ? 3 : 0), + child: SizedBox( + width: 20, + height: 20, + child: IgnorePointer( + child: Checkbox( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: value, + onChanged: (_) {}, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: RichText( + text: TextSpan( + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.w500_14(context), + children: [ + const TextSpan( + text: "I have read and agree to the ShopinBit ", + ), + TextSpan( + text: "Privacy Policy", + style: STextStyles.richLink( + context, + ).copyWith(fontSize: isDesktop ? 18 : 14), + recognizer: TapGestureRecognizer() + ..onTap = () => showRequestExternalLinkAndMaybeLaunch( + context, + uri: Uri.parse(_shopInBitPrivacyUrl), + ), + ), + const TextSpan(text: "."), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_state_picker.dart b/lib/pages/shopinbit/step_4_components/shopinbit_state_picker.dart new file mode 100644 index 0000000000..fb20d9cd5f --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_state_picker.dart @@ -0,0 +1,234 @@ +import "package:dropdown_button2/dropdown_button2.dart"; +import "package:flutter/material.dart"; +import "package:flutter_svg/svg.dart"; + +import "../../../themes/stack_colors.dart"; +import "../../../utilities/assets.dart"; +import "../../../utilities/constants.dart"; +import "../../../utilities/text_styles.dart"; +import "../../../utilities/util.dart"; + +const List _usStates = [ + "Alabama", + "Alaska", + "Arizona", + "Arkansas", + "California", + "Colorado", + "Connecticut", + "Delaware", + "Florida", + "Georgia", + "Hawaii", + "Idaho", + "Illinois", + "Indiana", + "Iowa", + "Kansas", + "Kentucky", + "Louisiana", + "Maine", + "Maryland", + "Massachusetts", + "Michigan", + "Minnesota", + "Mississippi", + "Missouri", + "Montana", + "Nebraska", + "Nevada", + "New Hampshire", + "New Jersey", + "New Mexico", + "New York", + "North Carolina", + "North Dakota", + "Ohio", + "Oklahoma", + "Oregon", + "Pennsylvania", + "Rhode Island", + "South Carolina", + "South Dakota", + "Tennessee", + "Texas", + "Utah", + "Vermont", + "Virginia", + "Washington", + "West Virginia", + "Wisconsin", + + // Wyoming is now allowed as per chat with shopinbit + // "Wyoming (WY)", +]; + +const List _canadaProvinces = [ + "Alberta", + "British Columbia", + "Manitoba", + "New Brunswick", + "Newfoundland and Labrador", + "Northwest Territories", + "Nova Scotia", + "Nunavut", + "Ontario", + "Prince Edward Island", + "Quebec", + "Saskatchewan", + "Yukon", +]; + +List _statesForCountry(String countryIso) => switch (countryIso) { + "US" => _usStates, + "CA" => _canadaProvinces, + _ => throw ArgumentError.value(countryIso, "countryIso", "Must be US or CA"), +}; + +String _hintTextForCountry(String countryIso) => switch (countryIso) { + "US" => "Select state", + "CA" => "Select province / territory", + _ => throw ArgumentError.value(countryIso, "countryIso", "Must be US or CA"), +}; + +class ShopInBitStatePicker extends StatefulWidget { + const ShopInBitStatePicker({ + super.key, + required this.countryIso, + required this.selectedState, + required this.onChanged, + }); + + final String countryIso; + final String? selectedState; + final ValueChanged onChanged; + + @override + State createState() => _ShopInBitStatePickerState(); +} + +class _ShopInBitStatePickerState extends State { + final TextEditingController _searchController = TextEditingController(); + + List get _states => _statesForCountry(widget.countryIso); + + String? get _validatedSelection { + final String? selected = widget.selectedState; + if (selected == null) return null; + return _states.contains(selected) ? selected : null; + } + + @override + void didUpdateWidget(ShopInBitStatePicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.countryIso != widget.countryIso) { + // Invalidate selection when country changes. + if (widget.selectedState != null && + !_statesForCountry( + widget.countryIso, + ).contains(widget.selectedState)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onChanged(null); + }); + } + } + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final StackColors stackColors = Theme.of(context).extension()!; + + final TextStyle itemStyle = Util.isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith(color: stackColors.textFieldActiveText) + : STextStyles.w500_14(context); + + final TextStyle hintStyle = Util.isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith(color: stackColors.textFieldDefaultSearchIconLeft) + : STextStyles.fieldLabel(context); + + return DropdownButtonHideUnderline( + child: DropdownButton2( + value: _validatedSelection, + items: _states + .map( + (state) => DropdownMenuItem( + value: state, + child: Text(state, style: itemStyle), + ), + ) + .toList(), + onMenuStateChange: (isOpen) { + if (!isOpen) { + _searchController.clear(); + } + }, + onChanged: widget.onChanged, + hint: Text(_hintTextForCountry(widget.countryIso), style: hintStyle), + isExpanded: true, + buttonStyleData: ButtonStyleData( + decoration: BoxDecoration( + color: stackColors.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + iconStyleData: IconStyleData( + icon: Padding( + padding: const EdgeInsets.only(right: 10), + child: SvgPicture.asset( + Assets.svg.chevronDown, + width: 12, + height: 6, + color: stackColors.textFieldActiveSearchIconRight, + ), + ), + ), + dropdownStyleData: DropdownStyleData( + offset: const Offset(0, -10), + elevation: 0, + maxHeight: 300, + decoration: BoxDecoration( + color: stackColors.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + dropdownSearchData: DropdownSearchData( + searchController: _searchController, + searchInnerWidgetHeight: 48, + searchInnerWidget: TextFormField( + controller: _searchController, + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + hintText: "Search...", + hintStyle: STextStyles.fieldLabel(context), + border: InputBorder.none, + ), + ), + searchMatchFn: (item, searchValue) => + item.value?.toLowerCase().contains(searchValue.toLowerCase()) ?? + false, + ), + menuItemStyleData: const MenuItemStyleData( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_dropdown.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_dropdown.dart new file mode 100644 index 0000000000..d3b2e53c3a --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_dropdown.dart @@ -0,0 +1,90 @@ +import "package:dropdown_button2/dropdown_button2.dart"; +import "package:flutter/material.dart"; +import "package:flutter_svg/svg.dart"; + +import "../../../themes/stack_colors.dart"; +import "../../../utilities/assets.dart"; +import "../../../utilities/constants.dart"; +import "../../../utilities/text_styles.dart"; +import "../../../utilities/util.dart"; + +class ShopInBitStep4Dropdown extends StatelessWidget { + const ShopInBitStep4Dropdown({ + super.key, + required this.value, + required this.items, + required this.hintText, + required this.onChanged, + }); + + final String? value; + final List items; + final String hintText; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final stackColors = Theme.of(context).extension()!; + + final itemStyle = Util.isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith(color: stackColors.textFieldActiveText) + : STextStyles.w500_14(context); + + final hintStyle = Util.isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith(color: stackColors.textFieldDefaultSearchIconLeft) + : STextStyles.fieldLabel(context); + + return DropdownButtonHideUnderline( + child: DropdownButton2( + value: value, + items: items + .map( + (item) => DropdownMenuItem( + value: item, + child: Text(item, style: itemStyle), + ), + ) + .toList(), + onChanged: onChanged, + hint: Text(hintText, style: hintStyle), + isExpanded: true, + buttonStyleData: ButtonStyleData( + decoration: BoxDecoration( + color: stackColors.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + iconStyleData: IconStyleData( + icon: Padding( + padding: const EdgeInsets.only(right: 10), + child: SvgPicture.asset( + Assets.svg.chevronDown, + width: 12, + height: 6, + color: stackColors.textFieldActiveSearchIconRight, + ), + ), + ), + dropdownStyleData: DropdownStyleData( + offset: const Offset(0, -10), + elevation: 0, + decoration: BoxDecoration( + color: stackColors.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + menuItemStyleData: const MenuItemStyleData( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_header.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_header.dart new file mode 100644 index 0000000000..c7b20e101d --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_header.dart @@ -0,0 +1,47 @@ +import "package:flutter/material.dart"; + +import "../../../utilities/text_styles.dart"; +import "../../../utilities/util.dart"; +import "../../exchange_view/sub_widgets/step_row.dart"; + +class ShopInBitStep4Header extends StatelessWidget { + const ShopInBitStep4Header({ + super.key, + required this.title, + required this.subtitle, + }); + + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: .min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!Util.isDesktop) ...[ + StepRow( + count: 4, + current: 3, + width: MediaQuery.of(context).size.width - 32, + ), + const SizedBox(height: 14), + ], + Text( + title, + style: Util.isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: Util.isDesktop ? 16 : 8), + Text( + subtitle, + style: Util.isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + ], + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart new file mode 100644 index 0000000000..9bafa78489 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart @@ -0,0 +1,78 @@ +import "dart:async"; + +import "package:flutter/material.dart"; + +import "../../../models/shopinbit/shopinbit_request_draft.dart"; +import "../../../services/shopinbit/shopinbit_service.dart"; +import "../../../services/shopinbit/src/models/ticket.dart"; +import "../../../utilities/logger.dart"; +import "../../../utilities/util.dart"; +import "../../../widgets/stack_dialog.dart"; +import "../shopinbit_order_created.dart"; + +/// Submits a ShopinBit request to the API and navigates to the order-created +/// view on success. +/// +/// Used by the concierge, travel and generic flows. The car flow has its own +/// pre-payment branching (fee view) and does not call this helper. +/// +/// All persistence lives in [ShopInBitService.createRequest], which inserts +/// the fully-provenanced ticket row and kicks off a background refresh, so the +/// UI only has to hand over the [draft] and route on the returned id. +Future submitShopInBitRequest( + BuildContext context, + ShopinbitRequestDraft draft, + ShopInBitService service, +) async { + try { + final TicketRef? ref = await service.createRequest( + category: draft.category, + comment: draft.requestDescription, + deliveryCountry: draft.deliveryCountryCode, + deliveryState: draft.deliveryState, + voucherCode: draft.voucherCode, + ); + + if (ref == null) { + if (context.mounted) { + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to create request", + maxWidth: Util.isDesktop ? 500 : null, + message: "Please try again in a moment.", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + return; + } + + if (!context.mounted) return; + + unawaited( + Navigator.of( + context, + ).pushNamed(ShopInBitOrderCreated.routeName, arguments: ref.id), + ); + } catch (e, s) { + Logging.instance.e( + "Failed to create ShopInBit request", + error: e, + stackTrace: s, + ); + if (context.mounted) { + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to create request", + maxWidth: Util.isDesktop ? 500 : null, + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit_button.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit_button.dart new file mode 100644 index 0000000000..ac38c46bb9 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit_button.dart @@ -0,0 +1,25 @@ +import "package:flutter/material.dart"; + +import "../../../widgets/desktop/primary_button.dart"; + +class ShopInBitStep4SubmitButton extends StatelessWidget { + const ShopInBitStep4SubmitButton({ + super.key, + required this.submitting, + required this.enabled, + required this.onPressed, + }); + + final bool submitting; + final bool enabled; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return PrimaryButton( + label: submitting ? "Submitting..." : "Submit request", + enabled: enabled, + onPressed: enabled ? onPressed : null, + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_travel_form.dart b/lib/pages/shopinbit/step_4_components/shopinbit_travel_form.dart new file mode 100644 index 0000000000..ca9207a11c --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_travel_form.dart @@ -0,0 +1,371 @@ +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +import "../../../models/shopinbit/shopinbit_request_draft.dart"; +import "../../../providers/global/shopin_bit_service_provider.dart"; +import "../../../utilities/text_styles.dart"; +import "../../../utilities/util.dart"; +import "../../../widgets/date_picker/date_picker.dart"; +import "../../../widgets/textfields/adaptive_text_field.dart"; +import "shopinbit_country_picker.dart"; +import "shopinbit_privacy_checkbox.dart"; +import "shopinbit_step4_dropdown.dart"; +import "shopinbit_step4_header.dart"; +import "shopinbit_step4_submit.dart"; +import "shopinbit_step4_submit_button.dart"; +import "shopinbit_traveler_counter.dart"; + +const List _arrangements = ["Flights Only", "Hotels Only"]; + +const int _minTravelBudget = 1000; +const int _minArrangementDetailsLength = 10; + +/// Travel request form. Collects arrangement type, departure / destinations, +/// dates (either exact or flexible), travelers and budget, then submits via +/// the shared submit helper. +class ShopInBitTravelForm extends ConsumerStatefulWidget { + const ShopInBitTravelForm({super.key}); + + @override + ConsumerState createState() => + _ShopInBitTravelFormState(); +} + +class _ShopInBitTravelFormState extends ConsumerState { + final TextEditingController _arrangementDetailsController = + TextEditingController(); + final FocusNode _arrangementDetailsFocusNode = FocusNode(); + bool _arrangementDetailsTouched = false; + + final TextEditingController _departureCityController = + TextEditingController(); + final FocusNode _departureCityFocusNode = FocusNode(); + bool _departureCityTouched = false; + + final TextEditingController _destinationsController = TextEditingController(); + final FocusNode _destinationsFocusNode = FocusNode(); + bool _destinationsTouched = false; + + DateTime? _departureDate; + DateTime? _returnDate; + + final TextEditingController _travelBudgetController = TextEditingController( + text: "5000", + ); + final FocusNode _travelBudgetFocusNode = FocusNode(); + bool _travelBudgetTouched = false; + + String? _selectedArrangement; + String? _selectedDepartureCountryIso; + + int _adults = 1; + int _children = 0; + int _infants = 0; + int _pets = 0; + + bool _privacyAccepted = false; + bool _submitting = false; + + @override + void initState() { + super.initState(); + _wireTouchOnBlur( + _arrangementDetailsFocusNode, + () => _arrangementDetailsTouched = true, + ); + _wireTouchOnBlur( + _departureCityFocusNode, + () => _departureCityTouched = true, + ); + _wireTouchOnBlur(_destinationsFocusNode, () => _destinationsTouched = true); + _wireTouchOnBlur(_travelBudgetFocusNode, () => _travelBudgetTouched = true); + } + + void _wireTouchOnBlur(FocusNode node, VoidCallback markTouched) { + node.addListener(() { + if (!node.hasFocus) markTouched(); + setState(() {}); + }); + } + + @override + void dispose() { + _arrangementDetailsController.dispose(); + _arrangementDetailsFocusNode.dispose(); + _departureCityController.dispose(); + _departureCityFocusNode.dispose(); + _destinationsController.dispose(); + _destinationsFocusNode.dispose(); + _travelBudgetController.dispose(); + _travelBudgetFocusNode.dispose(); + super.dispose(); + } + + bool get _hasValidDates => _departureDate != null && _returnDate != null; + + bool get _canContinue { + final int? travelBudgetValue = int.tryParse( + _travelBudgetController.text.trim(), + ); + return !_submitting && + _privacyAccepted && + _selectedArrangement != null && + _arrangementDetailsController.text.trim().length >= + _minArrangementDetailsLength && + _selectedDepartureCountryIso != null && + _departureCityController.text.trim().isNotEmpty && + _destinationsController.text.trim().isNotEmpty && + _hasValidDates && + _adults >= 1 && + travelBudgetValue != null && + travelBudgetValue >= _minTravelBudget; + } + + String _formatDate(DateTime date) { + final String day = date.day.toString().padLeft(2, "0"); + final String month = date.month.toString().padLeft(2, "0"); + return "$day/$month/${date.year}"; + } + + String _buildRequestDescription() { + final List parts = [ + "Arrangement: $_selectedArrangement", + "Details: ${_arrangementDetailsController.text.trim()}", + "Departure: ${_departureCityController.text.trim()}, " + "${_selectedDepartureCountryIso!}", + ]; + + parts.add("Destinations: ${_destinationsController.text.trim()}"); + + parts.add( + "Dates: ${_formatDate(_departureDate!)} - " + "${_formatDate(_returnDate!)}", + ); + + final List travelers = ["$_adults adult${_adults > 1 ? 's' : ''}"]; + if (_children > 0) { + travelers.add("$_children child${_children > 1 ? 'ren' : ''}"); + } + if (_infants > 0) { + travelers.add("$_infants infant${_infants > 1 ? 's' : ''}"); + } + if (_pets > 0) { + travelers.add("$_pets pet${_pets > 1 ? 's' : ''}"); + } + parts.add("Travelers: ${travelers.join(', ')}"); + + parts.add("Budget: ${_travelBudgetController.text.trim()} EUR"); + + return parts.join("\n"); + } + + Future _submit() async { + setState(() => _submitting = true); + final draft = ShopinbitRequestDraft( + category: .travel, + requestDescription: _buildRequestDescription(), + // Travel doesn't collect a delivery country: default to "DE" since the + // API requires the field. Travel destinations are captured in the + // structured comment field. + deliveryCountryCode: "DE", + voucherCode: null, + deliveryCountryName: "Germany", + deliveryState: null, + ); + try { + await submitShopInBitRequest(context, draft, ref.read(pShopinBitService)); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final bool isDesktop = Util.isDesktop; + + final String? arrangementDetailsError = + _arrangementDetailsTouched && + _arrangementDetailsController.text.trim().length < + _minArrangementDetailsLength + ? "Minimum $_minArrangementDetailsLength characters" + : null; + + final String? departureCityError = + _departureCityTouched && _departureCityController.text.trim().isEmpty + ? "Required" + : null; + + final String? destinationsError = + _destinationsTouched && _destinationsController.text.trim().isEmpty + ? "Required" + : null; + + final String travelBudgetText = _travelBudgetController.text.trim(); + final int? travelBudgetValue = int.tryParse(travelBudgetText); + final String? travelBudgetError = + _travelBudgetTouched && + (travelBudgetText.isEmpty || + travelBudgetValue == null || + travelBudgetValue < _minTravelBudget) + ? "Minimum budget is 1,000 EUR" + : null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const ShopInBitStep4Header( + title: "Travel request", + subtitle: "Tell us about your trip and we'll arrange everything.", + ), + SizedBox(height: isDesktop ? 32 : 24), + + _TravelSectionLabel(text: "Trip type", isDesktop: isDesktop), + SizedBox(height: isDesktop ? 12 : 8), + ShopInBitStep4Dropdown( + value: _selectedArrangement, + items: _arrangements, + hintText: "Arrangement type", + onChanged: (value) => setState(() => _selectedArrangement = value), + ), + + SizedBox(height: isDesktop ? 24 : 16), + _TravelSectionLabel(text: "Where", isDesktop: isDesktop), + SizedBox(height: isDesktop ? 12 : 8), + ShopInBitCountryPicker( + selectedIso: _selectedDepartureCountryIso, + onChanged: (data) => setState(() { + _selectedDepartureCountryIso = data?.code; + }), + hintText: "Departure country", + ), + SizedBox(height: isDesktop ? 24 : 16), + AdaptiveTextField( + controller: _departureCityController, + focusNode: _departureCityFocusNode, + labelText: "Departure city", + autocorrect: false, + enableSuggestions: false, + errorText: departureCityError, + onChanged: (_) => setState(() {}), + ), + SizedBox(height: isDesktop ? 24 : 16), + AdaptiveTextField( + controller: _destinationsController, + focusNode: _destinationsFocusNode, + labelText: "Destination (City, Country, Region)", + autocorrect: false, + enableSuggestions: false, + errorText: destinationsError, + onChanged: (_) => setState(() {}), + ), + + SizedBox(height: isDesktop ? 24 : 16), + _TravelSectionLabel(text: "When", isDesktop: isDesktop), + SizedBox(height: isDesktop ? 12 : 8), + StackDateRangePicker( + fromDate: _departureDate, + toDate: _returnDate, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 3650)), + onChanged: (from, to) { + setState(() { + _departureDate = from; + _returnDate = to; + }); + }, + ), + + SizedBox(height: isDesktop ? 24 : 16), + _TravelSectionLabel(text: "Who", isDesktop: isDesktop), + SizedBox(height: isDesktop ? 12 : 8), + ShopInBitTravelerCounter( + label: "Adults", + value: _adults, + min: 1, + onChanged: (v) => setState(() => _adults = v), + ), + SizedBox(height: isDesktop ? 12 : 8), + ShopInBitTravelerCounter( + label: "Children", + value: _children, + onChanged: (v) => setState(() => _children = v), + ), + SizedBox(height: isDesktop ? 12 : 8), + ShopInBitTravelerCounter( + label: "Infants", + value: _infants, + onChanged: (v) => setState(() => _infants = v), + ), + SizedBox(height: isDesktop ? 12 : 8), + ShopInBitTravelerCounter( + label: "Pets", + value: _pets, + onChanged: (v) => setState(() => _pets = v), + ), + + SizedBox(height: isDesktop ? 24 : 16), + _TravelSectionLabel(text: "Budget", isDesktop: isDesktop), + SizedBox(height: isDesktop ? 12 : 8), + AdaptiveTextField( + controller: _travelBudgetController, + focusNode: _travelBudgetFocusNode, + labelText: "Minimum 1000 EUR", + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + suffixText: "EUR", + autocorrect: false, + enableSuggestions: false, + errorText: travelBudgetError, + onChanged: (_) => setState(() {}), + ), + + SizedBox(height: isDesktop ? 24 : 16), + AdaptiveTextField( + controller: _arrangementDetailsController, + focusNode: _arrangementDetailsFocusNode, + labelText: "Describe your travel needs or paste a LINK here", + minLines: 3, + maxLines: 6, + autocorrect: false, + enableSuggestions: false, + errorText: arrangementDetailsError, + onChanged: (_) => setState(() {}), + ), + + // Travel doesn't collect delivery country: destinations are in the + // form and the API field is set to "DE" on submit. + const SizedBox(height: 24), + ShopInBitPrivacyCheckbox( + value: _privacyAccepted, + onChanged: (v) => setState(() => _privacyAccepted = v), + ), + const SizedBox(height: 32), + ShopInBitStep4SubmitButton( + submitting: _submitting, + enabled: _canContinue, + onPressed: _submit, + ), + ], + ); + } +} + +/// Bold-ish section header used inside the travel form ("Trip type", "Where", +/// "When", "Who", "Budget"). +class _TravelSectionLabel extends StatelessWidget { + const _TravelSectionLabel({required this.text, required this.isDesktop}); + + final String text; + final bool isDesktop; + + @override + Widget build(BuildContext context) { + return Text( + text, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.w500_14(context), + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_traveler_counter.dart b/lib/pages/shopinbit/step_4_components/shopinbit_traveler_counter.dart new file mode 100644 index 0000000000..fb5ab6d412 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_traveler_counter.dart @@ -0,0 +1,85 @@ +import "package:flutter/material.dart"; + +import "../../../themes/stack_colors.dart"; +import "../../../utilities/constants.dart"; +import "../../../utilities/text_styles.dart"; +import "../../../utilities/util.dart"; + +/// Label + minus/value/plus counter row used in the travel form to set the +/// number of adults, children, infants and pets. +class ShopInBitTravelerCounter extends StatelessWidget { + const ShopInBitTravelerCounter({ + super.key, + required this.label, + required this.value, + required this.onChanged, + this.min = 0, + this.max = 20, + }); + + final String label; + final int value; + final int min; + final int max; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final TextStyle textStyle = Util.isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.w500_14(context); + + return Row( + children: [ + Text(label, style: textStyle), + const Spacer(), + _CounterButton( + symbol: "-", + onTap: value > min ? () => onChanged(value - 1) : null, + textStyle: textStyle, + ), + const SizedBox(width: 16), + SizedBox( + width: 24, + child: Center(child: Text("$value", style: textStyle)), + ), + const SizedBox(width: 16), + _CounterButton( + symbol: "+", + onTap: value < max ? () => onChanged(value + 1) : null, + textStyle: textStyle, + ), + ], + ); + } +} + +class _CounterButton extends StatelessWidget { + const _CounterButton({ + required this.symbol, + required this.onTap, + required this.textStyle, + }); + + final String symbol; + final VoidCallback? onTap; + final TextStyle textStyle; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Theme.of(context).extension()!.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Center(child: Text(symbol, style: textStyle)), + ), + ); + } +} diff --git a/lib/pages/signing/signing_view.dart b/lib/pages/signing/signing_view.dart new file mode 100644 index 0000000000..9afaf36d20 --- /dev/null +++ b/lib/pages/signing/signing_view.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../themes/stack_colors.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/custom_tab_view.dart'; +import '../../widgets/stack_dialog.dart'; +import 'sub_widgets/sign_message_tab.dart'; +import 'sub_widgets/verify_message_tab.dart'; + +class SigningView extends ConsumerStatefulWidget { + const SigningView({super.key, required this.walletId}); + + final String walletId; + + static const String routeName = "/signingView"; + + @override + ConsumerState createState() => _SigningViewState(); +} + +class _SigningViewState extends ConsumerState { + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + // keep auto dispose providers alive + ref.listen(pSignIsValid, (_, __) {}); + ref.listen(pVerifyIsValid, (_, __) {}); + + return ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Sign / Verify", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea(child: child), + ), + ), + child: CustomTabView( + titles: const ["Sign message", "Verify message"], + children: [ + SignMessageForm( + key: const Key("_SignMessageFormKey"), + walletId: widget.walletId, + ), + VerifyMessageForm( + key: const Key("_VerifyMessageFormKey"), + walletId: widget.walletId, + ), + ], + ), + ); + } +} + +Future showSignVerifyError(Exception e, {required BuildContext context}) { + String message = e.toString().trim(); + const exceptionPrefix = "Exception:"; + while (message.startsWith(exceptionPrefix) && + message.length > exceptionPrefix.length) { + message = message.substring(exceptionPrefix.length).trim(); + } + return showDialog( + context: context, + builder: (context) => StackOkDialog( + title: "Error", + message: message, + maxWidth: Util.isDesktop ? 400 : null, + desktopPopRootNavigator: Util.isDesktop, + ), + ); +} diff --git a/lib/pages/signing/sub_widgets/address_list.dart b/lib/pages/signing/sub_widgets/address_list.dart new file mode 100644 index 0000000000..66d4635e67 --- /dev/null +++ b/lib/pages/signing/sub_widgets/address_list.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; + +import '../../../models/isar/models/address_label.dart'; +import '../../../models/isar/models/blockchain_data/address.dart'; +import '../../../providers/db/main_db_provider.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/constants.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../widgets/background.dart'; +import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../receive_view/addresses/address_card.dart'; + +class AddressList extends ConsumerStatefulWidget { + const AddressList({super.key, required this.walletId}); + + final String walletId; + + @override + ConsumerState createState() => _AddressListState(); +} + +class _AddressListState extends ConsumerState { + String _searchString = ""; + + late final TextEditingController _searchController; + final searchFieldFocusNode = FocusNode(); + + List _search(String term) { + if (term.isEmpty) { + return ref + .read(mainDBProvider) + .getAddresses(widget.walletId) + .filter() + .group( + (q) => q + .subTypeEqualTo(AddressSubType.change) + .or() + .subTypeEqualTo(AddressSubType.receiving) + .or() + .subTypeEqualTo(AddressSubType.paynymReceive) + .or() + .subTypeEqualTo(AddressSubType.paynymNotification), + ) + .and() + .not() + .typeEqualTo(AddressType.nonWallet) + .and() + .group( + (q) => q + .group( + (q2) => q2 + .typeEqualTo(AddressType.frostMS) + .and() + .zSafeFrostEqualTo(true), + ) + .or() + .not() + .typeEqualTo(AddressType.frostMS), + ) + .sortByDerivationIndex() + .idProperty() + .findAllSync(); + } + + final labels = ref + .read(mainDBProvider) + .getAddressLabels(widget.walletId) + .filter() + .group( + (q) => q + .valueContains(term, caseSensitive: false) + .or() + .addressStringContains(term, caseSensitive: false) + .or() + .group( + (q) => q.tagsIsNotNull().and().tagsElementContains( + term, + caseSensitive: false, + ), + ), + ) + .findAllSync(); + + if (labels.isEmpty) { + return []; + } + + return ref + .read(mainDBProvider) + .getAddresses(widget.walletId) + .filter() + .anyOf( + labels, + (q, e) => q.valueEqualTo(e.addressString), + ) + .group( + (q) => q + .subTypeEqualTo(AddressSubType.change) + .or() + .subTypeEqualTo(AddressSubType.receiving) + .or() + .subTypeEqualTo(AddressSubType.paynymReceive) + .or() + .subTypeEqualTo(AddressSubType.paynymNotification), + ) + .and() + .not() + .typeEqualTo(AddressType.nonWallet) + .and() + .group( + (q) => q + .group( + (q2) => q2 + .typeEqualTo(AddressType.frostMS) + .and() + .zSafeFrostEqualTo(true), + ) + .or() + .not() + .typeEqualTo(AddressType.frostMS), + ) + .sortByDerivationIndex() + .idProperty() + .findAllSync(); + } + + @override + void initState() { + _searchController = TextEditingController(); + + super.initState(); + } + + @override + void dispose() { + _searchController.dispose(); + searchFieldFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final coin = ref.watch(pWalletCoin(widget.walletId)); + + final ids = _search(_searchString); + + return ListView.separated( + shrinkWrap: true, + itemCount: ids.length, + separatorBuilder: (_, __) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Util.isDesktop + ? Container( + height: 1, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + ) + : const SizedBox(height: 2), + ), + itemBuilder: (_, index) => Padding( + padding: const EdgeInsets.all(4), + child: AddressCard( + key: Key("addressCardDesktop_key_${ids[index]}"), + walletId: widget.walletId, + compact: true, + addressId: ids[index], + coin: coin, + onPressed: () => Navigator.of( + context, + ).pop(ref.read(mainDBProvider).isar.addresses.getSync(ids[index])!), + ), + ), + ); + } +} + +class CompactAddressListView extends StatelessWidget { + const CompactAddressListView({super.key, required this.walletId}); + + final String walletId; + + static const routeName = "/compactAddressListView"; + + @override + Widget build(BuildContext context) { + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Choose address", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: Constants.size.standardPadding, + left: Constants.size.standardPadding, + right: Constants.size.standardPadding, + ), + child: AddressList(walletId: walletId), + ), + ), + ), + ); + } +} diff --git a/lib/pages/signing/sub_widgets/sign_message_tab.dart b/lib/pages/signing/sub_widgets/sign_message_tab.dart new file mode 100644 index 0000000000..e7970486e9 --- /dev/null +++ b/lib/pages/signing/sub_widgets/sign_message_tab.dart @@ -0,0 +1,267 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/isar/models/isar_models.dart'; +import '../../../providers/global/wallets_provider.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/constants.dart'; +import '../../../utilities/if_not_already.dart'; +import '../../../utilities/show_loading.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/sign_verify_interface.dart'; +import '../../../widgets/conditional_parent.dart'; +import '../../../widgets/custom_buttons/blue_text_button.dart'; +import '../../../widgets/custom_buttons/simple_copy_button.dart'; +import '../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/detail_item.dart'; +import '../../../widgets/dialogs/s_dialog.dart'; +import '../../../widgets/rounded_container.dart'; +import '../../../widgets/textfields/adaptive_text_field.dart'; +import '../signing_view.dart'; +import 'address_list.dart'; + +final class _SignState { + final String message, signature; + final Address? address; + + _SignState({ + required this.address, + required this.message, + required this.signature, + }); + + bool get isValid => message.isNotEmpty && address != null; + + _SignState copyWith({String? message, String? signature}) { + return _SignState( + address: address, + message: message ?? this.message, + signature: signature ?? this.signature, + ); + } + + _SignState copyWithAddress(Address? address) { + return _SignState(address: address, message: message, signature: signature); + } + + @override + String toString() => + "_SignState(address: $address, message: $message, signature: $signature)"; +} + +final _pSignState = StateProvider.autoDispose((ref) { + return _SignState(address: null, message: "", signature: ""); +}); + +final pSignIsValid = Provider.autoDispose( + (ref) => ref.watch(_pSignState).isValid, +); + +class SignMessageForm extends ConsumerStatefulWidget { + const SignMessageForm({super.key, required this.walletId}); + + final String walletId; + + @override + ConsumerState createState() => _SignMessageFormState(); +} + +class _SignMessageFormState extends ConsumerState { + final messageController = TextEditingController(); + + late final VoidCallback _chooseAddress; + late final VoidCallback _sign; + + TextStyle _getStyle(BuildContext context) { + return Util.isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ) + : STextStyles.smallMed12(context); + } + + @override + void initState() { + super.initState(); + + messageController.text = ref.read(_pSignState).message; + + _chooseAddress = IfNotAlreadyAsync(() async { + final Address? address; + + if (Util.isDesktop) { + address = await showDialog
( + context: context, + builder: (context) { + return SDialog( + contentCanScroll: false, + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => SizedBox(width: 600, child: child), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (Util.isDesktop) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(32), + child: Text( + "Choose address", + style: STextStyles.desktopH3(context), + textAlign: TextAlign.center, + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => Padding( + padding: const EdgeInsets.only( + top: 10, + left: 32, + right: 32, + bottom: 32, + ), + child: RoundedContainer( + padding: EdgeInsets.zero, + color: Colors.transparent, + borderColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + child: child, + ), + ), + + child: AddressList(walletId: widget.walletId), + ), + ), + ], + ), + ), + ); + }, + ); + } else { + address = await Navigator.of(context).pushNamed
( + CompactAddressListView.routeName, + arguments: widget.walletId, + ); + } + + if (address != null && + address.value != ref.read(_pSignState).address?.value && + mounted) { + ref.read(_pSignState.notifier).state = ref + .read(_pSignState) + .copyWithAddress(address) + .copyWith(signature: ""); + } + }).execute; + + _sign = IfNotAlreadyAsync(() async { + Exception? ex; + + final state = ref.read(_pSignState); + final signature = await showLoading( + whileFuture: + (ref.read(pWallets).getWallet(widget.walletId) + as SignVerifyInterface) + .signMessage(state.message, address: state.address!), + context: context, + message: "Signing...", + delay: const Duration(seconds: 1), + onException: (e) => ex = e, + ); + + if (mounted && ex != null) { + await showSignVerifyError(ex!, context: context); + } else if (signature != null && mounted) { + ref.read(_pSignState.notifier).state = state.copyWith( + signature: signature, + ); + } + }).execute; + } + + @override + void dispose() { + messageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Padding( + padding: EdgeInsets.all(Constants.size.standardPadding), + child: child, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: Util.isDesktop ? 20 : 12), + SelectableText("Message", style: _getStyle(context)), + SizedBox(height: Util.isDesktop ? 10 : 8), + AdaptiveTextField( + controller: messageController, + showPasteClearButton: true, + maxLines: 1, + onChangedComprehensive: (_) { + if (mounted) { + ref.read(_pSignState.notifier).state = ref + .read(_pSignState) + .copyWith(message: messageController.text, signature: ""); + } + }, + ), + SizedBox(height: Util.isDesktop ? 20 : 12), + + DetailItem( + title: "Address", + titleStyle: _getStyle(context), + detail: + ref.watch(_pSignState.select((s) => s.address))?.value ?? "", + showEmptyDetail: true, + detailPlaceholder: "n/a", + noPadding: Util.isDesktop, + button: CustomTextButton( + text: "Choose address", + onTap: _chooseAddress, + ), + ), + SizedBox(height: Util.isDesktop ? 20 : 12), + + DetailItem( + title: "Signature", + titleStyle: _getStyle(context), + detail: ref.watch(_pSignState.select((s) => s.signature)), + showEmptyDetail: true, + detailPlaceholder: "n/a", + noPadding: Util.isDesktop, + button: ref.watch(_pSignState.select((s) => s.signature)).isEmpty + ? null + : SimpleCopyButton(data: ref.read(_pSignState).signature), + ), + + const SizedBox(height: 32), + + PrimaryButton( + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + label: "Sign", + enabled: ref.watch(pSignIsValid), + onPressed: ref.watch(pSignIsValid) ? _sign : null, + ), + ], + ), + ); + } +} diff --git a/lib/pages/signing/sub_widgets/verify_message_tab.dart b/lib/pages/signing/sub_widgets/verify_message_tab.dart new file mode 100644 index 0000000000..0a33183174 --- /dev/null +++ b/lib/pages/signing/sub_widgets/verify_message_tab.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/global/wallets_provider.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/constants.dart'; +import '../../../utilities/if_not_already.dart'; +import '../../../utilities/show_loading.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/sign_verify_interface.dart'; +import '../../../widgets/conditional_parent.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/stack_dialog.dart'; +import '../../../widgets/textfields/adaptive_text_field.dart'; +import '../signing_view.dart'; + +final class _VerifyState { + final String address, message, signature; + + _VerifyState({ + required this.address, + required this.message, + required this.signature, + }); + + bool get isValid => + message.isNotEmpty && signature.isNotEmpty && address.isNotEmpty; + + _VerifyState copyWith({String? address, String? message, String? signature}) { + return _VerifyState( + address: address ?? this.address, + message: message ?? this.message, + signature: signature ?? this.signature, + ); + } + + @override + String toString() => + "_VerifyState(address: $address, message: $message, signature: $signature)"; +} + +final _pVerifyState = StateProvider.autoDispose((ref) { + return _VerifyState(address: "", message: "", signature: ""); +}); + +final pVerifyIsValid = Provider.autoDispose( + (ref) => ref.watch(_pVerifyState).isValid, +); + +class VerifyMessageForm extends ConsumerStatefulWidget { + const VerifyMessageForm({super.key, required this.walletId}); + + final String walletId; + + @override + ConsumerState createState() => _VerifyMessageFormState(); +} + +class _VerifyMessageFormState extends ConsumerState { + final messageController = TextEditingController(); + final addressController = TextEditingController(); + final signatureController = TextEditingController(); + + late final VoidCallback _verify; + + TextStyle _getStyle(BuildContext context) { + return Util.isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ) + : STextStyles.smallMed12(context); + } + + @override + void initState() { + super.initState(); + + addressController.text = ref.read(_pVerifyState).address; + messageController.text = ref.read(_pVerifyState).message; + signatureController.text = ref.read(_pVerifyState).signature; + + _verify = IfNotAlreadyAsync(() async { + Exception? ex; + + final verified = await showLoading( + whileFuture: + (ref.read(pWallets).getWallet(widget.walletId) + as SignVerifyInterface) + .verifyMessage( + messageController.text, + address: addressController.text, + signature: signatureController.text, + ), + context: context, + message: "Verifying...", + delay: const Duration(seconds: 1), + onException: (e) => ex = e, + ); + + if (mounted) { + if (ex != null) { + await showSignVerifyError(ex!, context: context); + } else { + await showDialog( + context: context, + builder: (context) => StackOkDialog( + title: verified == true + ? "Verification succeeded" + : "Verification failed", + maxWidth: Util.isDesktop ? 400 : null, + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + } + }).execute; + } + + @override + void dispose() { + messageController.dispose(); + addressController.dispose(); + signatureController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Padding( + padding: EdgeInsets.all(Constants.size.standardPadding), + child: child, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: Util.isDesktop ? 20 : 12), + + SelectableText("Message", style: _getStyle(context)), + SizedBox(height: Util.isDesktop ? 10 : 8), + AdaptiveTextField( + controller: messageController, + showPasteClearButton: true, + maxLines: 1, + onChangedComprehensive: (_) { + if (mounted) { + ref.read(_pVerifyState.notifier).state = ref + .read(_pVerifyState) + .copyWith(message: messageController.text); + } + }, + ), + SizedBox(height: Util.isDesktop ? 20 : 12), + + SelectableText("Address", style: _getStyle(context)), + SizedBox(height: Util.isDesktop ? 10 : 8), + AdaptiveTextField( + controller: addressController, + showPasteClearButton: true, + maxLines: 1, + onChangedComprehensive: (_) { + if (mounted) { + ref.read(_pVerifyState.notifier).state = ref + .read(_pVerifyState) + .copyWith(address: addressController.text); + } + }, + ), + SizedBox(height: Util.isDesktop ? 20 : 12), + + SelectableText("Signature", style: _getStyle(context)), + SizedBox(height: Util.isDesktop ? 10 : 8), + AdaptiveTextField( + controller: signatureController, + showPasteClearButton: true, + maxLines: 1, + onChangedComprehensive: (_) { + if (mounted) { + ref.read(_pVerifyState.notifier).state = ref + .read(_pVerifyState) + .copyWith(signature: signatureController.text); + } + }, + ), + + const SizedBox(height: 32), + + PrimaryButton( + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + label: "Verify", + enabled: ref.watch(pVerifyIsValid), + onPressed: ref.watch(pVerifyIsValid) ? _verify : null, + ), + ], + ), + ); + } +} diff --git a/lib/pages/spark_names/buy_spark_name_view.dart b/lib/pages/spark_names/buy_spark_name_view.dart index 478893dc84..9eeef92566 100644 --- a/lib/pages/spark_names/buy_spark_name_view.dart +++ b/lib/pages/spark_names/buy_spark_name_view.dart @@ -80,8 +80,7 @@ class _BuySparkNameViewState extends ConsumerState { Logging.instance.t( "Found address that already has a spark name. Generating next address...", ); - myAddress = await wallet.generateNextSparkAddress(); - await ref.read(mainDBProvider).updateOrPutAddresses([myAddress]); + myAddress = await wallet.generateNextSparkAddress(saveToDB: true); } addressController.text = myAddress!.value; diff --git a/lib/pages/spark_names/confirm_spark_name_transaction_view.dart b/lib/pages/spark_names/confirm_spark_name_transaction_view.dart index 3282b1f1fb..1c466c7ea3 100644 --- a/lib/pages/spark_names/confirm_spark_name_transaction_view.dart +++ b/lib/pages/spark_names/confirm_spark_name_transaction_view.dart @@ -16,7 +16,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import '../../models/isar/models/blockchain_data/address.dart'; import '../../models/isar/models/transaction_note.dart'; import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart'; @@ -117,7 +116,7 @@ class _ConfirmSparkNameTransactionViewState Future.delayed(const Duration(seconds: 5)), ]); - txids.add(txData.txid!); + txids.addAll(txData.sparkSpends?.map((e) => e.txid!) ?? [txData.txid!]); ref.refresh(desktopUseUTXOs); // save note @@ -132,10 +131,7 @@ class _ConfirmSparkNameTransactionViewState final address = txData.sparkNameInfo?.sparkAddress; final currentReceiving = await wallet.getCurrentReceivingSparkAddress(); if (currentReceiving?.value == address?.value) { - final address = await wallet.generateNextSparkAddress(); - await ref.read(mainDBProvider).isar.writeTxn(() async { - await ref.read(mainDBProvider).isar.addresses.put(address); - }); + await wallet.generateNextSparkAddress(saveToDB: true); } final db = ref.read(pDrift(walletId)); @@ -219,10 +215,9 @@ class _ConfirmSparkNameTransactionViewState child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), onPressed: () { @@ -267,81 +262,76 @@ class _ConfirmSparkNameTransactionViewState return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.background, - leading: AppBarBackButton( - onPressed: () async { - // if (FocusScope.of(context).hasFocus) { - // FocusScope.of(context).unfocus(); - // await Future.delayed(Duration(milliseconds: 50)); - // } - Navigator.of(context).pop(); - }, - ), - title: Text( - "Confirm transaction", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (builderContext, constraints) { - return Padding( - padding: const EdgeInsets.only( - left: 12, - top: 12, - right: 12, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.background, + leading: AppBarBackButton( + onPressed: () async { + // if (FocusScope.of(context).hasFocus) { + // FocusScope.of(context).unfocus(); + // await Future.delayed(Duration(milliseconds: 50)); + // } + Navigator.of(context).pop(); + }, + ), + title: Text( + "Confirm transaction", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, 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: ConditionalParent( condition: isDesktop, - builder: - (child) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, + builder: (child) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( children: [ - Row( - children: [ - AppBarBackButton( - size: 40, - iconSize: 24, - onPressed: - () => - Navigator.of(context, rootNavigator: true).pop(), - ), - Text( - "Confirm transaction", - style: STextStyles.desktopH3(context), - ), - ], + AppBarBackButton( + size: 40, + iconSize: 24, + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(), + ), + Text( + "Confirm transaction", + style: STextStyles.desktopH3(context), ), - Flexible(child: SingleChildScrollView(child: child)), ], ), + Flexible(child: SingleChildScrollView(child: child)), + ], + ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: isDesktop ? MainAxisSize.min : MainAxisSize.max, @@ -487,18 +477,18 @@ class _ConfirmSparkNameTransactionViewState ), child: RoundedWhiteContainer( padding: const EdgeInsets.all(0), - borderColor: - Theme.of(context).extension()!.background, + borderColor: Theme.of( + context, + ).extension()!.background, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.background, + color: Theme.of( + context, + ).extension()!.background, borderRadius: BorderRadius.only( topLeft: Radius.circular( Constants.size.circularBorderRadius, @@ -550,24 +540,23 @@ class _ConfirmSparkNameTransactionViewState const SizedBox(height: 2), SelectableText( widget.txData.sparkNameInfo!.name, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( context, ).extension()!.textDark, - ), + ), ), ], ), ), Container( height: 1, - color: - Theme.of( - context, - ).extension()!.background, + color: Theme.of( + context, + ).extension()!.background, ), Padding( padding: const EdgeInsets.all(12), @@ -584,14 +573,14 @@ class _ConfirmSparkNameTransactionViewState const SizedBox(height: 2), SelectableText( widget.txData.sparkNameInfo!.additionalInfo, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( context, ).extension()!.textDark, - ), + ), ), ], ), @@ -609,14 +598,12 @@ class _ConfirmSparkNameTransactionViewState children: [ SelectableText( "Note (optional)", - style: STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of(context) .extension()! .textFieldActiveSearchIconRight, - ), + ), textAlign: TextAlign.left, ), const SizedBox(height: 10), @@ -631,49 +618,48 @@ class _ConfirmSparkNameTransactionViewState enableSuggestions: isDesktop ? false : true, controller: noteController, focusNode: _noteFocusNode, - style: STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of( context, ).extension()!.textFieldActiveText, - height: 1.8, - ), + height: 1.8, + ), onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Type something...", - _noteFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.only( - left: 16, - top: 11, - bottom: 12, - right: 5, - ), - suffixIcon: - noteController.text.isNotEmpty + decoration: + standardInputDecoration( + "Type something...", + _noteFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 11, + bottom: 12, + right: 5, + ), + 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, - ), + ), ), ), const SizedBox(height: 20), @@ -697,10 +683,9 @@ class _ConfirmSparkNameTransactionViewState horizontal: 16, vertical: 18, ), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: Builder( builder: (context) { final externalCalls = ref.watch( @@ -711,21 +696,17 @@ class _ConfirmSparkNameTransactionViewState String fiatAmount = "N/A"; if (externalCalls) { - final price = - ref - .read(priceAnd24hChangeNotifierProvider) - .getPrice(coin) - ?.value; + final price = ref + .read(priceAnd24hChangeNotifierProvider) + .getPrice(coin) + ?.value; if (price != null && price > Decimal.zero) { fiatAmount = (amountWithoutChange.decimal * price) .toAmount(fractionDigits: 2) .fiatString( - locale: - ref - .read( - localeServiceChangeNotifierProvider, - ) - .locale, + locale: ref + .read(localeServiceChangeNotifierProvider) + .locale, ); } } @@ -770,10 +751,9 @@ class _ConfirmSparkNameTransactionViewState horizontal: 16, vertical: 18, ), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: SelectableText( widget.txData.recipients!.first.address, style: STextStyles.itemSubtitle(context), @@ -797,10 +777,9 @@ class _ConfirmSparkNameTransactionViewState horizontal: 16, vertical: 18, ), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: SelectableText( ref.watch(pAmountFormatter(coin)).format(fee!), style: STextStyles.itemSubtitle(context), @@ -827,10 +806,9 @@ class _ConfirmSparkNameTransactionViewState horizontal: 16, vertical: 18, ), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: SelectableText( "~${fee!.raw.toInt() ~/ widget.txData.vSize!}", style: STextStyles.itemSubtitle(context), @@ -840,64 +818,52 @@ class _ConfirmSparkNameTransactionViewState if (!isDesktop) const Spacer(), SizedBox(height: isDesktop ? 23 : 12), Padding( - padding: - isDesktop - ? const EdgeInsets.symmetric(horizontal: 32) - : const EdgeInsets.all(0), + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 32) + : const EdgeInsets.all(0), child: RoundedContainer( - padding: - isDesktop - ? const EdgeInsets.symmetric( - horizontal: 16, - vertical: 18, - ) - : const EdgeInsets.all(12), - color: - Theme.of( - context, - ).extension()!.snackBarBackSuccess, + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 16, vertical: 18) + : const EdgeInsets.all(12), + color: Theme.of( + context, + ).extension()!.snackBarBackSuccess, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( isDesktop ? "Total amount to send" : "Total amount", - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) - : STextStyles.titleBold12(context).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) + : STextStyles.titleBold12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), ), SelectableText( ref .watch(pAmountFormatter(coin)) .format(amountWithoutChange + fee!), - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) - : STextStyles.itemSubtitle12(context).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) + : STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), textAlign: TextAlign.right, ), ], @@ -906,10 +872,9 @@ class _ConfirmSparkNameTransactionViewState ), SizedBox(height: isDesktop ? 28 : 16), Padding( - padding: - isDesktop - ? const EdgeInsets.symmetric(horizontal: 32) - : const EdgeInsets.all(0), + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 32) + : const EdgeInsets.all(0), child: PrimaryButton( label: "Send", buttonHeight: isDesktop ? ButtonHeight.l : null, @@ -919,28 +884,27 @@ class _ConfirmSparkNameTransactionViewState if (isDesktop) { unlocked = await showDialog( context: context, - builder: - (context) => DesktopDialog( - maxWidth: 580, - maxHeight: double.infinity, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [DesktopDialogCloseButton()], - ), - Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, - ), - child: DesktopAuthSend(coin: coin), - ), - ], + builder: (context) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [DesktopDialogCloseButton()], ), - ), + Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + ), + child: DesktopAuthSend(coin: coin), + ), + ], + ), + ), ); } else { unlocked = await Navigator.push( @@ -948,18 +912,16 @@ class _ConfirmSparkNameTransactionViewState RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: - (_) => const LockscreenView( - showBackButton: true, - popOnSuccess: true, - routeOnSuccessArguments: true, - routeOnSuccess: "", - biometricsCancelButtonString: "CANCEL", - biometricsLocalizedReason: - "Authenticate to send transaction", - biometricsAuthenticationTitle: - "Confirm Transaction", - ), + builder: (_) => const LockscreenView( + showBackButton: true, + popOnSuccess: true, + routeOnSuccessArguments: true, + routeOnSuccess: "", + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: + "Authenticate to send transaction", + biometricsAuthenticationTitle: "Confirm Transaction", + ), settings: const RouteSettings( name: "/confirmsendlockscreen", ), @@ -975,10 +937,9 @@ class _ConfirmSparkNameTransactionViewState unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: - Util.isDesktop - ? "Invalid passphrase" - : "Invalid PIN", + message: Util.isDesktop + ? "Invalid passphrase" + : "Invalid PIN", context: context, ), ); diff --git a/lib/pages/spark_names/sub_widgets/buy_spark_name_option_widget.dart b/lib/pages/spark_names/sub_widgets/buy_spark_name_option_widget.dart index 61c35fe937..6ccc76f4b3 100644 --- a/lib/pages/spark_names/sub_widgets/buy_spark_name_option_widget.dart +++ b/lib/pages/spark_names/sub_widgets/buy_spark_name_option_widget.dart @@ -47,7 +47,7 @@ class _BuySparkNameWidgetState extends ConsumerState { ref.read(pWallets).getWallet(widget.walletId) as SparkInterface; try { - await wallet.electrumXClient.getSparkNameData(sparkName: name); + await wallet.getSparkNameData(sparkName: name); // name exists return false; } catch (e) { @@ -295,6 +295,9 @@ class _NameCard extends ConsumerWidget { ? STextStyles.w500_16(context) : STextStyles.w500_12(context)); + final _isViewOnlyWallet = + (ref.read(pWallets).getWallet(walletId) as SparkInterface).isViewOnly; + return RoundedWhiteContainer( padding: EdgeInsets.all(Util.isDesktop ? 24 : 16), child: IntrinsicHeight( @@ -318,59 +321,63 @@ class _NameCard extends ConsumerWidget { children: [ PrimaryButton( label: "Buy name", - enabled: isAvailable, + enabled: !_isViewOnlyWallet && isAvailable, buttonHeight: Util.isDesktop ? ButtonHeight.m : ButtonHeight.l, width: Util.isDesktop ? 140 : 120, - onPressed: () async { - if (context.mounted) { - if (Util.isDesktop) { - await showDialog( - context: context, - builder: (context) => SDialog( - child: SizedBox( - width: 580, - child: Column( - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only( - left: 32, + onPressed: _isViewOnlyWallet + ? null + : () async { + if (context.mounted) { + if (Util.isDesktop) { + await showDialog( + context: context, + builder: (context) => SDialog( + child: SizedBox( + width: 580, + child: Column( + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only( + left: 32, + ), + child: Text( + "Buy name", + style: STextStyles.desktopH3( + context, + ), + ), + ), + const DesktopDialogCloseButton(), + ], ), - child: Text( - "Buy name", - style: STextStyles.desktopH3(context), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + ), + child: BuySparkNameView( + walletId: walletId, + name: name, + ), ), - ), - const DesktopDialogCloseButton(), - ], - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - ), - child: BuySparkNameView( - walletId: walletId, - name: name, + ], ), ), - ], - ), - ), - ), - ); - } else { - await Navigator.of(context).pushNamed( - BuySparkNameView.routeName, - arguments: (walletId: walletId, name: name), - ); - } - } - }, + ), + ); + } else { + await Navigator.of(context).pushNamed( + BuySparkNameView.routeName, + arguments: (walletId: walletId, name: name), + ); + } + } + }, ), ], ), diff --git a/lib/pages/spark_names/sub_widgets/spark_name_details.dart b/lib/pages/spark_names/sub_widgets/spark_name_details.dart index 372f7484e6..6a5c75a753 100644 --- a/lib/pages/spark_names/sub_widgets/spark_name_details.dart +++ b/lib/pages/spark_names/sub_widgets/spark_name_details.dart @@ -5,10 +5,12 @@ import '../../../db/drift/database.dart'; import '../../../models/isar/models/isar_models.dart'; import '../../../providers/db/drift_provider.dart'; import '../../../providers/db/main_db_provider.dart'; +import '../../../providers/global/wallets_provider.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../../widgets/background.dart'; import '../../../widgets/conditional_parent.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -45,6 +47,8 @@ class _SparkNameDetailsViewState extends ConsumerState { late Stream _nameStream; late SparkName name; + late final bool _isViewOnlyWallet; + Stream? _labelStream; AddressLabel? label; @@ -81,37 +85,36 @@ class _SparkNameDetailsViewState extends ConsumerState { if (Util.isDesktop) { await showDialog( context: context, - builder: - (context) => SDialog( - child: SizedBox( - width: 580, - child: Column( + builder: (context) => SDialog( + child: SizedBox( + width: 580, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Renew name", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: BuySparkNameView( - walletId: widget.walletId, - name: name.name, - nameToRenew: name, + padding: const EdgeInsets.only(left: 32), + child: Text( + "Renew name", + style: STextStyles.desktopH3(context), ), ), + const DesktopDialogCloseButton(), ], ), - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: BuySparkNameView( + walletId: widget.walletId, + name: name.name, + nameToRenew: name, + ), + ), + ], ), + ), + ), ); } else { await Navigator.of(context).pushNamed( @@ -133,6 +136,10 @@ class _SparkNameDetailsViewState extends ConsumerState { super.initState(); name = widget.name; + _isViewOnlyWallet = + (ref.read(pWallets).getWallet(widget.walletId) as SparkInterface) + .isViewOnly; + label = ref .read(mainDBProvider) .getAddressLabelSync(widget.walletId, name.address); @@ -143,9 +150,9 @@ class _SparkNameDetailsViewState extends ConsumerState { final db = ref.read(pDrift(widget.walletId)); - _nameStream = - (db.select(db.sparkNames) - ..where((e) => e.name.equals(name.name))).watchSingleOrNull(); + _nameStream = (db.select( + db.sparkNames, + )..where((e) => e.name.equals(name.name))).watchSingleOrNull(); } @override @@ -159,38 +166,37 @@ class _SparkNameDetailsViewState extends ConsumerState { return ConditionalParent( condition: !Util.isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: Colors.transparent, - appBar: AppBar( - backgroundColor: Colors.transparent, - // Theme.of(context).extension()!.background, - leading: const AppBarBackButton(), - title: Text( - "Spark name details", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - child: IntrinsicHeight(child: child), - ), + builder: (child) => Background( + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + backgroundColor: Colors.transparent, + // Theme.of(context).extension()!.background, + leading: const AppBarBackButton(), + title: Text( + "Spark name details", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, ), - ); - }, - ), - ), + child: IntrinsicHeight(child: child), + ), + ), + ); + }, ), ), + ), + ), child: ConditionalParent( condition: Util.isDesktop, builder: (child) { @@ -221,10 +227,9 @@ class _SparkNameDetailsViewState extends ConsumerState { child: RoundedContainer( padding: EdgeInsets.zero, color: Colors.transparent, - borderColor: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + borderColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: child, ), ), @@ -244,19 +249,17 @@ class _SparkNameDetailsViewState extends ConsumerState { children: [ RoundedContainer( padding: const EdgeInsets.all(12), - color: - Util.isDesktop - ? Colors.transparent - : Theme.of(context).extension()!.popupBG, + color: Util.isDesktop + ? Colors.transparent + : Theme.of(context).extension()!.popupBG, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ SelectableText( name.name, - style: - Util.isDesktop - ? STextStyles.pageTitleH2(context) - : STextStyles.w500_14(context), + style: Util.isDesktop + ? STextStyles.pageTitleH2(context) + : STextStyles.w500_14(context), ), ], ), @@ -264,14 +267,12 @@ class _SparkNameDetailsViewState extends ConsumerState { const _Div(), RoundedContainer( - padding: - Util.isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), - color: - Util.isDesktop - ? Colors.transparent - : Theme.of(context).extension()!.popupBG, + padding: Util.isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), + color: Util.isDesktop + ? Colors.transparent + : Theme.of(context).extension()!.popupBG, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -282,10 +283,9 @@ class _SparkNameDetailsViewState extends ConsumerState { Text( "Address", style: STextStyles.w500_14(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), ), Util.isDesktop @@ -309,74 +309,69 @@ class _SparkNameDetailsViewState extends ConsumerState { return (label != null && label!.value.isNotEmpty) ? Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _Div(), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _Div(), - RoundedContainer( - padding: - Util.isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), - color: - Util.isDesktop - ? Colors.transparent - : Theme.of( + RoundedContainer( + padding: Util.isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), + color: Util.isDesktop + ? Colors.transparent + : Theme.of( context, ).extension()!.popupBG, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - "Address label", - style: STextStyles.w500_14( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textSubtitle1, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + "Address label", + style: STextStyles.w500_14(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ), ), - ), - Util.isDesktop - ? tvd.IconCopyButton( - data: label!.value, - ) - : SimpleCopyButton( - data: label!.value, - ), - ], - ), - const SizedBox(height: 4), - SelectableText( - label!.value, - style: STextStyles.w500_14(context), - ), - ], + Util.isDesktop + ? tvd.IconCopyButton( + data: label!.value, + ) + : SimpleCopyButton( + data: label!.value, + ), + ], + ), + const SizedBox(height: 4), + SelectableText( + label!.value, + style: STextStyles.w500_14(context), + ), + ], + ), ), - ), - ], - ) + ], + ) : const SizedBox(width: 0, height: 0); }, ), const _Div(), RoundedContainer( - padding: - Util.isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), - color: - Util.isDesktop - ? Colors.transparent - : Theme.of(context).extension()!.popupBG, + padding: Util.isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), + color: Util.isDesktop + ? Colors.transparent + : Theme.of(context).extension()!.popupBG, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -387,10 +382,9 @@ class _SparkNameDetailsViewState extends ConsumerState { Text( "Expiry", style: STextStyles.w500_14(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), ), const SizedBox(height: 4), @@ -402,11 +396,12 @@ class _SparkNameDetailsViewState extends ConsumerState { ), ], ), - if (remaining < _remainingMagic) + if (remaining < _remainingMagic && !_isViewOnlyWallet) PrimaryButton( label: "Renew", - buttonHeight: - Util.isDesktop ? ButtonHeight.xs : ButtonHeight.l, + buttonHeight: Util.isDesktop + ? ButtonHeight.xs + : ButtonHeight.l, onPressed: _renew, ), ], @@ -414,14 +409,12 @@ class _SparkNameDetailsViewState extends ConsumerState { ), const _Div(), RoundedContainer( - padding: - Util.isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), - color: - Util.isDesktop - ? Colors.transparent - : Theme.of(context).extension()!.popupBG, + padding: Util.isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), + color: Util.isDesktop + ? Colors.transparent + : Theme.of(context).extension()!.popupBG, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -429,10 +422,9 @@ class _SparkNameDetailsViewState extends ConsumerState { Text( "Additional info", style: STextStyles.w500_14(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), ), const SizedBox(height: 4), diff --git a/lib/pages/special/firo_rescan_recovery_error_dialog.dart b/lib/pages/special/firo_rescan_recovery_error_dialog.dart index 1b8675db6b..8e8c21f6a1 100644 --- a/lib/pages/special/firo_rescan_recovery_error_dialog.dart +++ b/lib/pages/special/firo_rescan_recovery_error_dialog.dart @@ -12,8 +12,7 @@ import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../wallets/wallet/intermediate/lib_monero_wallet.dart'; -import '../../wallets/wallet/intermediate/lib_salvium_wallet.dart'; +import '../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; import '../../widgets/background.dart'; @@ -65,21 +64,20 @@ class _FiroRescanRecoveryErrorViewState final result = await showDialog( context: context, barrierDismissible: false, - builder: - (context) => Navigator( - initialRoute: DesktopDeleteWalletDialog.routeName, - onGenerateRoute: RouteGenerator.generateRoute, - onGenerateInitialRoutes: (_, __) { - return [ - RouteGenerator.generateRoute( - RouteSettings( - name: DesktopDeleteWalletDialog.routeName, - arguments: widget.walletId, - ), - ), - ]; - }, - ), + builder: (context) => Navigator( + initialRoute: DesktopDeleteWalletDialog.routeName, + onGenerateRoute: RouteGenerator.generateRoute, + onGenerateInitialRoutes: (_, __) { + return [ + RouteGenerator.generateRoute( + RouteSettings( + name: DesktopDeleteWalletDialog.routeName, + arguments: widget.walletId, + ), + ), + ]; + }, + ), ); if (result == true) { @@ -100,8 +98,9 @@ class _FiroRescanRecoveryErrorViewState builder: (child) { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( automaticallyImplyLeading: false, actions: [ @@ -120,18 +119,16 @@ class _FiroRescanRecoveryErrorViewState key: const Key("walletViewRadioButton"), size: 36, shadows: const [], - color: - Theme.of( - context, - ).extension()!.background, + color: Theme.of( + context, + ).extension()!.background, icon: SvgPicture.asset( Assets.svg.trash, width: 20, height: 20, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), onPressed: () async { final walletName = ref.read( @@ -140,71 +137,60 @@ class _FiroRescanRecoveryErrorViewState await showDialog( barrierDismissible: true, context: context, - builder: - (_) => StackDialog( - title: "Do you want to delete $walletName?", - leftButton: TextButton( - style: Theme.of(context) + builder: (_) => StackDialog( + title: "Do you want to delete $walletName?", + leftButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + onPressed: () { + Navigator.pop(context); + }, + child: Text( + "Cancel", + style: STextStyles.button(context).copyWith( + color: Theme.of(context) .extension()! - .getSecondaryEnabledButtonStyle( - context, - ), - onPressed: () { - Navigator.pop(context); - }, - child: Text( - "Cancel", - style: STextStyles.button( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .accentColorDark, - ), - ), + .accentColorDark, ), - rightButton: TextButton( - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle( - context, - ), - onPressed: () { - Navigator.pop(context); - Navigator.push( - context, - RouteGenerator.getRoute( - shouldUseMaterialRoute: - RouteGenerator - .useMaterialPageRoute, - builder: - (_) => LockscreenView( - routeOnSuccessArguments: - widget.walletId, - showBackButton: true, - routeOnSuccess: - DeleteWalletWarningView - .routeName, - biometricsCancelButtonString: - "CANCEL", - biometricsLocalizedReason: - "Authenticate to delete wallet", - biometricsAuthenticationTitle: - "Delete wallet", - ), - settings: const RouteSettings( - name: "/deleteWalletLockscreen", - ), - ), - ); - }, - child: Text( - "Delete", - style: STextStyles.button(context), + ), + ), + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + onPressed: () { + Navigator.pop(context); + Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: + RouteGenerator.useMaterialPageRoute, + builder: (_) => LockscreenView( + routeOnSuccessArguments: + widget.walletId, + showBackButton: true, + routeOnSuccess: + DeleteWalletWarningView.routeName, + biometricsCancelButtonString: + "CANCEL", + biometricsLocalizedReason: + "Authenticate to delete wallet", + biometricsAuthenticationTitle: + "Delete wallet", + ), + settings: const RouteSettings( + name: "/deleteWalletLockscreen", + ), ), - ), + ); + }, + child: Text( + "Delete", + style: STextStyles.button(context), ), + ), + ), ); }, ), @@ -232,20 +218,18 @@ class _FiroRescanRecoveryErrorViewState Util.isDesktop ? const SizedBox(height: 60) : const Spacer(), BranchedParent( condition: Util.isDesktop, - conditionBranchBuilder: - (children) => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, - ), - otherBranchBuilder: - (children) => Row( - children: [ - Expanded(child: children[0]), - children[1], - Expanded(child: children[2]), - ], - ), + conditionBranchBuilder: (children) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + otherBranchBuilder: (children) => Row( + children: [ + Expanded(child: children[0]), + children[1], + Expanded(child: children[2]), + ], + ), children: [ SecondaryButton( label: "Show mnemonic", @@ -255,21 +239,20 @@ class _FiroRescanRecoveryErrorViewState await showDialog( context: context, barrierDismissible: false, - builder: - (context) => Navigator( - initialRoute: UnlockWalletKeysDesktop.routeName, - onGenerateRoute: RouteGenerator.generateRoute, - onGenerateInitialRoutes: (_, __) { - return [ - RouteGenerator.generateRoute( - RouteSettings( - name: UnlockWalletKeysDesktop.routeName, - arguments: widget.walletId, - ), - ), - ]; - }, - ), + builder: (context) => Navigator( + initialRoute: UnlockWalletKeysDesktop.routeName, + onGenerateRoute: RouteGenerator.generateRoute, + onGenerateInitialRoutes: (_, __) { + return [ + RouteGenerator.generateRoute( + RouteSettings( + name: UnlockWalletKeysDesktop.routeName, + arguments: widget.walletId, + ), + ), + ]; + }, + ), ); } else { final wallet = ref @@ -282,9 +265,7 @@ class _FiroRescanRecoveryErrorViewState KeyDataInterface? keyData; if (wallet is ExtendedKeysInterface) { keyData = await wallet.getXPrivs(); - } else if (wallet is LibMoneroWallet) { - keyData = await wallet.getKeys(); - } else if (wallet is LibSalviumWallet) { + } else if (wallet is CryptonoteWallet) { keyData = await wallet.getKeys(); } @@ -294,22 +275,20 @@ class _FiroRescanRecoveryErrorViewState RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: - (_) => LockscreenView( - routeOnSuccessArguments: ( - walletId: widget.walletId, - mnemonic: mnemonic, - keyData: keyData, - ), - showBackButton: true, - routeOnSuccess: - WalletBackupView.routeName, - biometricsCancelButtonString: "CANCEL", - biometricsLocalizedReason: - "Authenticate to view recovery phrase", - biometricsAuthenticationTitle: - "View recovery phrase", - ), + builder: (_) => LockscreenView( + routeOnSuccessArguments: ( + walletId: widget.walletId, + mnemonic: mnemonic, + keyData: keyData, + ), + showBackButton: true, + routeOnSuccess: WalletBackupView.routeName, + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: + "Authenticate to view recovery phrase", + biometricsAuthenticationTitle: + "View recovery phrase", + ), settings: const RouteSettings( name: "/viewRecoverPhraseLockscreen", ), diff --git a/lib/pages/token_view/my_tokens_view.dart b/lib/pages/token_view/my_tokens_view.dart index 10e84751b2..725fc51936 100644 --- a/lib/pages/token_view/my_tokens_view.dart +++ b/lib/pages/token_view/my_tokens_view.dart @@ -19,6 +19,7 @@ import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/coins/solana.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; @@ -28,6 +29,7 @@ import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../add_wallet_views/add_token_view/edit_wallet_tokens_view.dart'; import 'sub_widgets/my_tokens_list.dart'; +import 'sub_widgets/sol_tokens_list.dart'; class MyTokensView extends ConsumerStatefulWidget { const MyTokensView({super.key, required this.walletId}); @@ -66,80 +68,73 @@ class _MyTokensViewState extends ConsumerState { return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.background, - leading: AppBarBackButton( - onPressed: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 75), - ); - } - if (mounted) { - Navigator.of(context).pop(); - } - }, - ), - title: Text( - "${ref.watch(pWalletName(widget.walletId))} Tokens", - style: STextStyles.navBarTitle(context), - ), - actions: [ - Padding( - padding: const EdgeInsets.only( - top: 10, - bottom: 10, - right: 20, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.background, + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + if (mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "${ref.watch(pWalletName(widget.walletId))} Tokens", + style: STextStyles.navBarTitle(context), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10, right: 20), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + key: const Key("addTokenAppBarIconButtonKey"), + size: 36, + shadows: const [], + color: Theme.of( + context, + ).extension()!.background, + icon: SvgPicture.asset( + Assets.svg.circlePlusFilled, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + width: 20, + height: 20, ), - child: AspectRatio( - aspectRatio: 1, - child: AppBarIconButton( - key: const Key("addTokenAppBarIconButtonKey"), - size: 36, - shadows: const [], - color: - Theme.of( - context, - ).extension()!.background, - icon: SvgPicture.asset( - Assets.svg.circlePlusFilled, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, - width: 20, - height: 20, - ), - onPressed: () async { - final result = await Navigator.of(context).pushNamed( - EditWalletTokensView.routeName, - arguments: widget.walletId, - ); + onPressed: () async { + final result = await Navigator.of(context).pushNamed( + EditWalletTokensView.routeName, + arguments: widget.walletId, + ); - if (mounted && result == 42) { - setState(() {}); - } - }, - ), - ), + if (mounted && result == 42) { + setState(() {}); + } + }, ), - ], - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.only(left: 12, top: 12, right: 12), - child: child, ), ), + ], + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only(left: 12, top: 12, right: 12), + child: child, ), ), + ), + ), child: Column( children: [ Padding( @@ -166,57 +161,55 @@ class _MyTokensViewState extends ConsumerState { _searchString = value; }); }, - style: - isDesktop - ? STextStyles.desktopTextExtraSmall( + style: isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: Theme.of( context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: standardInputDecoration( - "Search...", - searchFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - prefixIcon: Padding( - padding: EdgeInsets.symmetric( - horizontal: isDesktop ? 12 : 10, - vertical: isDesktop ? 18 : 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: isDesktop ? 20 : 16, - height: isDesktop ? 20 : 16, - ), - ), - suffixIcon: - _searchController.text.isNotEmpty + ).extension()!.textFieldActiveText, + height: 1.8, + ) + : STextStyles.field(context), + decoration: + standardInputDecoration( + "Search...", + searchFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + prefixIcon: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 12 : 10, + vertical: isDesktop ? 18 : 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: isDesktop ? 20 : 16, + height: isDesktop ? 20 : 16, + ), + ), + 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, - ), + ), ), ), ), @@ -226,11 +219,21 @@ class _MyTokensViewState extends ConsumerState { ), const SizedBox(height: 8), Expanded( - child: MyTokensList( - walletId: widget.walletId, - searchTerm: _searchString, - tokenContracts: ref.watch(pWalletTokenAddresses(widget.walletId)), - ), + child: ref.watch(pWalletCoin(widget.walletId)) is Solana + ? SolanaTokensList( + walletId: widget.walletId, + searchTerm: _searchString, + tokenMints: ref.watch( + pWalletTokenAddresses(widget.walletId), + ), + ) + : MyTokensList( + walletId: widget.walletId, + searchTerm: _searchString, + tokenContracts: ref.watch( + pWalletTokenAddresses(widget.walletId), + ), + ), ), ], ), diff --git a/lib/pages/token_view/sol_token_view.dart b/lib/pages/token_view/sol_token_view.dart new file mode 100644 index 0000000000..ab8b462ef2 --- /dev/null +++ b/lib/pages/token_view/sol_token_view.dart @@ -0,0 +1,245 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:event_bus/event_bus.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:tuple/tuple.dart'; + +import '../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/text_styles.dart'; +import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../wallets/isar/providers/solana/solana_wallet_provider.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/custom_buttons/blue_text_button.dart'; +import '../../widgets/icon_widgets/sol_token_icon.dart'; +import 'solana_token_contract_details_view.dart'; +import 'sub_widgets/token_summary_sol.dart'; +import 'sub_widgets/token_transaction_list_widget_sol.dart'; + +/// [eventBus] should only be set during testing. +class SolTokenView extends ConsumerStatefulWidget { + const SolTokenView({ + super.key, + required this.walletId, + this.popPrevious = false, + this.eventBus, + }); + + static const String routeName = "/sol_token"; + + final String walletId; + final bool popPrevious; + final EventBus? eventBus; + + @override + ConsumerState createState() => _SolTokenViewState(); +} + +class _SolTokenViewState extends ConsumerState { + late final WalletSyncStatus initialSyncStatus; + + @override + void initState() { + // Get the initial sync status from the Solana wallet's refresh mutex. + final solanaWallet = ref.read(pSolanaWallet(widget.walletId)); + initialSyncStatus = solanaWallet?.refreshMutex.isLocked ?? false + ? WalletSyncStatus.syncing + : WalletSyncStatus.synced; + + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + return WillPopScope( + onWillPop: () async { + final nav = Navigator.of(context); + if (widget.popPrevious) { + nav.pop(); + } + nav.pop(); + return false; + }, + child: Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + final nav = Navigator.of(context); + if (widget.popPrevious) { + nav.pop(); + } + nav.pop(); + }, + ), + centerTitle: true, + title: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SolTokenIcon( + mintAddress: ref.watch( + pCurrentSolanaTokenWallet.select((s) => s!.tokenMint), + ), + size: 24, + ), + const SizedBox(width: 10), + Flexible( + child: Text( + ref.watch( + pCurrentSolanaTokenWallet.select( + (s) => s!.tokenName, + ), + ), + style: STextStyles.navBarTitle(context), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ], + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 2), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + icon: SvgPicture.asset( + Assets.svg.verticalEllipsis, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + ), + onPressed: () { + Navigator.of(context).pushNamed( + SolanaTokenContractDetailsView.routeName, + arguments: Tuple2( + ref.read(pCurrentSolanaTokenWallet)!.tokenMint, + widget.walletId, + ), + ); + }, + ), + ), + ), + ], + ), + body: SafeArea( + child: Container( + color: Theme.of(context).extension()!.background, + child: Column( + children: [ + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SolanaTokenSummary( + walletId: widget.walletId, + tokenMint: ref.watch( + pCurrentSolanaTokenWallet.select((s) => s!.tokenMint), + ), + initialSyncStatus: initialSyncStatus, + ), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Transactions", + style: STextStyles.itemSubtitle(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), + ), + CustomTextButton( + text: "See all", + onTap: () { + // TODO: Navigate to all transactions for this token. + // Navigator.of(context).pushNamed( + // AllTransactionsV2View.routeName, + // arguments: ( + // walletId: widget.walletId, + // tokenMint: widget.tokenMint, + // ), + // ); + }, + ), + ], + ), + ), + const SizedBox(height: 12), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ClipRRect( + borderRadius: BorderRadius.vertical( + top: Radius.circular( + Constants.size.circularBorderRadius, + ), + bottom: Radius.circular( + // TokenView.navBarHeight / 2.0, + Constants.size.circularBorderRadius, + ), + ), + child: Container( + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: SolanaTokenTransactionsList( + walletId: widget.walletId, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/token_view/solana_token_contract_details_view.dart b/lib/pages/token_view/solana_token_contract_details_view.dart new file mode 100644 index 0000000000..2ed51ed1fe --- /dev/null +++ b/lib/pages/token_view/solana_token_contract_details_view.dart @@ -0,0 +1,181 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; + +import '../../db/isar/main_db.dart'; +import '../../models/isar/models/isar_models.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/custom_buttons/simple_copy_button.dart'; +import '../../widgets/rounded_white_container.dart'; + +class SolanaTokenContractDetailsView extends ConsumerStatefulWidget { + const SolanaTokenContractDetailsView({ + super.key, + required this.tokenMint, + required this.walletId, + }); + + static const String routeName = "/solanaTokenContractDetailsView"; + + final String tokenMint; + final String walletId; + + @override + ConsumerState createState() => + _SolanaTokenContractDetailsViewState(); +} + +class _SolanaTokenContractDetailsViewState + extends ConsumerState { + final isDesktop = Util.isDesktop; + + late SolContract token; + + @override + void initState() { + token = MainDB.instance.isar.solContracts + .where() + .addressEqualTo(widget.tokenMint) + .findFirstSync()!; + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + titleSpacing: 0, + title: Text( + "Token details", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Item( + title: "Mint address", + data: token.address, + button: SimpleCopyButton(data: token.address), + ), + const SizedBox(height: 12), + _Item( + title: "Name", + data: token.name, + button: SimpleCopyButton(data: token.name), + ), + const SizedBox(height: 12), + _Item( + title: "Symbol", + data: token.symbol, + button: SimpleCopyButton(data: token.symbol), + ), + const SizedBox(height: 12), + _Item( + title: "Decimals", + data: token.decimals.toString(), + button: SimpleCopyButton(data: token.decimals.toString()), + ), + if (token.metadataAddress != null) ...[ + const SizedBox(height: 12), + _Item( + title: "Metadata address", + data: token.metadataAddress ?? "", + button: SimpleCopyButton(data: token.metadataAddress ?? ""), + ), + ], + ], + ), + ); + } +} + +class _Item extends StatelessWidget { + const _Item({ + super.key, + required this.title, + required this.data, + required this.button, + }); + + final String title; + final String data; + final Widget button; + + @override + Widget build(BuildContext context) { + return RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: STextStyles.itemSubtitle(context)), + button, + ], + ), + const SizedBox(height: 5), + data.isNotEmpty + ? SelectableText(data, style: STextStyles.w500_14(context)) + : Text( + "$title will appear here", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle3, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/token_view/sub_widgets/my_token_select_item.dart b/lib/pages/token_view/sub_widgets/my_token_select_item.dart index 745f2a0fd5..a00813e23f 100644 --- a/lib/pages/token_view/sub_widgets/my_token_select_item.dart +++ b/lib/pages/token_view/sub_widgets/my_token_select_item.dart @@ -15,7 +15,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../models/isar/models/ethereum/eth_contract.dart'; import '../../../pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart'; -import '../../../providers/db/main_db_provider.dart'; import '../../../providers/providers.dart'; import '../../../services/ethereum/cached_eth_token_balance.dart'; import '../../../themes/stack_colors.dart'; @@ -64,21 +63,20 @@ class _MyTokenSelectItemState extends ConsumerState { await showDialog( barrierDismissible: false, context: context, - builder: - (context) => BasicDialog( - title: "Failed to load token data", - desktopHeight: double.infinity, - desktopWidth: 450, - rightButton: PrimaryButton( - label: "OK", - onPressed: () { - Navigator.of(context).pop(); - if (!isDesktop) { - Navigator.of(context).pop(); - } - }, - ), - ), + builder: (context) => BasicDialog( + title: "Failed to load token data", + desktopHeight: double.infinity, + desktopWidth: 450, + rightButton: PrimaryButton( + label: "OK", + onPressed: () { + Navigator.of(context).pop(); + if (!isDesktop) { + Navigator.of(context).pop(); + } + }, + ), + ), ); return false; } @@ -153,10 +151,9 @@ class _MyTokenSelectItemState extends ConsumerState { padding: const EdgeInsets.all(0), child: MaterialButton( key: Key("walletListItemButtonKey_${widget.token.symbol}"), - padding: - isDesktop - ? const EdgeInsets.symmetric(horizontal: 28, vertical: 24) - : const EdgeInsets.symmetric(horizontal: 12, vertical: 13), + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 28, vertical: 24) + : const EdgeInsets.symmetric(horizontal: 12, vertical: 13), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( @@ -181,17 +178,15 @@ class _MyTokenSelectItemState extends ConsumerState { children: [ Text( widget.token.name, - style: - isDesktop - ? STextStyles.desktopTextExtraSmall( + style: isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: Theme.of( context, - ).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark, - ) - : STextStyles.titleBold12(context), + ).extension()!.textDark, + ) + : STextStyles.titleBold12(context), ), const Spacer(), Text( @@ -210,19 +205,17 @@ class _MyTokenSelectItemState extends ConsumerState { )), ) .total, - ethContract: widget.token, + tokenContract: widget.token, ), - style: - isDesktop - ? STextStyles.desktopTextExtraSmall( + style: isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: Theme.of( context, - ).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark, - ) - : STextStyles.itemSubtitle(context), + ).extension()!.textDark, + ) + : STextStyles.itemSubtitle(context), ), ], ), @@ -231,24 +224,22 @@ class _MyTokenSelectItemState extends ConsumerState { children: [ Text( widget.token.symbol, - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle(context), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle(context), ), const Spacer(), if (priceString != null) Text( "$priceString " "${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle(context), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle(context), ), ], ), diff --git a/lib/pages/token_view/sub_widgets/sol_token_select_item.dart b/lib/pages/token_view/sub_widgets/sol_token_select_item.dart new file mode 100644 index 0000000000..498ef025c8 --- /dev/null +++ b/lib/pages/token_view/sub_widgets/sol_token_select_item.dart @@ -0,0 +1,247 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/isar/models/solana/sol_contract.dart'; +import '../../../pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart'; +import '../../../providers/providers.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/constants.dart'; +import '../../../utilities/show_loading.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; +import '../../../wallets/wallet/impl/solana_wallet.dart'; +import '../../../wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; +import '../../../wallets/wallet/wallet.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/dialogs/basic_dialog.dart'; +import '../../../widgets/icon_widgets/sol_token_icon.dart'; +import '../../../widgets/rounded_white_container.dart'; +import '../sol_token_view.dart'; + +class SolTokenSelectItem extends ConsumerStatefulWidget { + const SolTokenSelectItem({ + super.key, + required this.walletId, + required this.token, + }); + + final String walletId; + final SolContract token; + + @override + ConsumerState createState() => _SolTokenSelectItemState(); +} + +class _SolTokenSelectItemState extends ConsumerState { + final bool isDesktop = Util.isDesktop; + + Future _loadTokenWallet(BuildContext context, WidgetRef ref) async { + try { + await ref.read(pCurrentSolanaTokenWallet)!.init(); + return true; + } catch (_) { + await showDialog( + barrierDismissible: false, + context: context, + builder: (context) => BasicDialog( + title: "Failed to load token data", + desktopHeight: double.infinity, + desktopWidth: 450, + rightButton: PrimaryButton( + label: "OK", + onPressed: () { + Navigator.of(context).pop(); + if (!isDesktop) { + Navigator.of(context).pop(); + } + }, + ), + ), + ); + return false; + } + } + + void _onPressed() async { + final old = ref.read(solanaTokenServiceStateProvider); + // exit previous if there is one + unawaited(old?.exit()); + + // Get the parent Solana wallet. + final solanaWallet = + ref.read(pWallets).getWallet(widget.walletId) as SolanaWallet?; + if (solanaWallet == null) { + if (mounted) { + await showDialog( + barrierDismissible: false, + context: context, + builder: (context) => BasicDialog( + title: "Error: Parent Solana wallet not found", + desktopHeight: double.infinity, + desktopWidth: 450, + rightButton: PrimaryButton( + label: "OK", + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + ); + } + return; + } + + ref.read(solanaTokenServiceStateProvider.state).state = + Wallet.loadSolTokenWallet( + solWallet: solanaWallet, + contract: widget.token, + ) + as SolanaTokenWallet; + + final success = await showLoading( + whileFuture: _loadTokenWallet(context, ref), + context: context, + rootNavigator: isDesktop, + message: "Loading ${widget.token.name}", + ); + + if (!success!) { + return; + } + + if (mounted) { + unawaited(ref.read(pCurrentSolanaTokenWallet)!.refresh()); + await Navigator.of(context).pushNamed( + isDesktop ? DesktopSolTokenView.routeName : SolTokenView.routeName, + arguments: widget.walletId, + ); + } + } + + @override + Widget build(BuildContext context) { + String? priceString; + if (ref.watch(prefsChangeNotifierProvider.select((s) => s.externalCalls))) { + priceString = ref.watch( + priceAnd24hChangeNotifierProvider.select( + (s) => + s.getTokenPrice(widget.token.address)?.value.toStringAsFixed(2), + ), + ); + } + + return RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + child: MaterialButton( + key: Key("walletListItemButtonKey_${widget.token.symbol}"), + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 28, vertical: 24) + : const EdgeInsets.symmetric(horizontal: 12, vertical: 13), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: _onPressed, + child: Row( + children: [ + SolTokenIcon(mintAddress: widget.token.address, size: 32), + SizedBox(width: isDesktop ? 12 : 10), + Expanded( + child: Consumer( + builder: (_, ref, __) { + // Watch the balance from the database. + final balance = ref.watch( + pSolanaTokenBalance(( + walletId: widget.walletId, + tokenMint: widget.token.address, + )), + ); + + // Format the balance. + final decimalValue = balance.total.decimal.toStringAsFixed( + widget.token.decimals, + ); + final balanceString = "$decimalValue ${widget.token.symbol}"; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text( + widget.token.name, + style: isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ) + : STextStyles.titleBold12(context), + ), + const Spacer(), + Text( + balanceString, + style: isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ) + : STextStyles.itemSubtitle(context), + ), + ], + ), + const SizedBox(height: 2), + Row( + children: [ + Text( + widget.token.symbol, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle(context), + ), + const Spacer(), + if (priceString != null) + Text( + "$priceString " + "${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle(context), + ), + ], + ), + ], + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/token_view/sub_widgets/sol_tokens_list.dart b/lib/pages/token_view/sub_widgets/sol_tokens_list.dart new file mode 100644 index 0000000000..ec8a0678c2 --- /dev/null +++ b/lib/pages/token_view/sub_widgets/sol_tokens_list.dart @@ -0,0 +1,95 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; + +import '../../../models/isar/models/solana/sol_contract.dart'; +import '../../../providers/db/main_db_provider.dart'; +import '../../../utilities/util.dart'; +import 'sol_token_select_item.dart'; + +class SolanaTokensList extends StatelessWidget { + const SolanaTokensList({ + super.key, + required this.walletId, + required this.searchTerm, + required this.tokenMints, + }); + + final String walletId; + final String searchTerm; + final List tokenMints; + + List _filter(String searchTerm, List allTokens) { + if (tokenMints.isEmpty) { + return []; + } + + // Filter to only tokens in the wallet's token list. + var filtered = allTokens + .where((token) => tokenMints.contains(token.address)) + .toList(); + + // Apply search filter if provided. + if (searchTerm.isNotEmpty) { + final term = searchTerm.toLowerCase(); + filtered = filtered + .where( + (token) => + token.name.toLowerCase().contains(term) || + token.symbol.toLowerCase().contains(term) || + token.address.toLowerCase().contains(term), + ) + .toList(); + } + + return filtered; + } + + @override + Widget build(BuildContext context) { + final bool isDesktop = Util.isDesktop; + + return Consumer( + builder: (_, ref, __) { + // Get all available SOL tokens. + final db = ref.watch(mainDBProvider); + + final allTokens = db.getSolContracts().findAllSync(); + + final tokens = _filter(searchTerm, allTokens); + + if (tokens.isEmpty) { + return Center( + child: Text( + "No tokens in this wallet", + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + + return ListView.builder( + itemCount: tokens.length, + itemBuilder: (ctx, index) { + final token = tokens[index]; + return Padding( + key: Key(token.address), + padding: isDesktop + ? const EdgeInsets.symmetric(vertical: 5) + : const EdgeInsets.all(4), + child: SolTokenSelectItem(walletId: walletId, token: token), + ); + }, + ); + }, + ); + } +} diff --git a/lib/pages/token_view/sub_widgets/token_summary.dart b/lib/pages/token_view/sub_widgets/token_summary.dart index 2c09077cb1..0f1bd177d4 100644 --- a/lib/pages/token_view/sub_widgets/token_summary.dart +++ b/lib/pages/token_view/sub_widgets/token_summary.dart @@ -83,10 +83,9 @@ class TokenSummary extends ConsumerWidget { children: [ SvgPicture.asset( Assets.svg.walletDesktop, - color: - Theme.of( - context, - ).extension()!.tokenSummaryTextSecondary, + color: Theme.of( + context, + ).extension()!.tokenSummaryTextSecondary, width: 12, height: 12, ), @@ -94,10 +93,9 @@ class TokenSummary extends ConsumerWidget { Text( ref.watch(pWalletName(walletId)), style: STextStyles.w500_12(context).copyWith( - color: - Theme.of( - context, - ).extension()!.tokenSummaryTextSecondary, + color: Theme.of( + context, + ).extension()!.tokenSummaryTextSecondary, ), ), ], @@ -113,12 +111,11 @@ class TokenSummary extends ConsumerWidget { Ethereum(CryptoCurrencyNetwork.main), ), ) - .format(balance.total, ethContract: token), + .format(balance.total, tokenContract: token), style: STextStyles.pageTitleH1(context).copyWith( - color: - Theme.of( - context, - ).extension()!.tokenSummaryTextPrimary, + color: Theme.of( + context, + ).extension()!.tokenSummaryTextPrimary, ), ), const SizedBox(width: 10), @@ -134,10 +131,9 @@ class TokenSummary extends ConsumerWidget { Text( "${(balance.total.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: ref.watch(localeServiceChangeNotifierProvider.select((value) => value.locale)))} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", style: STextStyles.subtitle500(context).copyWith( - color: - Theme.of( - context, - ).extension()!.tokenSummaryTextPrimary, + color: Theme.of( + context, + ).extension()!.tokenSummaryTextPrimary, ), ), const SizedBox(height: 20), @@ -156,8 +152,9 @@ class TokenSummary extends ConsumerWidget { (value) => value!.tokenContract.address, ), ), - overrideIconColor: - Theme.of(context).extension()!.topNavIconPrimary, + overrideIconColor: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), ), ], @@ -266,8 +263,9 @@ class TokenOptionsButton extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ RawMaterialButton( - fillColor: - Theme.of(context).extension()!.tokenSummaryButtonBG, + fillColor: Theme.of( + context, + ).extension()!.tokenSummaryButtonBG, elevation: 0, focusElevation: 0, hoverElevation: 0, @@ -283,36 +281,31 @@ class TokenOptionsButton extends StatelessWidget { padding: const EdgeInsets.all(10), child: ConditionalParent( condition: iconSize < 24, - builder: - (child) => RoundedContainer( - padding: const EdgeInsets.all(6), - color: Theme.of(context) - .extension()! - .tokenSummaryIcon - .withOpacity(0.4), - radiusMultiplier: 10, - child: Center(child: child), - ), - child: - iconAssetPathSVG.startsWith("assets/") - ? SvgPicture.asset( - iconAssetPathSVG, - color: - Theme.of( - context, - ).extension()!.tokenSummaryIcon, - width: iconSize, - height: iconSize, - ) - : SvgPicture.file( - File(iconAssetPathSVG), - color: - Theme.of( - context, - ).extension()!.tokenSummaryIcon, - width: iconSize, - height: iconSize, - ), + builder: (child) => RoundedContainer( + padding: const EdgeInsets.all(6), + color: Theme.of( + context, + ).extension()!.tokenSummaryIcon.withOpacity(0.4), + radiusMultiplier: 10, + child: Center(child: child), + ), + child: iconAssetPathSVG.startsWith("assets/") + ? SvgPicture.asset( + iconAssetPathSVG, + color: Theme.of( + context, + ).extension()!.tokenSummaryIcon, + width: iconSize, + height: iconSize, + ) + : SvgPicture.file( + File(iconAssetPathSVG), + color: Theme.of( + context, + ).extension()!.tokenSummaryIcon, + width: iconSize, + height: iconSize, + ), ), ), ), @@ -320,10 +313,9 @@ class TokenOptionsButton extends StatelessWidget { Text( subLabel, style: STextStyles.w500_12(context).copyWith( - color: - Theme.of( - context, - ).extension()!.tokenSummaryTextPrimary, + color: Theme.of( + context, + ).extension()!.tokenSummaryTextPrimary, ), ), ], diff --git a/lib/pages/token_view/sub_widgets/token_summary_sol.dart b/lib/pages/token_view/sub_widgets/token_summary_sol.dart new file mode 100644 index 0000000000..9d627c1f58 --- /dev/null +++ b/lib/pages/token_view/sub_widgets/token_summary_sol.dart @@ -0,0 +1,316 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'dart:io'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../../providers/global/locale_provider.dart'; +import '../../../providers/global/price_provider.dart'; +import '../../../providers/global/prefs_provider.dart'; +import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/assets.dart'; +import '../../../utilities/constants.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; +import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../widgets/coin_ticker_tag.dart'; +import '../../../widgets/conditional_parent.dart'; +import '../../../widgets/rounded_container.dart'; +import '../../receive_view/sol_token_receive_view.dart'; +import '../../send_view/sol_token_send_view.dart'; +import '../../wallet_view/sub_widgets/wallet_refresh_button.dart'; + +/// Solana-specific token summary widget. +/// +/// Displays token balance, wallet name, and available actions for Solana tokens. +class SolanaTokenSummary extends ConsumerWidget { + const SolanaTokenSummary({ + super.key, + required this.walletId, + required this.tokenMint, + required this.initialSyncStatus, + }); + + final String walletId; + final String tokenMint; + final WalletSyncStatus initialSyncStatus; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Get the Solana token wallet. + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + + // If wallet is not initialized, show a placeholder. + if (tokenWallet == null) { + return RoundedContainer( + color: Theme.of(context).extension()!.tokenSummaryBG, + padding: const EdgeInsets.all(24), + child: Center( + child: Text( + "Loading token data...", + style: STextStyles.subtitle500(context).copyWith( + color: + Theme.of(context).extension()!.tokenSummaryTextPrimary, + ), + ), + ), + ); + } + + // Watch the balance from the database provider. + final balance = ref.watch( + pSolanaTokenBalance( + ( + walletId: walletId, + tokenMint: tokenMint, + ), + ), + ); + + Decimal? price; + if (ref.watch(prefsChangeNotifierProvider.select((s) => s.externalCalls))) { + // Get the token price from the price service. + price = ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getTokenPrice(tokenMint)?.value, + ), + ); + } + + return Stack( + children: [ + RoundedContainer( + color: Theme.of(context).extension()!.tokenSummaryBG, + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + Assets.svg.walletDesktop, + color: Theme.of( + context, + ).extension()!.tokenSummaryTextSecondary, + width: 12, + height: 12, + ), + const SizedBox(width: 6), + Text( + ref.watch(pWalletName(walletId)), + style: STextStyles.w500_12(context).copyWith( + color: Theme.of( + context, + ).extension()!.tokenSummaryTextSecondary, + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + balance.total.decimal.toStringAsFixed(tokenWallet.tokenDecimals), + style: STextStyles.pageTitleH1(context).copyWith( + color: Theme.of( + context, + ).extension()!.tokenSummaryTextPrimary, + ), + ), + const SizedBox(width: 10), + CoinTickerTag( + ticker: tokenWallet.tokenSymbol, + ), + ], + ), + if (price != null) const SizedBox(height: 6), + if (price != null) + Text( + "${(balance.total.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: ref.watch(localeServiceChangeNotifierProvider.select((value) => value.locale)))} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", + style: STextStyles.subtitle500(context).copyWith( + color: Theme.of( + context, + ).extension()!.tokenSummaryTextPrimary, + ), + ), + const SizedBox(height: 20), + SolanaTokenWalletOptions( + walletId: walletId, + tokenMint: tokenMint, + ), + ], + ), + ), + Positioned( + top: 10, + right: 10, + child: WalletRefreshButton( + walletId: walletId, + initialSyncStatus: initialSyncStatus, + tokenContractAddress: tokenMint, + overrideIconColor: + Theme.of(context).extension()!.topNavIconPrimary, + ), + ), + ], + ); + } +} + +/// Solana token wallet action buttons (Send, Receive, etc.). +class SolanaTokenWalletOptions extends ConsumerWidget { + const SolanaTokenWalletOptions({ + super.key, + required this.walletId, + required this.tokenMint, + }); + + final String walletId; + final String tokenMint; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // TODO: Use prefs for enabling/disabling exchange features when implemented for Solana. + // final prefs = ref.watch(prefsChangeNotifierProvider); + // final showExchange = prefs.enableExchange; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TokenOptionsButton( + onPressed: () { + Navigator.of(context).pushNamed( + SolTokenReceiveView.routeName, + arguments: (walletId, tokenMint), + ); + }, + subLabel: "Receive", + iconAssetPathSVG: Assets.svg.arrowDownLeft, + ), + const SizedBox(width: 16), + TokenOptionsButton( + onPressed: () { + Navigator.of(context).pushNamed( + SolTokenSendView.routeName, + arguments: (walletId, tokenMint), + ); + }, + subLabel: "Send", + iconAssetPathSVG: Assets.svg.arrowUpRight, + ), + // TODO: Add swap and buy buttons when Solana token swap/buy views are implemented. + // if (AppConfig.hasFeature(AppFeature.swap) && showExchange) + // const SizedBox(width: 16), + // if (AppConfig.hasFeature(AppFeature.swap) && showExchange) + // TokenOptionsButton( + // onPressed: () => _onExchangePressed(context), + // subLabel: "Swap", + // iconAssetPathSVG: ref.watch( + // themeProvider.select((value) => value.assets.exchange), + // ), + // ), + ], + ); + } +} + +/// A button for token wallet options (Send, Receive, Swap, Buy). +class TokenOptionsButton extends StatelessWidget { + const TokenOptionsButton({ + super.key, + required this.onPressed, + required this.subLabel, + required this.iconAssetPathSVG, + }); + + final VoidCallback onPressed; + final String subLabel; + final String iconAssetPathSVG; + + @override + Widget build(BuildContext context) { + final iconSize = subLabel == "Send" || subLabel == "Receive" ? 12.0 : 24.0; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + RawMaterialButton( + fillColor: + Theme.of(context).extension()!.tokenSummaryButtonBG, + elevation: 0, + focusElevation: 0, + hoverElevation: 0, + highlightElevation: 0, + constraints: const BoxConstraints(), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: onPressed, + child: Padding( + padding: const EdgeInsets.all(10), + child: ConditionalParent( + condition: iconSize < 24, + builder: + (child) => RoundedContainer( + padding: const EdgeInsets.all(6), + color: Theme.of(context) + .extension()! + .tokenSummaryIcon + .withOpacity(0.4), + radiusMultiplier: 10, + child: Center(child: child), + ), + child: + iconAssetPathSVG.startsWith("assets/") + ? SvgPicture.asset( + iconAssetPathSVG, + color: + Theme.of( + context, + ).extension()!.tokenSummaryIcon, + width: iconSize, + height: iconSize, + ) + : SvgPicture.file( + File(iconAssetPathSVG), + color: + Theme.of( + context, + ).extension()!.tokenSummaryIcon, + width: iconSize, + height: iconSize, + ), + ), + ), + ), + const SizedBox(height: 6), + Text( + subLabel, + style: STextStyles.w500_12(context).copyWith( + color: + Theme.of( + context, + ).extension()!.tokenSummaryTextPrimary, + ), + ), + ], + ); + } +} diff --git a/lib/pages/token_view/sub_widgets/token_transaction_list_widget.dart b/lib/pages/token_view/sub_widgets/token_transaction_list_widget.dart index dc8255726e..d69414fb78 100644 --- a/lib/pages/token_view/sub_widgets/token_transaction_list_widget.dart +++ b/lib/pages/token_view/sub_widgets/token_transaction_list_widget.dart @@ -13,9 +13,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:isar_community/isar.dart'; + import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; -import '../../wallet_view/sub_widgets/no_transactions_found.dart'; -import '../../wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart'; import '../../../providers/db/main_db_provider.dart'; import '../../../providers/global/wallets_provider.dart'; import '../../../themes/stack_colors.dart'; @@ -23,12 +22,11 @@ import '../../../utilities/constants.dart'; import '../../../utilities/util.dart'; import '../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../../widgets/loading_indicator.dart'; +import '../../wallet_view/sub_widgets/no_transactions_found.dart'; +import '../../wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart'; class TokenTransactionsList extends ConsumerStatefulWidget { - const TokenTransactionsList({ - super.key, - required this.walletId, - }); + const TokenTransactionsList({super.key, required this.walletId}); final String walletId; @@ -48,23 +46,15 @@ class _TransactionsListState extends ConsumerState { BorderRadius get _borderRadiusFirst { return BorderRadius.only( - topLeft: Radius.circular( - Constants.size.circularBorderRadius, - ), - topRight: Radius.circular( - Constants.size.circularBorderRadius, - ), + topLeft: Radius.circular(Constants.size.circularBorderRadius), + topRight: Radius.circular(Constants.size.circularBorderRadius), ); } BorderRadius get _borderRadiusLast { return BorderRadius.only( - bottomLeft: Radius.circular( - Constants.size.circularBorderRadius, - ), - bottomRight: Radius.circular( - Constants.size.circularBorderRadius, - ), + bottomLeft: Radius.circular(Constants.size.circularBorderRadius), + bottomRight: Radius.circular(Constants.size.circularBorderRadius), ); } @@ -75,22 +65,20 @@ class _TransactionsListState extends ConsumerState { .getWallet(widget.walletId) .cryptoCurrency .minConfirms; - _query = - ref.read(mainDBProvider).isar.transactionV2s.buildQuery( - whereClauses: [ - IndexWhereClause.equalTo( - indexName: 'walletId', - value: [widget.walletId], - ), - ], - filter: ref.read(pCurrentTokenWallet)!.transactionFilterOperation, - sortBy: [ - const SortProperty( - property: "timestamp", - sort: Sort.desc, - ), - ], - ); + _query = ref + .read(mainDBProvider) + .isar + .transactionV2s + .buildQuery( + whereClauses: [ + IndexWhereClause.equalTo( + indexName: 'walletId', + value: [widget.walletId], + ), + ], + filter: ref.read(pCurrentTokenWallet)!.transactionFilterOperation, + sortBy: [const SortProperty(property: "timestamp", sort: Sort.desc)], + ); _subscription = _query.watch().listen((event) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -110,8 +98,9 @@ class _TransactionsListState extends ConsumerState { @override Widget build(BuildContext context) { - final wallet = - ref.watch(pWallets.select((value) => value.getWallet(widget.walletId))); + final wallet = ref.watch( + pWallets.select((value) => value.getWallet(widget.walletId)), + ); return FutureBuilder( future: _query.findAll(), @@ -125,22 +114,14 @@ class _TransactionsListState extends ConsumerState { return const Column( children: [ Spacer(), - Center( - child: LoadingIndicator( - height: 50, - width: 50, - ), - ), - Spacer( - flex: 4, - ), + Center(child: LoadingIndicator(height: 50, width: 50)), + Spacer(flex: 4), ], ); } if (_transactions.isEmpty) { return const NoTransActionsFound(); } else { - _transactions.sort((a, b) => b.timestamp - a.timestamp); return RefreshIndicator( onRefresh: () async { if (!ref.read(pCurrentTokenWallet)!.refreshMutex.isLocked) { @@ -171,9 +152,9 @@ class _TransactionsListState extends ConsumerState { return Container( width: double.infinity, height: 2, - color: Theme.of(context) - .extension()! - .background, + color: Theme.of( + context, + ).extension()!.background, ); }, itemCount: _transactions.length, diff --git a/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart b/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart new file mode 100644 index 0000000000..59a0a440c6 --- /dev/null +++ b/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart @@ -0,0 +1,194 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; + +import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import '../../../providers/db/main_db_provider.dart'; +import '../../../providers/global/wallets_provider.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/constants.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../widgets/loading_indicator.dart'; +import '../../wallet_view/sub_widgets/no_transactions_found.dart'; +import '../../wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart'; + +/// Solana-specific transaction list widget. +/// +/// Displays transactions for a Solana token using the Solana token wallet provider. +class SolanaTokenTransactionsList extends ConsumerStatefulWidget { + const SolanaTokenTransactionsList({super.key, required this.walletId}); + + final String walletId; + + @override + ConsumerState createState() => + _SolanaTransactionsListState(); +} + +class _SolanaTransactionsListState + extends ConsumerState { + late final int minConfirms; + + bool _hasLoaded = false; + List _transactions = []; + + late final StreamSubscription> _subscription; + late final Query _query; + + BorderRadius get _borderRadiusFirst { + return BorderRadius.only( + topLeft: Radius.circular(Constants.size.circularBorderRadius), + topRight: Radius.circular(Constants.size.circularBorderRadius), + ); + } + + BorderRadius get _borderRadiusLast { + return BorderRadius.only( + bottomLeft: Radius.circular(Constants.size.circularBorderRadius), + bottomRight: Radius.circular(Constants.size.circularBorderRadius), + ); + } + + @override + void initState() { + minConfirms = ref + .read(pWallets) + .getWallet(widget.walletId) + .cryptoCurrency + .minConfirms; + _query = ref + .read(mainDBProvider) + .isar + .transactionV2s + .buildQuery( + whereClauses: [ + IndexWhereClause.equalTo( + indexName: 'walletId', + value: [widget.walletId], + ), + ], + filter: ref + .read(pCurrentSolanaTokenWallet)! + .transactionFilterOperation, + sortBy: [const SortProperty(property: "timestamp", sort: Sort.desc)], + ); + + _subscription = _query.watch().listen((event) { + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _transactions = event; + }); + }); + }); + super.initState(); + } + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final wallet = ref.watch( + pWallets.select((value) => value.getWallet(widget.walletId)), + ); + + return FutureBuilder( + future: _query.findAll(), + builder: (fbContext, AsyncSnapshot> snapshot) { + if (snapshot.connectionState == ConnectionState.done && + snapshot.hasData) { + _transactions = snapshot.data!; + _hasLoaded = true; + } + if (!_hasLoaded) { + return const Column( + children: [ + Spacer(), + Center(child: LoadingIndicator(height: 50, width: 50)), + Spacer(flex: 4), + ], + ); + } + + if (_transactions.isEmpty) { + return const NoTransActionsFound(); + } else { + return RefreshIndicator( + onRefresh: () async { + if (!ref.read(pCurrentSolanaTokenWallet)!.refreshMutex.isLocked) { + unawaited(ref.read(pCurrentSolanaTokenWallet)!.refresh()); + } + }, + child: Util.isDesktop + ? ListView.separated( + itemBuilder: (context, index) { + BorderRadius? radius; + if (_transactions.length == 1) { + radius = BorderRadius.circular( + Constants.size.circularBorderRadius, + ); + } else if (index == _transactions.length - 1) { + radius = _borderRadiusLast; + } else if (index == 0) { + radius = _borderRadiusFirst; + } + final tx = _transactions[index]; + return TxListItem( + tx: tx, + coin: wallet.info.coin, + radius: radius, + ); + }, + separatorBuilder: (context, index) { + return Container( + width: double.infinity, + height: 2, + color: Theme.of( + context, + ).extension()!.background, + ); + }, + itemCount: _transactions.length, + ) + : ListView.builder( + itemCount: _transactions.length, + itemBuilder: (context, index) { + BorderRadius? radius; + if (_transactions.length == 1) { + radius = BorderRadius.circular( + Constants.size.circularBorderRadius, + ); + } else if (index == _transactions.length - 1) { + radius = _borderRadiusLast; + } else if (index == 0) { + radius = _borderRadiusFirst; + } + final tx = _transactions[index]; + return TxListItem( + tx: tx, + coin: wallet.info.coin, + radius: radius, + ); + }, + ), + ); + } + }, + ); + } +} diff --git a/lib/pages/wallet_view/sub_widgets/wallet_navigation_bar.dart b/lib/pages/wallet_view/sub_widgets/wallet_navigation_bar.dart deleted file mode 100644 index a022be5888..0000000000 --- a/lib/pages/wallet_view/sub_widgets/wallet_navigation_bar.dart +++ /dev/null @@ -1,9 +0,0 @@ -/* - * This file is part of Stack Wallet. - * - * Copyright (c) 2023 Cypher Stack - * All Rights Reserved. - * The code is distributed under GPLv3 license, see LICENSE file for details. - * Generated by Cypher Stack on 2023-05-26 - * - */ diff --git a/lib/pages/wallet_view/sub_widgets/wallet_refresh_button.dart b/lib/pages/wallet_view/sub_widgets/wallet_refresh_button.dart index 6e98f4a3fb..e75a063d58 100644 --- a/lib/pages/wallet_view/sub_widgets/wallet_refresh_button.dart +++ b/lib/pages/wallet_view/sub_widgets/wallet_refresh_button.dart @@ -21,6 +21,7 @@ import '../../../themes/stack_colors.dart'; import '../../../utilities/constants.dart'; import '../../../utilities/util.dart'; import '../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; +import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../../../widgets/animated_widgets/rotating_arrows.dart'; /// [eventBus] should only be set during testing @@ -112,13 +113,24 @@ class _RefreshButtonState extends ConsumerState { splashColor: Theme.of(context).extension()!.highlight, onPressed: () { if (widget.tokenContractAddress == null) { - final wallet = ref.read(pWallets).getWallet(widget.walletId); - final isRefreshing = wallet.refreshMutex.isLocked; - if (!isRefreshing) { - _spinController.repeat?.call(); - wallet.refresh().then((_) => _spinController.stop?.call()); + // Solana token - check if there's a current Solana token wallet. + final solanaTokenWallet = ref.read(pCurrentSolanaTokenWallet); + if (solanaTokenWallet != null) { + if (!solanaTokenWallet.refreshMutex.isLocked) { + _spinController.repeat?.call(); + solanaTokenWallet.refresh().then((_) => _spinController.stop?.call()); + } + } else { + // Fall back to refreshing the parent Solana wallet. + final wallet = ref.read(pWallets).getWallet(widget.walletId); + final isRefreshing = wallet.refreshMutex.isLocked; + if (!isRefreshing) { + _spinController.repeat?.call(); + wallet.refresh().then((_) => _spinController.stop?.call()); + } } } else { + // Ethereum token. if (!ref.read(pCurrentTokenWallet)!.refreshMutex.isLocked) { ref.read(pCurrentTokenWallet)!.refresh(); } diff --git a/lib/pages/wallet_view/transaction_views/transaction_details_view.dart b/lib/pages/wallet_view/transaction_views/transaction_details_view.dart index 1e3aa7c729..b38935dadf 100644 --- a/lib/pages/wallet_view/transaction_views/transaction_details_view.dart +++ b/lib/pages/wallet_view/transaction_views/transaction_details_view.dart @@ -97,7 +97,9 @@ class _TransactionDetailsViewState void initState() { isDesktop = Util.isDesktop; _transaction = widget.transaction; - isTokenTx = _transaction.subType == TransactionSubType.ethToken; + isTokenTx = + _transaction.subType == TransactionSubType.ethToken || + _transaction.subType == TransactionSubType.splToken; walletId = widget.walletId; minConfirms = ref @@ -518,7 +520,7 @@ class _TransactionDetailsViewState : CrossAxisAlignment.start, children: [ SelectableText( - "$amountPrefix${ref.watch(pAmountFormatter(coin)).format(amount, ethContract: ethContract)}", + "$amountPrefix${ref.watch(pAmountFormatter(coin)).format(amount, tokenContract: ethContract)}", style: isDesktop ? STextStyles.desktopTextExtraExtraSmall( context, diff --git a/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart b/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart index a337cd898a..b799d10eb9 100644 --- a/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart +++ b/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart @@ -11,7 +11,6 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/svg.dart'; import '../../../models/transaction_filter.dart'; import '../../../providers/global/locale_provider.dart'; @@ -21,9 +20,7 @@ import '../../../themes/theme_providers.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/amount/amount_formatter.dart'; import '../../../utilities/amount/amount_input_formatter.dart'; -import '../../../utilities/assets.dart'; import '../../../utilities/constants.dart'; -import '../../../utilities/format.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; @@ -60,9 +57,6 @@ class _TransactionSearchViewState bool _isActiveSentCheckbox = false; bool _isActiveTradeCheckbox = false; - String _fromDateString = ""; - String _toDateString = ""; - final keywordTextFieldFocusNode = FocusNode(); final amountTextFieldFocusNode = FocusNode(); @@ -79,19 +73,11 @@ class _TransactionSearchViewState _selectedFromDate = filterState.from; _keywordTextEditingController.text = filterState.keyword; - _fromDateString = - _selectedFromDate == null - ? "" - : Format.formatDate(_selectedFromDate!); - _toDateString = - _selectedToDate == null ? "" : Format.formatDate(_selectedToDate!); - - final String amount = - filterState.amount == null - ? "" - : ref - .read(pAmountFormatter(widget.coin)) - .format(filterState.amount!, withUnitName: false); + final String amount = filterState.amount == null + ? "" + : ref + .read(pAmountFormatter(widget.coin)) + .format(filterState.amount!, withUnitName: false); _amountTextEditingController.text = amount; } @@ -109,239 +95,9 @@ class _TransactionSearchViewState super.dispose(); } - // The following two getters are not required if the - // date fields are to remain unclearable. - Widget get _dateFromText { - final isDateSelected = _fromDateString.isEmpty; - return Text( - isDateSelected ? "From..." : _fromDateString, - style: STextStyles.fieldLabel(context).copyWith( - color: - isDateSelected - ? Theme.of(context).extension()!.textSubtitle2 - : Theme.of(context).extension()!.accentColorDark, - ), - ); - } - - Widget get _dateToText { - final isDateSelected = _toDateString.isEmpty; - return Text( - isDateSelected ? "To..." : _toDateString, - style: STextStyles.fieldLabel(context).copyWith( - color: - isDateSelected - ? Theme.of(context).extension()!.textSubtitle2 - : Theme.of(context).extension()!.accentColorDark, - ), - ); - } - DateTime? _selectedFromDate = DateTime(2007); DateTime? _selectedToDate = DateTime.now(); - Widget _buildDateRangePicker() { - const middleSeparatorPadding = 2.0; - const middleSeparatorWidth = 12.0; - final isDesktop = Util.isDesktop; - - final width = - isDesktop - ? null - : (MediaQuery.of(context).size.width - - (middleSeparatorWidth + - (2 * middleSeparatorPadding) + - (2 * Constants.size.standardPadding))) / - 2; - - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: GestureDetector( - key: const Key("transactionSearchViewFromDatePickerKey"), - onTap: () async { - // check and hide keyboard - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed(const Duration(milliseconds: 125)); - } - - if (mounted) { - final date = await showSWDatePicker(context); - if (date != null) { - _selectedFromDate = date; - - // flag to adjust date so from date is always before to date - final flag = - _selectedToDate != null && - !_selectedFromDate!.isBefore(_selectedToDate!); - if (flag) { - _selectedToDate = DateTime.fromMillisecondsSinceEpoch( - _selectedFromDate!.millisecondsSinceEpoch, - ); - } - - setState(() { - if (flag) { - _toDateString = - _selectedToDate == null - ? "" - : Format.formatDate(_selectedToDate!); - } - _fromDateString = - _selectedFromDate == null - ? "" - : Format.formatDate(_selectedFromDate!); - }); - } - } - }, - child: Container( - width: width, - decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - border: Border.all( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - width: 1, - ), - ), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 12, - vertical: isDesktop ? 17 : 12, - ), - child: Row( - children: [ - SvgPicture.asset( - Assets.svg.calendar, - height: 20, - width: 20, - color: - Theme.of( - context, - ).extension()!.textSubtitle2, - ), - const SizedBox(width: 10), - Align( - alignment: Alignment.centerLeft, - child: FittedBox(child: _dateFromText), - ), - ], - ), - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: middleSeparatorPadding, - ), - child: Container( - width: middleSeparatorWidth, - // height: 1, - // color: CFColors.smoke, - ), - ), - Expanded( - child: GestureDetector( - key: const Key("transactionSearchViewToDatePickerKey"), - onTap: () async { - // check and hide keyboard - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed(const Duration(milliseconds: 125)); - } - - if (mounted) { - final date = await showSWDatePicker(context); - if (date != null) { - _selectedToDate = date; - - // flag to adjust date so from date is always before to date - final flag = - _selectedFromDate != null && - !_selectedToDate!.isAfter(_selectedFromDate!); - if (flag) { - _selectedFromDate = DateTime.fromMillisecondsSinceEpoch( - _selectedToDate!.millisecondsSinceEpoch, - ); - } - - setState(() { - if (flag) { - _fromDateString = - _selectedFromDate == null - ? "" - : Format.formatDate(_selectedFromDate!); - } - _toDateString = - _selectedToDate == null - ? "" - : Format.formatDate(_selectedToDate!); - }); - } - } - }, - child: Container( - width: width, - decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - border: Border.all( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - width: 1, - ), - ), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 12, - vertical: isDesktop ? 17 : 12, - ), - child: Row( - children: [ - SvgPicture.asset( - Assets.svg.calendar, - height: 20, - width: 20, - color: - Theme.of( - context, - ).extension()!.textSubtitle2, - ), - const SizedBox(width: 10), - Align( - alignment: Alignment.centerLeft, - child: FittedBox(child: _dateToText), - ), - ], - ), - ), - ), - ), - ), - if (isDesktop) const SizedBox(width: 24), - ], - ); - } - @override Widget build(BuildContext context) { if (Util.isDesktop) { @@ -356,11 +112,13 @@ class _TransactionSearchViewState } else { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, leading: AppBarBackButton( onPressed: () async { if (FocusScope.of(context).hasFocus) { @@ -472,14 +230,9 @@ class _TransactionSearchViewState children: [ Text( "Sent", - style: - isDesktop - ? STextStyles.desktopTextSmall( - context, - ) - : STextStyles.itemSubtitle12( - context, - ), + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle12(context), ), if (isDesktop) const SizedBox(height: 4), ], @@ -530,14 +283,9 @@ class _TransactionSearchViewState children: [ Text( "Received", - style: - isDesktop - ? STextStyles.desktopTextSmall( - context, - ) - : STextStyles.itemSubtitle12( - context, - ), + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle12(context), ), if (isDesktop) const SizedBox(height: 4), ], @@ -588,14 +336,9 @@ class _TransactionSearchViewState children: [ Text( "Trades", - style: - isDesktop - ? STextStyles.desktopTextSmall( - context, - ) - : STextStyles.itemSubtitle12( - context, - ), + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle12(context), ), if (isDesktop) const SizedBox(height: 4), ], @@ -617,25 +360,35 @@ class _TransactionSearchViewState child: FittedBox( child: Text( "Date", - style: - isDesktop - ? STextStyles.labelExtraExtraSmall(context) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.labelExtraExtraSmall(context) + : STextStyles.smallMed12(context), ), ), ), SizedBox(height: isDesktop ? 10 : 8), - _buildDateRangePicker(), + Padding( + padding: isDesktop ? const .only(right: 32) : .zero, + child: StackDateRangePicker( + fromDate: _selectedFromDate, + toDate: _selectedToDate, + onChanged: (from, to) { + setState(() { + _selectedFromDate = from; + _selectedToDate = to; + }); + }, + ), + ), SizedBox(height: isDesktop ? 32 : 24), Align( alignment: Alignment.centerLeft, child: FittedBox( child: Text( "Amount", - style: - isDesktop - ? STextStyles.labelExtraExtraSmall(context) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.labelExtraExtraSmall(context) + : STextStyles.smallMed12(context), ), ), ), @@ -653,13 +406,12 @@ class _TransactionSearchViewState controller: _amountTextEditingController, focusNode: amountTextFieldFocusNode, onChanged: (_) => setState(() {}), - keyboardType: - Util.isDesktop - ? null - : const TextInputType.numberWithOptions( - signed: false, - decimal: true, - ), + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), inputFormatters: [ AmountInputFormatter( decimals: widget.coin.fractionDigits, @@ -677,50 +429,47 @@ class _TransactionSearchViewState // ? newValue // : oldValue), ], - style: - isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark, - height: 1.8, - ) - : STextStyles.field(context), - decoration: standardInputDecoration( - "Enter ${widget.coin.ticker} amount...", - keywordTextFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - contentPadding: - isDesktop + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + height: 1.8, + ) + : STextStyles.field(context), + decoration: + standardInputDecoration( + "Enter ${widget.coin.ticker} amount...", + keywordTextFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + contentPadding: isDesktop ? const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ) + vertical: 10, + horizontal: 16, + ) : null, - suffixIcon: - _amountTextEditingController.text.isNotEmpty + suffixIcon: _amountTextEditingController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _amountTextEditingController.text = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _amountTextEditingController.text = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), @@ -730,10 +479,9 @@ class _TransactionSearchViewState child: FittedBox( child: Text( "Keyword", - style: - isDesktop - ? STextStyles.labelExtraExtraSmall(context) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.labelExtraExtraSmall(context) + : STextStyles.smallMed12(context), ), ), ), @@ -750,51 +498,48 @@ class _TransactionSearchViewState key: const Key("transactionSearchViewKeywordFieldKey"), controller: _keywordTextEditingController, focusNode: keywordTextFieldFocusNode, - style: - isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark, - height: 1.8, - ) - : STextStyles.field(context), + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + height: 1.8, + ) + : STextStyles.field(context), onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Type keyword...", - keywordTextFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - contentPadding: - isDesktop + decoration: + standardInputDecoration( + "Type keyword...", + keywordTextFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + contentPadding: isDesktop ? const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ) + vertical: 10, + horizontal: 16, + ) : null, - suffixIcon: - _keywordTextEditingController.text.isNotEmpty + suffixIcon: _keywordTextEditingController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _keywordTextEditingController.text = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _keywordTextEditingController.text = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), @@ -887,14 +632,13 @@ class _TransactionSearchViewState 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); + amount = amountText.contains(",") + ? Decimal.parse( + amountText.replaceFirst(",", "."), + ).toAmount(fractionDigits: widget.coin.fractionDigits) + : Decimal.parse( + amountText, + ).toAmount(fractionDigits: widget.coin.fractionDigits); } final TransactionFilter filter = TransactionFilter( diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/all_transactions_v2_view.dart b/lib/pages/wallet_view/transaction_views/tx_v2/all_transactions_v2_view.dart index 356d106f83..2d38c8d29d 100644 --- a/lib/pages/wallet_view/transaction_views/tx_v2/all_transactions_v2_view.dart +++ b/lib/pages/wallet_view/transaction_views/tx_v2/all_transactions_v2_view.dart @@ -19,6 +19,7 @@ import 'package:isar_community/isar.dart'; import '../../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../../models/isar/models/contact_entry.dart'; +import '../../../../models/isar/models/contract.dart'; import '../../../../models/isar/models/isar_models.dart'; import '../../../../models/transaction_filter.dart'; import '../../../../providers/global/address_book_service_provider.dart'; @@ -33,6 +34,7 @@ import '../../../../utilities/format.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; import '../../../../wallets/crypto_currency/coins/ethereum.dart'; +import '../../../../wallets/crypto_currency/coins/solana.dart'; import '../../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; @@ -52,8 +54,11 @@ import '../transaction_search_filter_view.dart'; import 'transaction_v2_card.dart'; import 'transaction_v2_details_view.dart' as tvd; -typedef _GroupedTransactions = - ({String label, DateTime startDate, List transactions}); +typedef _GroupedTransactions = ({ + String label, + DateTime startDate, + List transactions, +}); class AllTransactionsV2View extends ConsumerStatefulWidget { const AllTransactionsV2View({ @@ -106,14 +111,13 @@ class _AllTransactionsV2ViewState extends ConsumerState { // debugPrint("FILTER: $filter"); final contacts = ref.read(addressBookServiceProvider).contacts; - final notes = - ref - .read(mainDBProvider) - .isar - .transactionNotes - .where() - .walletIdEqualTo(walletId) - .findAllSync(); + final notes = ref + .read(mainDBProvider) + .isar + .transactionNotes + .where() + .walletIdEqualTo(walletId) + .findAllSync(); return transactions.where((tx) { if (!filter.sent && !filter.received) { @@ -159,25 +163,23 @@ class _AllTransactionsV2ViewState extends ConsumerState { bool contains = false; // check if address book name contains - contains |= - contacts - .where( - (e) => - e.addresses - .map((e) => e.address) - .toSet() - .intersection(tx.associatedAddresses()) - .isNotEmpty && - e.name.toLowerCase().contains(keyword), - ) - .isNotEmpty; + contains |= contacts + .where( + (e) => + e.addresses + .map((e) => e.address) + .toSet() + .intersection(tx.associatedAddresses()) + .isNotEmpty && + e.name.toLowerCase().contains(keyword), + ) + .isNotEmpty; // check if address contains - contains |= - tx - .associatedAddresses() - .where((e) => e.toLowerCase().contains(keyword)) - .isNotEmpty; + contains |= tx + .associatedAddresses() + .where((e) => e.toLowerCase().contains(keyword)) + .isNotEmpty; TransactionNote? note; final matchingNotes = notes.where((e) => e.txid == tx.txid); @@ -214,14 +216,13 @@ class _AllTransactionsV2ViewState extends ConsumerState { } text = text.toLowerCase(); final contacts = ref.read(addressBookServiceProvider).contacts; - final notes = - ref - .read(mainDBProvider) - .isar - .transactionNotes - .where() - .walletIdEqualTo(walletId) - .findAllSync(); + final notes = ref + .read(mainDBProvider) + .isar + .transactionNotes + .where() + .walletIdEqualTo(walletId) + .findAllSync(); return transactions .where((tx) => _isKeywordMatch(tx, text, contacts, notes)) @@ -258,94 +259,90 @@ class _AllTransactionsV2ViewState extends ConsumerState { return MasterScaffold( background: Theme.of(context).extension()!.background, isDesktop: isDesktop, - appBar: - isDesktop - ? DesktopAppBar( - isCompactHeight: true, - background: Theme.of(context).extension()!.popupBG, - leading: Row( - children: [ - const SizedBox(width: 32), - AppBarIconButton( - size: 32, - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + appBar: isDesktop + ? DesktopAppBar( + isCompactHeight: true, + background: Theme.of(context).extension()!.popupBG, + leading: Row( + children: [ + const SizedBox(width: 32), + AppBarIconButton( + size: 32, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + shadows: const [], + icon: SvgPicture.asset( + Assets.svg.arrowLeft, + width: 18, + height: 18, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + ), + onPressed: Navigator.of(context).pop, + ), + const SizedBox(width: 12), + Text("Transactions", style: STextStyles.desktopH3(context)), + ], + ), + ) + : AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.background, + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75), + ); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "Transactions", + style: STextStyles.navBarTitle(context), + ), + actions: [ + Padding( + padding: const EdgeInsets.only( + top: 10, + bottom: 10, + right: 20, + ), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + key: const Key("transactionSearchFilterViewButton"), + size: 36, shadows: const [], + color: Theme.of( + context, + ).extension()!.background, icon: SvgPicture.asset( - Assets.svg.arrowLeft, - width: 18, - height: 18, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, - ), - onPressed: Navigator.of(context).pop, - ), - const SizedBox(width: 12), - Text("Transactions", style: STextStyles.desktopH3(context)), - ], - ), - ) - : AppBar( - backgroundColor: - Theme.of(context).extension()!.background, - leading: AppBarBackButton( - onPressed: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 75), - ); - } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - ), - title: Text( - "Transactions", - style: STextStyles.navBarTitle(context), - ), - actions: [ - Padding( - padding: const EdgeInsets.only( - top: 10, - bottom: 10, - right: 20, - ), - child: AspectRatio( - aspectRatio: 1, - child: AppBarIconButton( - key: const Key("transactionSearchFilterViewButton"), - size: 36, - shadows: const [], - color: - Theme.of( - context, - ).extension()!.background, - icon: SvgPicture.asset( - Assets.svg.filter, - color: - Theme.of( - context, - ).extension()!.accentColorDark, - width: 20, - height: 20, - ), - onPressed: () { - Navigator.of(context).pushNamed( - TransactionSearchFilterView.routeName, - arguments: ref.read(pWalletCoin(walletId)), - ); - }, + Assets.svg.filter, + color: Theme.of( + context, + ).extension()!.accentColorDark, + width: 20, + height: 20, ), + onPressed: () { + Navigator.of(context).pushNamed( + TransactionSearchFilterView.routeName, + arguments: ref.read(pWalletCoin(walletId)), + ); + }, ), ), - ], - ), + ), + ], + ), body: Padding( padding: EdgeInsets.only( left: isDesktop ? 20 : 12, @@ -378,57 +375,57 @@ class _AllTransactionsV2ViewState extends ConsumerState { _searchString = value; }); }, - style: - isDesktop - ? STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: standardInputDecoration( - "Search...", - searchFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - prefixIcon: Padding( - padding: EdgeInsets.symmetric( - horizontal: isDesktop ? 12 : 10, - vertical: isDesktop ? 18 : 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: isDesktop ? 20 : 16, - height: isDesktop ? 20 : 16, - ), - ), - suffixIcon: - _searchController.text.isNotEmpty + style: isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveText, + height: 1.8, + ) + : STextStyles.field(context), + decoration: + standardInputDecoration( + "Search...", + searchFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + prefixIcon: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 12 : 10, + vertical: isDesktop ? 18 : 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: isDesktop ? 20 : 16, + height: isDesktop ? 20 : 16, + ), + ), + 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, - ), + ), ), ), ), @@ -441,10 +438,9 @@ class _AllTransactionsV2ViewState extends ConsumerState { label: "Filter", icon: SvgPicture.asset( Assets.svg.filter, - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, width: 20, height: 20, ), @@ -480,39 +476,38 @@ class _AllTransactionsV2ViewState extends ConsumerState { Expanded( child: Consumer( builder: (_, ref, __) { - final criteria = - ref.watch(transactionFilterProvider.state).state; + final criteria = ref + .watch(transactionFilterProvider.state) + .state; return FutureBuilder( - future: - ref - .watch(mainDBProvider) - .isar - .transactionV2s - .buildQuery( - whereClauses: [ - IndexWhereClause.equalTo( - indexName: 'walletId', - value: [widget.walletId], - ), - ], - filter: - widget.contractAddress == null - ? ref - .watch(pWallets) - .getWallet(widget.walletId) - .transactionFilterOperation - : ref - .read(pCurrentTokenWallet)! - .transactionFilterOperation, - sortBy: [ - const SortProperty( - property: "timestamp", - sort: Sort.desc, - ), - ], - ) - .findAll(), + future: ref + .watch(mainDBProvider) + .isar + .transactionV2s + .buildQuery( + whereClauses: [ + IndexWhereClause.equalTo( + indexName: 'walletId', + value: [widget.walletId], + ), + ], + filter: widget.contractAddress == null + ? ref + .watch(pWallets) + .getWallet(widget.walletId) + .transactionFilterOperation + : ref + .read(pCurrentTokenWallet)! + .transactionFilterOperation, + sortBy: [ + const SortProperty( + property: "timestamp", + sort: Sort.desc, + ), + ], + ) + .findAll(), builder: (_, AsyncSnapshot> snapshot) { if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) { @@ -553,27 +548,26 @@ class _AllTransactionsV2ViewState extends ConsumerState { child: ListView.separated( shrinkWrap: true, primary: false, - separatorBuilder: - (context, _) => Container( + separatorBuilder: (context, _) => + Container( height: 1, - color: - Theme.of(context) - .extension()! - .background, + color: Theme.of(context) + .extension()! + .background, ), itemCount: month.transactions.length, - itemBuilder: - (context, index) => Padding( - padding: const EdgeInsets.all(4), - child: DesktopTransactionCardRow( - key: Key( - "transactionCard_key_${month.transactions[index].txid}", - ), - transaction: - month.transactions[index], - walletId: walletId, - ), + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.all(4), + child: DesktopTransactionCardRow( + key: Key( + "transactionCard_key_" + "${month.transactions[index].txid}", ), + transaction: + month.transactions[index], + walletId: walletId, + ), + ), ), ), if (!isDesktop) @@ -785,8 +779,9 @@ class TransactionFilterOptionBarItem extends StatelessWidget { child: Container( height: 32, decoration: BoxDecoration( - color: - Theme.of(context).extension()!.buttonBackSecondary, + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, borderRadius: BorderRadius.circular(1000), ), child: Padding( @@ -802,8 +797,9 @@ class TransactionFilterOptionBarItem extends StatelessWidget { label, textAlign: TextAlign.center, style: STextStyles.labelExtraExtraSmall(context).copyWith( - color: - Theme.of(context).extension()!.textDark, + color: Theme.of( + context, + ).extension()!.textDark, ), ), ), @@ -842,38 +838,40 @@ class _DesktopTransactionCardRowState late final TransactionV2 _transaction; late final String walletId; late final int minConfirms; - late final EthContract? ethContract; + late final Contract? contract; - bool get isTokenTx => ethContract != null; + bool get isTokenTx => contract != null; String whatIsIt(TransactionV2 tx, int height) => tx.statusLabel( currentChainHeight: height, minConfirms: minConfirms, - minCoinbaseConfirms: - ref - .read(pWallets) - .getWallet(widget.walletId) - .cryptoCurrency - .minCoinbaseConfirms, + minCoinbaseConfirms: ref + .read(pWallets) + .getWallet(widget.walletId) + .cryptoCurrency + .minCoinbaseConfirms, ); @override void initState() { walletId = widget.walletId; - minConfirms = - ref - .read(pWallets) - .getWallet(widget.walletId) - .cryptoCurrency - .minConfirms; + minConfirms = ref + .read(pWallets) + .getWallet(widget.walletId) + .cryptoCurrency + .minConfirms; _transaction = widget.transaction; - if (_transaction.subType == TransactionSubType.ethToken) { - ethContract = ref + if (_transaction.subType == TransactionSubType.splToken) { + contract = ref + .read(mainDBProvider) + .getSolContractSync(_transaction.contractAddress!); + } else if (_transaction.subType == TransactionSubType.ethToken) { + contract = ref .read(mainDBProvider) .getEthContractSync(_transaction.contractAddress!); } else { - ethContract = null; + contract = null; } super.initState(); @@ -897,10 +895,9 @@ class _DesktopTransactionCardRowState )) { price = ref.watch( priceAnd24hChangeNotifierProvider.select( - (value) => - isTokenTx - ? value.getTokenPrice(_transaction.contractAddress!)?.value - : value.getPrice(coin)?.value, + (value) => isTokenTx + ? value.getTokenPrice(_transaction.contractAddress!)?.value + : value.getPrice(coin)?.value, ), ); } @@ -921,7 +918,7 @@ class _DesktopTransactionCardRowState final currentHeight = ref.watch(pWalletChainHeight(walletId)); final Amount amount; - final fractionDigits = ethContract?.decimals ?? coin.fractionDigits; + final fractionDigits = contract?.decimals ?? coin.fractionDigits; if (_transaction.subType == TransactionSubType.cashFusion) { amount = _transaction.getAmountReceivedInThisWallet( fractionDigits: fractionDigits, @@ -931,7 +928,7 @@ class _DesktopTransactionCardRowState case TransactionType.outgoing: amount = _transaction.getAmountSentFromThisWallet( fractionDigits: fractionDigits, - subtractFee: coin is! Ethereum, + subtractFee: !(coin is Ethereum || coin is Solana), ); break; @@ -963,7 +960,7 @@ class _DesktopTransactionCardRowState case TransactionType.unknown: amount = _transaction.getAmountSentFromThisWallet( fractionDigits: fractionDigits, - subtractFee: coin is! Ethereum, + subtractFee: !(coin is Ethereum || coin is Solana), ); break; } @@ -987,16 +984,15 @@ class _DesktopTransactionCardRowState if (Util.isDesktop) { await showDialog( context: context, - builder: - (context) => DesktopDialog( - maxHeight: MediaQuery.of(context).size.height - 64, - maxWidth: 580, - child: tvd.TransactionV2DetailsView( - transaction: _transaction, - coin: coin, - walletId: walletId, - ), - ), + builder: (context) => DesktopDialog( + maxHeight: MediaQuery.of(context).size.height - 64, + maxWidth: 640, + child: tvd.TransactionV2DetailsView( + transaction: _transaction, + coin: coin, + walletId: walletId, + ), + ), ); } else { unawaited( @@ -1021,11 +1017,12 @@ class _DesktopTransactionCardRowState flex: 3, child: Text( whatIsIt(_transaction, currentHeight), - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context).extension()!.textDark, - ), + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), ), ), if (kDebugMode) @@ -1045,29 +1042,47 @@ class _DesktopTransactionCardRowState ), Expanded( flex: 6, - child: Text( - "$prefix${ref.watch(pAmountFormatter(coin)).format(amount, ethContract: ethContract)}", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context).extension()!.textDark, - ), + child: Builder( + builder: (context) { + final formattedAmount = ref + .watch(pAmountFormatter(coin)) + .format(amount, tokenContract: contract); + + return Text( + "$prefix$formattedAmount", + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + ); + }, ), ), if (price != null) Expanded( flex: 4, - child: Text( - "$prefix${(amount.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: locale)} $baseCurrency", - style: STextStyles.desktopTextExtraExtraSmall(context), + child: Builder( + builder: (context) { + final formattedFiat = (amount.decimal * price!) + .toAmount(fractionDigits: 2) + .fiatString(locale: locale); + + return Text( + "$prefix$formattedFiat $baseCurrency", + style: STextStyles.desktopTextExtraExtraSmall(context), + ); + }, ), ), SvgPicture.asset( Assets.svg.circleInfo, width: 20, height: 20, - color: - Theme.of(context).extension()!.textSubtitle2, + color: Theme.of( + context, + ).extension()!.textSubtitle2, ), ], ), diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_card.dart b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_card.dart index a8b30fd5ae..a55940e636 100644 --- a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_card.dart +++ b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_card.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import '../../../../models/isar/models/contract.dart'; import '../../../../models/isar/models/isar_models.dart'; import '../../../../providers/db/main_db_provider.dart'; import '../../../../providers/global/locale_provider.dart'; @@ -43,28 +44,26 @@ class _TransactionCardStateV2 extends ConsumerState { late final String unit; late final CryptoCurrency coin; late final TransactionType txType; - late final EthContract? tokenContract; + late final Contract? tokenContract; bool get isTokenTx => tokenContract != null; String whatIsIt(CryptoCurrency coin, int currentHeight) => _transaction.isCancelled && coin is Ethereum - ? "Failed" - : _transaction.statusLabel( - currentChainHeight: currentHeight, - minConfirms: - ref - .read(pWallets) - .getWallet(walletId) - .cryptoCurrency - .minConfirms, - minCoinbaseConfirms: - ref - .read(pWallets) - .getWallet(walletId) - .cryptoCurrency - .minCoinbaseConfirms, - ); + ? "Failed" + : _transaction.statusLabel( + currentChainHeight: currentHeight, + minConfirms: ref + .read(pWallets) + .getWallet(walletId) + .cryptoCurrency + .minConfirms, + minCoinbaseConfirms: ref + .read(pWallets) + .getWallet(walletId) + .cryptoCurrency + .minCoinbaseConfirms, + ); @override void initState() { @@ -77,6 +76,12 @@ class _TransactionCardStateV2 extends ConsumerState { .read(mainDBProvider) .getEthContractSync(_transaction.contractAddress!); + unit = tokenContract!.symbol; + } else if (_transaction.subType == TransactionSubType.splToken) { + tokenContract = ref + .read(mainDBProvider) + .getSolContractSync(_transaction.contractAddress!); + unit = tokenContract!.symbol; } else { tokenContract = null; @@ -115,10 +120,9 @@ class _TransactionCardStateV2 extends ConsumerState { )) { price = ref.watch( priceAnd24hChangeNotifierProvider.select( - (value) => - isTokenTx - ? value.getTokenPrice(tokenContract!.address)?.value - : value.getPrice(coin)?.value, + (value) => isTokenTx + ? value.getTokenPrice(tokenContract!.address)?.value + : value.getPrice(coin)?.value, ), ); } @@ -196,16 +200,15 @@ class _TransactionCardStateV2 extends ConsumerState { if (Util.isDesktop) { await showDialog( context: context, - builder: - (context) => DesktopDialog( - maxHeight: MediaQuery.of(context).size.height - 64, - maxWidth: 580, - child: tvd.TransactionV2DetailsView( - transaction: _transaction, - coin: coin, - walletId: walletId, - ), - ), + builder: (context) => DesktopDialog( + maxHeight: MediaQuery.of(context).size.height - 64, + maxWidth: 640, + child: tvd.TransactionV2DetailsView( + transaction: _transaction, + coin: coin, + walletId: walletId, + ), + ), ); } else { unawaited( @@ -246,15 +249,14 @@ class _TransactionCardStateV2 extends ConsumerState { coin.minConfirms, coin.minCoinbaseConfirms, ), - builder: - (child) => Row( - children: [ - child, + builder: (child) => Row( + children: [ + child, - const SizedBox(width: 10), - const CoinTickerTag(ticker: "INSTANT"), - ], - ), + const SizedBox(width: 10), + const CoinTickerTag(ticker: "INSTANT"), + ], + ), child: Text( whatIsIt(coin, currentHeight), style: STextStyles.itemSubtitle12(context), @@ -267,9 +269,16 @@ class _TransactionCardStateV2 extends ConsumerState { child: FittedBox( fit: BoxFit.scaleDown, child: Builder( - builder: (_) { + builder: (context) { + final formattedAmount = ref + .watch(pAmountFormatter(coin)) + .format( + amount, + tokenContract: tokenContract, + ); + return Text( - "$prefix${ref.watch(pAmountFormatter(coin)).format(amount, ethContract: tokenContract)}", + "$prefix$formattedAmount", style: STextStyles.itemSubtitle12(context), ); }, @@ -298,9 +307,14 @@ class _TransactionCardStateV2 extends ConsumerState { child: FittedBox( fit: BoxFit.scaleDown, child: Builder( - builder: (_) { + builder: (context) { + final formattedFiat = + (amount.decimal * price!) + .toAmount(fractionDigits: 2) + .fiatString(locale: locale); + return Text( - "$prefix${Amount.fromDecimal(amount.decimal * price!, fractionDigits: 2).fiatString(locale: locale)} $baseCurrency", + "$prefix$formattedFiat $baseCurrency", style: STextStyles.label(context), ); }, diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart index 21d76faaf8..e10200c8d1 100644 --- a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart +++ b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart @@ -22,7 +22,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../../../models/isar/models/blockchain_data/transaction.dart'; import '../../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; -import '../../../../models/isar/models/ethereum/eth_contract.dart'; +import '../../../../models/isar/models/contract.dart'; import '../../../../notifications/show_flush_bar.dart'; import '../../../../providers/global/address_book_service_provider.dart'; import '../../../../providers/providers.dart'; @@ -43,8 +43,7 @@ import '../../../../wallets/isar/models/spark_coin.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; -import '../../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; -import '../../../../wallets/wallet/intermediate/lib_salvium_wallet.dart'; +import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/rbf_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../../../widgets/background.dart'; @@ -98,11 +97,11 @@ class _TransactionV2DetailsViewState late final String amountPrefix; late final String unit; late final int minConfirms; - late final EthContract? ethContract; + late final Contract? tokenContract; late final bool supportsRbf; late final bool hasTxKeyProbably; - bool get isTokenTx => ethContract != null; + bool get isTokenTx => tokenContract != null; late final List<({List addresses, Amount amount})> data; @@ -185,7 +184,7 @@ class _TransactionV2DetailsViewState final wallet = ref.read(pWallets).getWallet(walletId); hasTxKeyProbably = - (wallet is LibMoneroWallet || wallet is LibSalviumWallet) && + (wallet is CryptonoteWallet) && (_transaction.type == TransactionType.outgoing || _transaction.type == TransactionType.sentToSelf); @@ -200,14 +199,20 @@ class _TransactionV2DetailsViewState coin = widget.coin; - if (_transaction.subType == TransactionSubType.ethToken) { - ethContract = ref + if (_transaction.subType == TransactionSubType.splToken) { + tokenContract = ref + .read(mainDBProvider) + .getSolContractSync(_transaction.contractAddress!); + + unit = tokenContract!.symbol; + } else if (_transaction.subType == TransactionSubType.ethToken) { + tokenContract = ref .read(mainDBProvider) .getEthContractSync(_transaction.contractAddress!); - unit = ethContract!.symbol; + unit = tokenContract!.symbol; } else { - ethContract = null; + tokenContract = null; unit = coin.ticker; } @@ -217,7 +222,7 @@ class _TransactionV2DetailsViewState .cryptoCurrency .minConfirms; - final fractionDigits = ethContract?.decimals ?? coin.fractionDigits; + final fractionDigits = tokenContract?.decimals ?? coin.fractionDigits; fee = _transaction.getFee(fractionDigits: fractionDigits); @@ -449,147 +454,6 @@ class _TransactionV2DetailsViewState } } - Future showExplorerWarning(String explorer) async { - final bool? shouldContinue = await showDialog( - context: context, - barrierDismissible: false, - builder: (_) { - if (!isDesktop) { - return StackDialog( - title: "Attention", - message: - "You are about to view this transaction in a block explorer. The explorer may log your IP address and link it to the transaction. Only proceed if you trust $explorer.", - icon: Row( - children: [ - Consumer( - builder: (_, ref, __) { - return Checkbox( - value: ref.watch( - prefsChangeNotifierProvider.select( - (value) => value.hideBlockExplorerWarning, - ), - ), - onChanged: (value) { - if (value is bool) { - ref - .read(prefsChangeNotifierProvider) - .hideBlockExplorerWarning = - value; - setState(() {}); - } - }, - ); - }, - ), - Text( - "Never show again", - style: STextStyles.smallMed14(context), - ), - ], - ), - leftButton: TextButton( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text( - "Cancel", - style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorDark, - ), - ), - ), - rightButton: TextButton( - style: Theme.of( - context, - ).extension()!.getPrimaryEnabledButtonStyle(context), - onPressed: () { - Navigator.of(context).pop(true); - }, - child: Text("Continue", style: STextStyles.button(context)), - ), - ); - } else { - return DesktopDialog( - maxWidth: 550, - maxHeight: 300, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text("Attention", style: STextStyles.desktopH2(context)), - Row( - children: [ - Consumer( - builder: (_, ref, __) { - return Checkbox( - value: ref.watch( - prefsChangeNotifierProvider.select( - (value) => value.hideBlockExplorerWarning, - ), - ), - onChanged: (value) { - if (value is bool) { - ref - .read(prefsChangeNotifierProvider) - .hideBlockExplorerWarning = - value; - setState(() {}); - } - }, - ); - }, - ), - Text( - "Never show again", - style: STextStyles.smallMed14(context), - ), - ], - ), - ], - ), - const SizedBox(height: 16), - Text( - "You are about to view this transaction in a block explorer. The explorer may log your IP address and link it to the transaction. Only proceed if you trust $explorer.", - style: STextStyles.desktopTextSmall(context), - ), - const SizedBox(height: 35), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SecondaryButton( - width: 200, - buttonHeight: ButtonHeight.l, - label: "Cancel", - onPressed: () { - Navigator.of(context, rootNavigator: true).pop(false); - }, - ), - const SizedBox(width: 20), - PrimaryButton( - width: 200, - buttonHeight: ButtonHeight.l, - label: "Continue", - onPressed: () { - Navigator.of(context, rootNavigator: true).pop(true); - }, - ), - ], - ), - ], - ), - ), - ); - } - }, - ); - return shouldContinue ?? false; - } - @override Widget build(BuildContext context) { final currentHeight = ref.watch(pWalletChainHeight(walletId)); @@ -627,6 +491,16 @@ class _TransactionV2DetailsViewState ); } + final labelStyle = Util.isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle(context); + + final detailStyle = Util.isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of(context).extension()!.textDark, + ) + : STextStyles.itemSubtitle12(context); + return ConditionalParent( condition: !isDesktop, builder: (child) => Background(child: child), @@ -703,127 +577,19 @@ class _TransactionV2DetailsViewState mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - RoundedWhiteContainer( - padding: isDesktop - ? const EdgeInsets.all(0) - : const EdgeInsets.all(12), - child: Container( - decoration: isDesktop - ? BoxDecoration( - color: Theme.of(context) - .extension()! - .backgroundAppBar, - borderRadius: BorderRadius.vertical( - top: Radius.circular( - Constants - .size - .circularBorderRadius, - ), - ), - ) - : null, - child: Padding( - padding: isDesktop - ? const EdgeInsets.all(12) - : const EdgeInsets.all(0), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - if (isDesktop) - Row( - children: [ - TxIcon( - transaction: _transaction, - currentHeight: currentHeight, - coin: coin, - ), - const SizedBox(width: 16), - SelectableText( - whatIsIt( - _transaction, - currentHeight, - ), - style: - STextStyles.desktopTextMedium( - context, - ), - ), - ], - ), - Column( - crossAxisAlignment: isDesktop - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - SelectableText( - "$amountPrefix${ref.watch(pAmountFormatter(coin)).format(amount, ethContract: ethContract)}", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension< - StackColors - >()! - .textDark, - ) - : STextStyles.titleBold12( - context, - ), - ), - const SizedBox(height: 2), - if (price != null) - Builder( - builder: (context) { - final total = - (amount.decimal * price!) - .toAmount( - fractionDigits: 2, - ); - final formatted = total - .fiatString( - locale: ref.watch( - localeServiceChangeNotifierProvider - .select( - (value) => value - .locale, - ), - ), - ); - final ticker = ref.watch( - prefsChangeNotifierProvider - .select( - (value) => - value.currency, - ), - ); - return SelectableText( - "$amountPrefix$formatted $ticker", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle( - context, - ), - ); - }, - ), - ], - ), - if (!isDesktop) - TxIcon( - transaction: _transaction, - currentHeight: currentHeight, - coin: coin, - ), - ], - ), - ), - ), + _TxDetailsAmountHeader( + isDesktop: isDesktop, + currentHeight: currentHeight, + transaction: _transaction, + coin: coin, + whatIsIt: whatIsIt, + amount: amount, + price: price, + labelStyle: labelStyle, + detailStyle: detailStyle, + amountPrefix: amountPrefix, + tokenContract: tokenContract, ), - isDesktop ? const _Divider() : const SizedBox(height: 12), @@ -835,14 +601,7 @@ class _TransactionV2DetailsViewState mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - "Status", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle(context), - ), + Text("Status", style: labelStyle), // Flexible( // child: FittedBox( // fit: BoxFit.scaleDown, @@ -963,13 +722,7 @@ class _TransactionV2DetailsViewState }, child: Text( outputLabel, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle( - context, - ), + style: labelStyle, ), ), const SizedBox(height: 8), @@ -1133,30 +886,12 @@ class _TransactionV2DetailsViewState children: [ Text( "On chain note", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle( - context, - ), + style: labelStyle, ), const SizedBox(height: 8), SelectableText( _transaction.onChainNote ?? "", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension< - StackColors - >()! - .textDark, - ) - : STextStyles.itemSubtitle12( - context, - ), + style: detailStyle, ), ], ), @@ -1187,13 +922,7 @@ class _TransactionV2DetailsViewState coin is Mimblewimblecoin) ? "Local Note" : "Note ", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle( - context, - ), + style: labelStyle, ), isDesktop ? IconPencilButton( @@ -1206,6 +935,8 @@ class _TransactionV2DetailsViewState maxHeight: 360, child: EditNoteView( txid: + _transaction + .slateId ?? _transaction.txid, walletId: walletId, ), @@ -1221,7 +952,8 @@ class _TransactionV2DetailsViewState ).pushNamed( EditNoteView.routeName, arguments: Tuple2( - _transaction.txid, + _transaction.slateId ?? + _transaction.txid, walletId, ), ); @@ -1256,26 +988,14 @@ class _TransactionV2DetailsViewState .watch( pTransactionNote(( txid: - (coin is Epiccash || - coin - is Mimblewimblecoin) - ? _transaction.slateId - .toString() - : _transaction.txid, + _transaction.slateId ?? + _transaction.txid, walletId: walletId, )), ) ?.value ?? "", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ) - : STextStyles.itemSubtitle12(context), + style: detailStyle, ), ], ), @@ -1285,45 +1005,10 @@ class _TransactionV2DetailsViewState ? const _Divider() : const SizedBox(height: 12), if (_sparkMemo != null) - RoundedWhiteContainer( - padding: isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - "Memo", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle( - context, - ), - ), - ], - ), - const SizedBox(height: 8), - SelectableText( - _sparkMemo!, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ) - : STextStyles.itemSubtitle12( - context, - ), - ), - ], - ), + _DetailItem( + label: "Memo", + detail: _sparkMemo!, + vertical: true, ), isDesktop ? const _Divider() @@ -1341,16 +1026,7 @@ class _TransactionV2DetailsViewState crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - "Date", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle( - context, - ), - ), + Text("Date", style: labelStyle), if (isDesktop) const SizedBox(height: 2), if (isDesktop) @@ -1358,19 +1034,7 @@ class _TransactionV2DetailsViewState Format.extractDateFrom( _transaction.timestamp, ), - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension< - StackColors - >()! - .textDark, - ) - : STextStyles.itemSubtitle12( - context, - ), + style: detailStyle, ), ], ), @@ -1379,17 +1043,7 @@ class _TransactionV2DetailsViewState Format.extractDateFrom( _transaction.timestamp, ), - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ) - : STextStyles.itemSubtitle12( - context, - ), + style: detailStyle, ), if (isDesktop) IconCopyButton( @@ -1445,33 +1099,14 @@ class _TransactionV2DetailsViewState children: [ Text( "Transaction fee", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle( - context, - ), + style: labelStyle, ), if (isDesktop) const SizedBox(height: 2), if (isDesktop) SelectableText( feeString, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension< - StackColors - >()! - .textDark, - ) - : STextStyles.itemSubtitle12( - context, - ), + style: detailStyle, ), if (supportsRbf && !confirmedTxn) const SizedBox(height: 8), @@ -1485,19 +1120,7 @@ class _TransactionV2DetailsViewState if (!isDesktop) SelectableText( feeString, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension< - StackColors - >()! - .textDark, - ) - : STextStyles.itemSubtitle12( - context, - ), + style: detailStyle, ), if (isDesktop) IconCopyButton(data: feeString), @@ -1536,7 +1159,8 @@ class _TransactionV2DetailsViewState ); if (widget.coin is! Epiccash && confirmed) { height = - "${_transaction.height == 0 ? "Unknown" : _transaction.height}"; + "${_transaction.height == 0 ? "Unknow" + "n" : _transaction.height}"; } else { height = confirms > 0 ? "${_transaction.height}" @@ -1564,54 +1188,21 @@ class _TransactionV2DetailsViewState children: [ Text( "Block height", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle( - context, - ), + style: labelStyle, ), if (isDesktop) const SizedBox(height: 2), if (isDesktop) SelectableText( height, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of( - context, - ) - .extension< - StackColors - >()! - .textDark, - ) - : STextStyles.itemSubtitle12( - context, - ), + style: detailStyle, ), ], ), if (!isDesktop) SelectableText( height, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension< - StackColors - >()! - .textDark, - ) - : STextStyles.itemSubtitle12( - context, - ), + style: detailStyle, ), if (isDesktop) IconCopyButton(data: height), @@ -1637,54 +1228,21 @@ class _TransactionV2DetailsViewState children: [ Text( "Confirmations", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle( - context, - ), + style: labelStyle, ), if (isDesktop) const SizedBox(height: 2), if (isDesktop) SelectableText( confirmations, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of( - context, - ) - .extension< - StackColors - >()! - .textDark, - ) - : STextStyles.itemSubtitle12( - context, - ), + style: detailStyle, ), ], ), if (!isDesktop) SelectableText( confirmations, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension< - StackColors - >()! - .textDark, - ) - : STextStyles.itemSubtitle12( - context, - ), + style: detailStyle, ), if (isDesktop) IconCopyButton(data: height), @@ -1702,40 +1260,18 @@ class _TransactionV2DetailsViewState : const SizedBox(height: 12), if (coin is Ethereum && _transaction.type != TransactionType.incoming) - RoundedWhiteContainer( - padding: isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: - CrossAxisAlignment.start, - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - "Nonce", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle(context), - ), - SelectableText( - _transaction.nonce.toString(), - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ) - : STextStyles.itemSubtitle12( - context, - ), - ), - ], - ), + _DetailItem( + label: "Nonce", + detail: _transaction.nonce.toString(), + ), + if (_transaction.memo != null) + isDesktop + ? const _Divider() + : const SizedBox(height: 12), + if (_transaction.memo != null) + _DetailItem( + label: "Memo", + detail: _transaction.memo!, ), if (coin is Salvium && _transaction.salviumTypeString != null) @@ -1744,86 +1280,21 @@ class _TransactionV2DetailsViewState : const SizedBox(height: 12), if (coin is Salvium && _transaction.salviumTypeString != null) - RoundedWhiteContainer( - padding: isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - "Type", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle( - context, - ), - ), - ], - ), - const SizedBox(height: 8), - SelectableText( - _transaction.salviumTypeString!, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ) - : STextStyles.itemSubtitle12( - context, - ), - ), - ], - ), + _DetailItem( + label: "Type", + detail: _transaction.salviumTypeString!, + vertical: true, ), if (kDebugMode) isDesktop ? const _Divider() : const SizedBox(height: 12), if (kDebugMode) - RoundedWhiteContainer( - padding: isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: - CrossAxisAlignment.start, - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - "Tx sub type", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle(context), - ), - SelectableText( - _transaction.subType.toString(), - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ) - : STextStyles.itemSubtitle12( - context, - ), - ), - ], - ), + _DetailItem( + label: "Tx sub type", + detail: _transaction.subType.toString(), ), + if (hasTxKeyProbably) isDesktop ? const _Divider() @@ -1836,299 +1307,11 @@ class _TransactionV2DetailsViewState isDesktop ? const _Divider() : const SizedBox(height: 12), - - _transaction.txid.startsWith("mweb_outputId_") && - _transaction.subType == - TransactionSubType.mweb - ? RoundedWhiteContainer( - padding: isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: - CrossAxisAlignment.start, - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - ConditionalParent( - condition: !isDesktop, - builder: (child) => Row( - children: [ - Expanded(child: child), - SimpleCopyButton( - data: _transaction.txid - .replaceFirst( - "mweb_outputId_", - "", - ), - ), - ], - ), - child: Text( - "MWEB Output ID", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle( - context, - ), - ), - ), - const SizedBox(height: 8), - SelectableText( - _transaction.txid - .replaceFirst( - "mweb_outputId_", - "", - ), - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension< - StackColors - >()! - .textDark, - ) - : STextStyles.itemSubtitle12( - context, - ), - ), - // if (coin is Litecoin && - // coin.network == - // CryptoCurrencyNetwork - // .main) - // const SizedBox(height: 8), - // if (coin is Litecoin && - // coin.network == - // CryptoCurrencyNetwork - // .main) - // CustomTextButton( - // text: - // "Open in block explorer", - // onTap: () async { - // final uri = - // getBlockExplorerTransactionUrlFor( - // coin: coin, - // txid: _transaction - // .txid - // .replaceFirst( - // "mweb_outputId_", - // "", - // ), - // ); - // - // if (ref - // .read( - // prefsChangeNotifierProvider, - // ) - // .hideBlockExplorerWarning == - // false) { - // final shouldContinue = - // await showExplorerWarning( - // "${uri.scheme}://${uri.host}", - // ); - // - // if (!shouldContinue) { - // return; - // } - // } - // try { - // await launchUrl( - // uri, - // mode: - // LaunchMode - // .externalApplication, - // ); - // } catch (_) { - // if (context.mounted) { - // unawaited( - // showDialog( - // context: context, - // builder: - // ( - // _, - // ) => StackOkDialog( - // title: - // "Could not open in block explorer", - // message: - // "Failed to open \"${uri.toString()}\"", - // ), - // ), - // ); - // } - // } - // }, - // ), - ], - ), - ), - if (isDesktop) - const SizedBox(width: 12), - if (isDesktop) - IconCopyButton( - data: _transaction.txid - .replaceFirst( - "mweb_outputId_", - "", - ), - ), - ], - ), - ) - : RoundedWhiteContainer( - padding: isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: - CrossAxisAlignment.start, - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - ConditionalParent( - condition: !isDesktop, - builder: (child) => Row( - children: [ - Expanded(child: child), - SimpleCopyButton( - data: _transaction.txid, - ), - ], - ), - child: Text( - "Transaction ID", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle( - context, - ), - ), - ), - const SizedBox(height: 8), - // Flexible( - // child: FittedBox( - // fit: BoxFit.scaleDown, - // child: - SelectableText( - _transaction.txid, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension< - StackColors - >()! - .textDark, - ) - : STextStyles.itemSubtitle12( - context, - ), - ), - if (coin is! Epiccash && - coin is! Mimblewimblecoin) - const SizedBox(height: 8), - if (coin is! Epiccash && - coin is! Mimblewimblecoin) - CustomTextButton( - text: - "Open in block explorer", - onTap: () async { - final uri = - getBlockExplorerTransactionUrlFor( - coin: coin, - txid: _transaction - .txid, - ); - - if (ref - .read( - prefsChangeNotifierProvider, - ) - .hideBlockExplorerWarning == - false) { - final shouldContinue = - await showExplorerWarning( - "${uri.scheme}://${uri.host}", - ); - - if (!shouldContinue) { - return; - } - } - - // ref - // .read( - // shouldShowLockscreenOnResumeStateProvider - // .state) - // .state = false; - try { - await launchUrl( - uri, - mode: LaunchMode - .externalApplication, - ); - } catch (_) { - if (context.mounted) { - unawaited( - showDialog( - context: context, - builder: (_) => StackOkDialog( - title: - "Could not open in block explorer", - message: - "Failed to open \"${uri.toString()}\"", - maxWidth: - Util.isDesktop - ? 400 - : null, - ), - ), - ); - } - } finally { - // Future.delayed( - // const Duration(seconds: 1), - // () => ref - // .read( - // shouldShowLockscreenOnResumeStateProvider - // .state) - // .state = true, - // ); - } - }, - ), - // ), - // ), - ], - ), - ), - if (isDesktop) - const SizedBox(width: 12), - if (isDesktop) - IconCopyButton( - data: _transaction.txid, - ), - ], - ), - ), + _TxidDetailItem( + coin: coin, + txid: _transaction.txid, + subType: _transaction.subType, + ), // if ((coin is FiroTestNet || coin is Firo) && // _transaction.subType == "mint") // const SizedBox( @@ -2225,35 +1408,14 @@ class _TransactionV2DetailsViewState crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - "Slate ID", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ) - : STextStyles.itemSubtitle( - context, - ), - ), + Text("Slate ID", style: labelStyle), // Flexible( // child: FittedBox( // fit: BoxFit.scaleDown, // child: SelectableText( _transaction.slateId ?? "Unknown", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension< - StackColors - >()! - .textDark, - ) - : STextStyles.itemSubtitle12( - context, - ), + style: detailStyle, ), // ), // ), @@ -2430,7 +1592,8 @@ class _TransactionV2DetailsViewState showFloatingFlushBar( type: FlushBarType.warning, message: - "ERROR: Wallet type is not Epic Cash or MimbleWimbleCoin", + "ERROR: Wallet type is not " + "Epic Cash or MimbleWimbleCoin", context: context, ), ); @@ -2509,7 +1672,9 @@ class OutputCard extends ConsumerWidget { } class _Divider extends StatelessWidget { - const _Divider({super.key}); + const _Divider( + // {super.key} + ); @override Widget build(BuildContext context) { @@ -2592,3 +1757,508 @@ class IconPencilButton extends StatelessWidget { ); } } + +class _DetailItemBase extends StatelessWidget { + const _DetailItemBase({ + // super.key, + required this.child, + }); + + final Widget child; + + @override + Widget build(BuildContext context) { + return RoundedWhiteContainer( + padding: Util.isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), + + child: child, + ); + } +} + +class _DetailItem extends StatelessWidget { + const _DetailItem({ + // super.key, + required this.label, + required this.detail, + this.vertical = false, + }); + + final String label, detail; + final bool vertical; + + @override + Widget build(BuildContext context) { + final labelStyle = Util.isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle(context); + + final detailStyle = Util.isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of(context).extension()!.textDark, + ) + : STextStyles.itemSubtitle12(context); + + return _DetailItemBase( + child: vertical + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [Text(label, style: labelStyle)]), + const SizedBox(height: 8), + SelectableText(detail, style: detailStyle), + ], + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: labelStyle), + SelectableText(detail, style: detailStyle), + ], + ), + ); + } +} + +class _TxidDetailItem extends ConsumerStatefulWidget { + const _TxidDetailItem({ + // super.key, + required this.coin, + required this.txid, + required this.subType, + }); + + final CryptoCurrency coin; + final String txid; + final TransactionSubType subType; + + @override + ConsumerState<_TxidDetailItem> createState() => _TxidDetailItemState(); +} + +class _TxidDetailItemState extends ConsumerState<_TxidDetailItem> { + Future showExplorerWarning(String explorer) async { + final warningMessage = + "You are about to view this transaction in a block explorer. " + "The explorer may log your IP address and link it to the " + "transaction. Only proceed if you trust $explorer."; + + final bool? shouldContinue = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) { + if (!Util.isDesktop) { + return StackDialog( + title: "Attention", + message: warningMessage, + icon: Row( + children: [ + Consumer( + builder: (_, ref, __) { + return Checkbox( + value: ref.watch( + prefsChangeNotifierProvider.select( + (value) => value.hideBlockExplorerWarning, + ), + ), + onChanged: (value) { + if (value is bool) { + ref + .read(prefsChangeNotifierProvider) + .hideBlockExplorerWarning = + value; + setState(() {}); + } + }, + ); + }, + ), + Text( + "Never show again", + style: STextStyles.smallMed14(context), + ), + ], + ), + leftButton: TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text( + "Cancel", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + rightButton: TextButton( + style: Theme.of( + context, + ).extension()!.getPrimaryEnabledButtonStyle(context), + onPressed: () { + Navigator.of(context).pop(true); + }, + child: Text("Continue", style: STextStyles.button(context)), + ), + ); + } else { + return DesktopDialog( + maxWidth: 550, + maxHeight: 300, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Attention", style: STextStyles.desktopH2(context)), + Row( + children: [ + Consumer( + builder: (_, ref, __) { + return Checkbox( + value: ref.watch( + prefsChangeNotifierProvider.select( + (value) => value.hideBlockExplorerWarning, + ), + ), + onChanged: (value) { + if (value is bool) { + ref + .read(prefsChangeNotifierProvider) + .hideBlockExplorerWarning = + value; + setState(() {}); + } + }, + ); + }, + ), + Text( + "Never show again", + style: STextStyles.smallMed14(context), + ), + ], + ), + ], + ), + const SizedBox(height: 16), + Text( + warningMessage, + style: STextStyles.desktopTextSmall(context), + ), + const SizedBox(height: 35), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SecondaryButton( + width: 200, + buttonHeight: ButtonHeight.l, + label: "Cancel", + onPressed: () { + Navigator.of(context, rootNavigator: true).pop(false); + }, + ), + const SizedBox(width: 20), + PrimaryButton( + width: 200, + buttonHeight: ButtonHeight.l, + label: "Continue", + onPressed: () { + Navigator.of(context, rootNavigator: true).pop(true); + }, + ), + ], + ), + ], + ), + ), + ); + } + }, + ); + return shouldContinue ?? false; + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + return _DetailItemBase( + child: + widget.txid.startsWith("mweb_outputId_") && + widget.subType == TransactionSubType.mweb + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + ConditionalParent( + condition: !isDesktop, + builder: (child) => Row( + children: [ + Expanded(child: child), + SimpleCopyButton( + data: widget.txid.replaceFirst( + "mweb_outputId_", + "", + ), + ), + ], + ), + child: Text( + "MWEB Output ID", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle(context), + ), + ), + const SizedBox(height: 8), + SelectableText( + widget.txid.replaceFirst("mweb_outputId_", ""), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ) + : STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + if (isDesktop) const SizedBox(width: 12), + if (isDesktop) + IconCopyButton( + data: widget.txid.replaceFirst("mweb_outputId_", ""), + ), + ], + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConditionalParent( + condition: !isDesktop, + builder: (child) => Row( + children: [ + Expanded(child: child), + SimpleCopyButton(data: widget.txid), + ], + ), + child: Text( + "Transaction ID", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle(context), + ), + ), + const SizedBox(height: 8), + + SelectableText( + widget.txid, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ) + : STextStyles.itemSubtitle12(context), + ), + if (widget.coin is! Epiccash && + widget.coin is! Mimblewimblecoin) + const SizedBox(height: 8), + if (widget.coin is! Epiccash && + widget.coin is! Mimblewimblecoin) + CustomTextButton( + text: "Open in block explorer", + onTap: () async { + final uri = getBlockExplorerTransactionUrlFor( + coin: widget.coin, + txid: widget.txid, + ); + + if (ref + .read(prefsChangeNotifierProvider) + .hideBlockExplorerWarning == + false) { + final shouldContinue = await showExplorerWarning( + "${uri.scheme}://${uri.host}", + ); + + if (!shouldContinue) { + return; + } + } + + try { + await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + } catch (_) { + if (context.mounted) { + unawaited( + showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Could not open in block explorer", + message: + "Failed to open " + "\"${uri.toString()}\"", + maxWidth: Util.isDesktop ? 400 : null, + ), + ), + ); + } + } + }, + ), + ], + ), + ), + if (isDesktop) const SizedBox(width: 12), + if (isDesktop) IconCopyButton(data: widget.txid), + ], + ), + ); + } +} + +class _TxDetailsAmountHeader extends ConsumerWidget { + const _TxDetailsAmountHeader({ + required this.isDesktop, + required this.currentHeight, + required this.transaction, + required this.coin, + required this.whatIsIt, + required this.amount, + this.price, + required this.labelStyle, + required this.detailStyle, + required this.amountPrefix, + this.tokenContract, + }); + + final bool isDesktop; + final int currentHeight; + final TransactionV2 transaction; + final CryptoCurrency coin; + final String Function(TransactionV2, int) whatIsIt; + final Amount amount; + final Decimal? price; + final TextStyle labelStyle; + final TextStyle detailStyle; + final String amountPrefix; + final Contract? tokenContract; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return RoundedWhiteContainer( + padding: isDesktop ? const EdgeInsets.all(0) : const EdgeInsets.all(12), + child: Container( + decoration: isDesktop + ? BoxDecoration( + color: Theme.of( + context, + ).extension()!.backgroundAppBar, + borderRadius: BorderRadius.vertical( + top: Radius.circular(Constants.size.circularBorderRadius), + ), + ) + : null, + child: Padding( + padding: isDesktop + ? const EdgeInsets.all(12) + : const EdgeInsets.all(0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (isDesktop) + Row( + children: [ + TxIcon( + transaction: transaction, + currentHeight: currentHeight, + coin: coin, + ), + const SizedBox(width: 16), + SelectableText( + whatIsIt(transaction, currentHeight), + style: STextStyles.desktopTextMedium(context), + ), + ], + ), + Column( + crossAxisAlignment: isDesktop + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Builder( + builder: (context) { + final formattedAmount = ref + .watch(pAmountFormatter(coin)) + .format(amount, tokenContract: tokenContract); + return SelectableText( + "$amountPrefix$formattedAmount", + style: detailStyle, + ); + }, + ), + const SizedBox(height: 2), + if (price != null) + Builder( + builder: (context) { + final total = (amount.decimal * price!).toAmount( + fractionDigits: 2, + ); + final formatted = total.fiatString( + locale: ref.watch( + localeServiceChangeNotifierProvider.select( + (value) => value.locale, + ), + ), + ); + final ticker = ref.watch( + prefsChangeNotifierProvider.select( + (value) => value.currency, + ), + ); + return SelectableText( + "$amountPrefix$formatted $ticker", + style: labelStyle, + ); + }, + ), + ], + ), + if (!isDesktop) + TxIcon( + transaction: transaction, + currentHeight: currentHeight, + coin: coin, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart index 9acb8b7051..74e83cf882 100644 --- a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart +++ b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart @@ -18,11 +18,12 @@ import '../../../../models/isar/models/blockchain_data/transaction.dart'; import '../../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../../providers/db/main_db_provider.dart'; import '../../../../providers/global/wallets_provider.dart'; -import '../../../../themes/stack_colors.dart'; import '../../../../utilities/constants.dart'; import '../../../../utilities/util.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../../wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart'; import '../../../../widgets/loading_indicator.dart'; +import '../../../../widgets/paginated_list_view.dart'; import '../../sub_widgets/no_transactions_found.dart'; import '../../wallet_view.dart'; import 'fusion_tx_group_card.dart'; @@ -59,6 +60,54 @@ class _TransactionsV2ListState extends ConsumerState { ); } + List _processData(List transactions) { + if (ref.read(pWallets).getWallet(widget.walletId) is! CashFusionInterface) { + return transactions; + } + + final List processed = []; + + List fusions = []; + + for (int i = 0; i < transactions.length; i++) { + final tx = transactions[i]; + + if (tx.subType == TransactionSubType.cashFusion) { + if (fusions.isNotEmpty) { + final prevTime = DateTime.fromMillisecondsSinceEpoch( + fusions.last.timestamp * 1000, + ); + final thisTime = DateTime.fromMillisecondsSinceEpoch( + tx.timestamp * 1000, + ); + + if (prevTime.difference(thisTime).inMinutes > 30) { + processed.add(FusionTxGroup(fusions)); + fusions = [tx]; + continue; + } + } + + fusions.add(tx); + } + + if (i + 1 < transactions.length) { + final nextTx = transactions[i + 1]; + if (nextTx.subType != TransactionSubType.cashFusion && + fusions.isNotEmpty) { + processed.add(FusionTxGroup(fusions)); + fusions = []; + } + } + + if (tx.subType != TransactionSubType.cashFusion) { + processed.add(tx); + } + } + + return processed; + } + @override void initState() { coin = ref.read(pWallets).getWallet(widget.walletId).info.coin; @@ -73,19 +122,20 @@ class _TransactionsV2ListState extends ConsumerState { value: [widget.walletId], ), ], - filter: - ref - .read(pWallets) - .getWallet(widget.walletId) - .transactionFilterOperation, + filter: ref + .read(pWallets) + .getWallet(widget.walletId) + .transactionFilterOperation, sortBy: [const SortProperty(property: "timestamp", sort: Sort.desc)], ); _subscription = _query.watch().listen((event) { WidgetsBinding.instance.addPostFrameCallback((_) { - setState(() { - _transactions = event; - }); + if (mounted) { + setState(() { + _transactions = event; + }); + } }); }); @@ -128,110 +178,58 @@ class _TransactionsV2ListState extends ConsumerState { return compare; }); - final List _txns = []; - - List fusions = []; - - for (int i = 0; i < _transactions.length; i++) { - final tx = _transactions[i]; - - if (tx.subType == TransactionSubType.cashFusion) { - if (fusions.isNotEmpty) { - final prevTime = DateTime.fromMillisecondsSinceEpoch( - fusions.last.timestamp * 1000, - ); - final thisTime = DateTime.fromMillisecondsSinceEpoch( - tx.timestamp * 1000, - ); - - if (prevTime.difference(thisTime).inMinutes > 30) { - _txns.add(FusionTxGroup(fusions)); - fusions = [tx]; - continue; - } - } - - fusions.add(tx); - } - - if (i + 1 < _transactions.length) { - final nextTx = _transactions[i + 1]; - if (nextTx.subType != TransactionSubType.cashFusion && - fusions.isNotEmpty) { - _txns.add(FusionTxGroup(fusions)); - fusions = []; - } - } - - if (tx.subType != TransactionSubType.cashFusion) { - _txns.add(tx); - } - } + final _txns = _processData(_transactions); return RefreshIndicator( onRefresh: () async { await ref.read(pWallets).getWallet(widget.walletId).refresh(); }, - child: - Util.isDesktop - ? ListView.separated( - shrinkWrap: true, - itemBuilder: (context, index) { - BorderRadius? radius; - if (_txns.length == 1) { - radius = BorderRadius.circular( - Constants.size.circularBorderRadius, - ); - } else if (index == _txns.length - 1) { - radius = _borderRadiusLast; - } else if (index == 0) { - radius = _borderRadiusFirst; - } - final tx = _txns[index]; - return TxListItem(tx: tx, coin: coin, radius: radius); - }, - separatorBuilder: (context, index) { - return Container( - width: double.infinity, - height: 2, - color: - Theme.of( - context, - ).extension()!.background, + child: Util.isDesktop + ? PaginatedListView( + items: _txns, + itemBuilder: (context, tx, position) { + final radius = switch (position) { + PageItemPosition.first => _borderRadiusFirst, + PageItemPosition.last => _borderRadiusLast, + PageItemPosition.solo => BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + PageItemPosition.somewhere => null, + }; + + return TxListItem(tx: tx, coin: coin, radius: radius); + }, + ) + : ListView.builder( + itemCount: _txns.length, + itemBuilder: (context, index) { + BorderRadius? radius; + bool shouldWrap = false; + if (_txns.length == 1) { + radius = BorderRadius.circular( + Constants.size.circularBorderRadius, ); - }, - itemCount: _txns.length, - ) - : ListView.builder( - itemCount: _txns.length, - itemBuilder: (context, index) { - BorderRadius? radius; - bool shouldWrap = false; - if (_txns.length == 1) { - radius = BorderRadius.circular( - Constants.size.circularBorderRadius, - ); - } else if (index == _txns.length - 1) { - radius = _borderRadiusLast; - shouldWrap = true; - } else if (index == 0) { - radius = _borderRadiusFirst; - } - final tx = _txns[index]; - if (shouldWrap) { - return Column( - children: [ - TxListItem(tx: tx, coin: coin, radius: radius), - const SizedBox( - height: WalletView.navBarHeight + 14, - ), - ], - ); - } else { - return TxListItem(tx: tx, coin: coin, radius: radius); - } - }, - ), + } else if (index == _txns.length - 1) { + radius = _borderRadiusLast; + shouldWrap = true; + } else if (index == 0) { + radius = _borderRadiusFirst; + } + final tx = _txns[index]; + if (shouldWrap) { + return Column( + children: [ + TxListItem(tx: tx, coin: coin, radius: radius), + const SizedBox( + height: WalletView.navBarHeight + 14, + ), + ], + ); + } else { + return TxListItem(tx: tx, coin: coin, radius: radius); + } + }, + ), ); } }, diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart index 0ae6414010..74ac48de21 100644 --- a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart +++ b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tuple/tuple.dart'; +import '../../../../models/exchange/response_objects/trade.dart'; import '../../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../../models/isar/models/isar_models.dart'; import '../../../../providers/global/trades_service_provider.dart'; @@ -38,12 +39,19 @@ class TxListItem extends ConsumerWidget { if (tx is TransactionV2) { final _tx = tx as TransactionV2; - final matchingTrades = ref - .read(tradesServiceProvider) - .trades - .where((e) => e.payInTxid == _tx.txid || e.payOutTxid == _tx.txid); + final Iterable matchingTrades = + _tx.type == TransactionType.outgoing && _tx.txid.isNotEmpty + ? ref + .read(tradesServiceProvider) + .trades + .where( + (e) => e.payInTxid == _tx.txid || e.payOutTxid == _tx.txid, + ) + : []; - if (_tx.type == TransactionType.outgoing && matchingTrades.isNotEmpty) { + final txKeyString = _tx.txid + _tx.type.name + _tx.hashCode.toString(); + + if (matchingTrades.isNotEmpty) { final trade = matchingTrades.first; return Container( decoration: BoxDecoration( @@ -54,17 +62,9 @@ class TxListItem extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - TransactionCardV2( - key: UniqueKey(), - transaction: _tx, - ), + TransactionCardV2(key: Key(txKeyString), transaction: _tx), TradeCard( - key: Key( - _tx.txid + - _tx.type.name + - _tx.hashCode.toString() + - trade.uuid, - ), // + key: Key(txKeyString + trade.uuid), trade: trade, onTap: () async { if (Util.isDesktop) { @@ -94,7 +94,8 @@ class TxListItem extends ConsumerWidget { Text( "Trade details", style: STextStyles.desktopH3( - context), + context, + ), ), DesktopDialogCloseButton( onPressedOverride: Navigator.of( @@ -111,8 +112,9 @@ class TxListItem extends ConsumerWidget { // TODO: [prio:med] // transactionIfSentFromStack: tx, transactionIfSentFromStack: null, - walletName: ref - .watch(pWalletName(_tx.walletId)), + walletName: ref.watch( + pWalletName(_tx.walletId), + ), walletId: _tx.walletId, ), ), @@ -155,7 +157,7 @@ class TxListItem extends ConsumerWidget { child: Breathing( child: TransactionCardV2( // this may mess with combined firo transactions - key: UniqueKey(), + key: Key(txKeyString), transaction: _tx, ), ), @@ -171,10 +173,7 @@ class TxListItem extends ConsumerWidget { borderRadius: radius, ), child: Breathing( - child: FusionTxGroupCard( - key: UniqueKey(), - group: group, - ), + child: FusionTxGroupCard(key: ObjectKey(group), group: group), ), ); } diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index 417ad227dd..c40963153d 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -50,16 +50,19 @@ import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/crypto_currency/intermediate/frost_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import '../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; import '../../wallets/wallet/impl/namecoin_wallet.dart'; -import '../../wallets/wallet/intermediate/lib_monero_wallet.dart'; +import '../../wallets/wallet/impl/salvium_wallet.dart'; +import '../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../wallets/wallet/intermediate/lib_salvium_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/sign_verify_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../widgets/background.dart'; @@ -89,8 +92,10 @@ import '../buy_view/buy_in_wallet_view.dart'; import '../cashfusion/cashfusion_view.dart'; import '../churning/churning_view.dart'; import '../coin_control/coin_control_view.dart'; +import '../epic_finalize_view/epic_finalize_view.dart'; import '../exchange_view/wallet_initiated_exchange_view.dart'; import '../finalize_view/finalize_view.dart'; +import '../masternodes/masternodes_home_view.dart'; import '../monkey/monkey_view.dart'; import '../namecoin_names/namecoin_names_home_view.dart'; import '../notification_views/notifications_view.dart'; @@ -103,6 +108,7 @@ import '../send_view/frost_ms/frost_send_view.dart'; import '../send_view/send_view.dart'; import '../settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart'; import '../settings_views/wallet_settings_view/wallet_settings_view.dart'; +import '../signing/signing_view.dart'; import '../spark_names/spark_names_home_view.dart'; import '../token_view/my_tokens_view.dart'; import 'sub_widgets/transactions_list.dart'; @@ -1026,6 +1032,21 @@ class _WalletViewState extends ConsumerState { } }, ), + if (wallet is EpiccashWallet) + WalletNavigationBarItemData( + label: "Finalize", + icon: const FinalizeNavIcon(), + onTap: () { + if (mounted) { + unawaited( + Navigator.of(context).pushNamed( + EpicFinalizeView.routeName, + arguments: walletId, + ), + ); + } + }, + ), if (ref.watch(pWalletCoin(walletId)) is FrostCurrency) WalletNavigationBarItemData( label: "Sign", @@ -1080,7 +1101,8 @@ class _WalletViewState extends ConsumerState { icon: const BuyNavIcon(), onTap: () => _onBuyPressed(context), ), - if (wallet is SparkInterface) + if (wallet is SparkInterface || + (viewOnly && wallet.viewOnlyType == .spark)) WalletNavigationBarItemData( label: "Names", icon: const PaynymNavIcon(), @@ -1092,7 +1114,7 @@ class _WalletViewState extends ConsumerState { }, ), ], - moreItems: [ + moreItems: [ if (ref.watch( pWallets.select( (value) => value @@ -1129,7 +1151,26 @@ class _WalletViewState extends ConsumerState { ); }, ), + if (wallet is SignVerifyInterface && !viewOnly) + WalletNavigationBarItemData( + icon: SvgPicture.asset( + Assets.svg.pencil, + height: 20, + width: 20, + color: Theme.of( + context, + ).extension()!.bottomNavIconIcon, + ), + label: "Sign/Verify", + onTap: () { + Navigator.of(context).pushNamed( + SigningView.routeName, + arguments: widget.walletId, + ); + }, + ), if (wallet is CoinControlInterface && + wallet is! SalviumWallet && ref.watch( prefsChangeNotifierProvider.select( (value) => value.enableCoinControl, @@ -1164,6 +1205,27 @@ class _WalletViewState extends ConsumerState { ); }, ), + if (!viewOnly && wallet is FiroWallet) + WalletNavigationBarItemData( + label: "Masternodes", + icon: SvgPicture.asset( + Assets.svg.recycle, + height: 20, + width: 20, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.bottomNavIconIcon, + BlendMode.srcIn, + ), + ), + onTap: () { + Navigator.of(context).pushNamed( + MasternodesHomeView.routeName, + arguments: widget.walletId, + ); + }, + ), if (wallet is NamecoinWallet) WalletNavigationBarItemData( label: "Domains", @@ -1272,9 +1334,7 @@ class _WalletViewState extends ConsumerState { ); }, ), - if ((wallet is LibMoneroWallet || - wallet is LibSalviumWallet) && - !viewOnly) + if ((wallet is CryptonoteWallet) && !viewOnly) WalletNavigationBarItemData( label: "Churn", icon: const ChurnNavIcon(), diff --git a/lib/pages/wallets_view/sub_widgets/wallet_list_item.dart b/lib/pages/wallets_view/sub_widgets/wallet_list_item.dart index 64d9ecbc9f..42955e0068 100644 --- a/lib/pages/wallets_view/sub_widgets/wallet_list_item.dart +++ b/lib/pages/wallets_view/sub_widgets/wallet_list_item.dart @@ -24,7 +24,9 @@ import '../../../utilities/show_loading.dart'; import '../../../utilities/show_node_tor_settings_mismatch.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; +import '../../../wallets/crypto_currency/coins/solana.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/wallet/intermediate/external_wallet.dart'; import '../../../widgets/dialogs/tor_warning_dialog.dart'; import '../../../widgets/rounded_white_container.dart'; @@ -80,7 +82,23 @@ class WalletListItem extends ConsumerWidget { } } - if (walletCount == 1 && coin is! Ethereum) { + // Check if we should show the wallets overview or open wallet directly. + bool shouldShowWalletsOverview = walletCount > 1 || coin is Ethereum; + + // For Solana and other token-supporting coins, check if any wallet has tokens. + if (!shouldShowWalletsOverview && coin.hasTokenSupport) { + final wallet = ref + .read(pWallets) + .wallets + .firstWhere((e) => e.info.coin == coin); + + final tokenAddresses = ref.read(pWalletTokenAddresses(wallet.walletId)); + if (tokenAddresses.isNotEmpty) { + shouldShowWalletsOverview = true; + } + } + + if (walletCount == 1 && !shouldShowWalletsOverview) { final wallet = ref .read(pWallets) .wallets diff --git a/lib/pages/wallets_view/wallets_overview.dart b/lib/pages/wallets_view/wallets_overview.dart index d0cc2d12da..c1adb8dead 100644 --- a/lib/pages/wallets_view/wallets_overview.dart +++ b/lib/pages/wallets_view/wallets_overview.dart @@ -15,7 +15,7 @@ import 'package:isar_community/isar.dart'; import '../../app_config.dart'; import '../../models/add_wallet_list_entity/sub_classes/coin_entity.dart'; -import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../models/isar/models/contract.dart'; import '../../pages_desktop_specific/my_stack_view/dialogs/desktop_expanding_wallet_card.dart'; import '../../providers/providers.dart'; import '../../services/event_bus/events/wallet_added_event.dart'; @@ -59,7 +59,7 @@ class WalletsOverview extends ConsumerStatefulWidget { ConsumerState createState() => _EthWalletsOverviewState(); } -typedef WalletListItemData = ({Wallet wallet, List contracts}); +typedef WalletListItemData = ({Wallet wallet, List contracts}); class _EthWalletsOverviewState extends ConsumerState { final isDesktop = Util.isDesktop; @@ -99,15 +99,13 @@ class _EthWalletsOverviewState extends ConsumerState { term, ); - final List contracts = []; + final List contracts = []; for (final contract in entry.value.contracts) { if (_elementContains(contract.name, term)) { contracts.add(contract); } else if (_elementContains(contract.symbol, term)) { contracts.add(contract); - } else if (_elementContains(contract.type.name, term)) { - contracts.add(contract); } else if (_elementContains(contract.address, term)) { contracts.add(contract); } @@ -133,7 +131,7 @@ class _EthWalletsOverviewState extends ConsumerState { if (widget.coin is Ethereum) { for (final data in walletsData) { - final List contracts = []; + final List contracts = []; final contractAddresses = ref.read( pWalletTokenAddresses(data.walletId), ); @@ -150,6 +148,31 @@ class _EthWalletsOverviewState extends ConsumerState { } } + // add tuple to list + wallets[data.walletId] = ( + wallet: ref.read(pWallets).getWallet(data.walletId), + contracts: contracts, + ); + } + } else if (widget.coin is Solana) { + for (final data in walletsData) { + final List contracts = []; + final tokenMintAddresses = ref.read( + pWalletTokenAddresses(data.walletId), + ); + + // fetch each token + for (final tokenAddress in tokenMintAddresses) { + final token = ref + .read(mainDBProvider) + .getSolContractSync(tokenAddress); + + // add it to list if it exists in DB + if (token != null) { + contracts.add(token); + } + } + // add tuple to list wallets[data.walletId] = ( wallet: ref.read(pWallets).getWallet(data.walletId), @@ -326,6 +349,7 @@ class _EthWalletsOverviewState extends ConsumerState { data: entry, navigatorState: widget.navigatorState!, ); + // } } else { return MasterWalletCard( key: Key(wallet.walletId), diff --git a/lib/pages_desktop_specific/address_book_view/desktop_address_book.dart b/lib/pages_desktop_specific/address_book_view/desktop_address_book.dart index f27147cb3f..a4e767048b 100644 --- a/lib/pages_desktop_specific/address_book_view/desktop_address_book.dart +++ b/lib/pages_desktop_specific/address_book_view/desktop_address_book.dart @@ -17,7 +17,6 @@ import '../../models/isar/models/blockchain_data/address.dart'; import '../../models/isar/models/contact_entry.dart'; import '../../pages/address_book_views/subviews/add_address_book_entry_view.dart'; import '../../pages/address_book_views/subviews/address_book_filter_view.dart'; -import '../../providers/db/main_db_provider.dart'; import '../../providers/global/address_book_service_provider.dart'; import '../../providers/providers.dart'; import '../../providers/ui/address_book_providers/address_book_filter_provider.dart'; @@ -99,17 +98,18 @@ class _DesktopAddressBook extends ConsumerState { // if (widget.coin == null) { final coins = AppConfig.coins.toList(); - coins.removeWhere( - (e) => e is Firo && e.network.isTestNet, - ); + coins.removeWhere((e) => e is Firo && e.network.isTestNet); - final bool showTestNet = - ref.read(prefsChangeNotifierProvider).showTestNetCoins; + final bool showTestNet = ref + .read(prefsChangeNotifierProvider) + .showTestNetCoins; if (showTestNet) { ref.read(addressBookFilterProvider).addAll(coins, false); } else { - ref.read(addressBookFilterProvider).addAll( + ref + .read(addressBookFilterProvider) + .addAll( coins.where((e) => e.network != CryptoCurrencyNetwork.test), false, ); @@ -123,12 +123,10 @@ class _DesktopAddressBook extends ConsumerState { final wallets = ref.read(pWallets).wallets; for (final wallet in wallets) { final String addressString; - if (wallet is SparkInterface) { + if (wallet is SparkInterface && + !(wallet.isViewOnly && wallet.viewOnlyType != .spark)) { Address? address = await wallet.getCurrentReceivingSparkAddress(); - if (address == null) { - address = await wallet.generateNextSparkAddress(); - await ref.read(mainDBProvider).updateOrPutAddresses([address]); - } + address ??= await wallet.generateNextSparkAddress(saveToDB: true); addressString = address.value; } else { final address = await wallet.getCurrentReceivingAddress(); @@ -166,8 +164,9 @@ class _DesktopAddressBook extends ConsumerState { @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); - final contacts = - ref.watch(addressBookServiceProvider.select((value) => value.contacts)); + final contacts = ref.watch( + addressBookServiceProvider.select((value) => value.contacts), + ); final allContacts = contacts .where( @@ -176,8 +175,9 @@ class _DesktopAddressBook extends ConsumerState { element.addresses .where( (e) => ref.watch( - addressBookFilterProvider - .select((value) => value.coins.contains(e.coin)), + addressBookFilterProvider.select( + (value) => value.coins.contains(e.coin), + ), ), ) .isNotEmpty, @@ -194,8 +194,9 @@ class _DesktopAddressBook extends ConsumerState { element.addresses .where( (e) => ref.watch( - addressBookFilterProvider - .select((value) => value.coins.contains(e.coin)), + addressBookFilterProvider.select( + (value) => value.coins.contains(e.coin), + ), ), ) .isNotEmpty, @@ -213,22 +214,13 @@ class _DesktopAddressBook extends ConsumerState { isCompactHeight: true, leading: Row( children: [ - const SizedBox( - width: 24, - ), - Text( - "Address Book", - style: STextStyles.desktopH3(context), - ), + const SizedBox(width: 24), + Text("Address Book", style: STextStyles.desktopH3(context)), ], ), ), body: Padding( - padding: const EdgeInsets.only( - left: 24, - right: 24, - bottom: 24, - ), + padding: const EdgeInsets.only(left: 24, right: 24, bottom: 24), child: DesktopAddressBookScaffold( controlsLeft: ClipRRect( borderRadius: BorderRadius.circular( @@ -245,43 +237,44 @@ class _DesktopAddressBook extends ConsumerState { }); }, style: STextStyles.field(context), - decoration: standardInputDecoration( - "Search", - _searchFocusNode, - context, - ).copyWith( - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 20, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 16, - height: 16, - ), - ), - 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 = ""; - _searchTerm = ""; - }); - }, + decoration: + standardInputDecoration( + "Search", + _searchFocusNode, + context, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 20, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 16, + height: 16, + ), + ), + 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 = ""; + _searchTerm = ""; + }); + }, + ), + ], ), - ], - ), - ), - ) - : null, - ), + ), + ) + : null, + ), ), ), controlsRight: Row( @@ -293,24 +286,22 @@ class _DesktopAddressBook extends ConsumerState { buttonHeight: ButtonHeight.l, icon: SvgPicture.asset( Assets.svg.filter, - color: Theme.of(context) - .extension()! - .buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), onPressed: selectCryptocurrency, ), - const SizedBox( - width: 20, - ), + const SizedBox(width: 20), PrimaryButton( width: 184, label: "Add new", buttonHeight: ButtonHeight.l, icon: SvgPicture.asset( Assets.svg.circlePlus, - color: Theme.of(context) - .extension()! - .buttonTextPrimary, + color: Theme.of( + context, + ).extension()!.buttonTextPrimary, ), onPressed: newContact, ), @@ -326,10 +317,7 @@ class _DesktopAddressBook extends ConsumerState { lowerLabel: favorites.isEmpty ? null : Padding( - padding: const EdgeInsets.only( - top: 20, - bottom: 12, - ), + padding: const EdgeInsets.only(top: 20, bottom: 12), child: Text( "All contacts", style: STextStyles.smallMed12(context), @@ -337,15 +325,15 @@ class _DesktopAddressBook extends ConsumerState { ), favorites: favorites.isEmpty ? contacts.isNotEmpty - ? null - : RoundedWhiteContainer( - child: Center( - child: Text( - "Your favorite contacts will appear here", - style: STextStyles.itemSubtitle(context), + ? null + : RoundedWhiteContainer( + child: Center( + child: Text( + "Your favorite contacts will appear here", + style: STextStyles.itemSubtitle(context), + ), ), - ), - ) + ) : RoundedWhiteContainer( padding: const EdgeInsets.all(0), child: Column( @@ -355,9 +343,9 @@ class _DesktopAddressBook extends ConsumerState { children: [ if (i > 0) Container( - color: Theme.of(context) - .extension()! - .background, + color: Theme.of( + context, + ).extension()!.background, height: 1, ), Padding( @@ -406,15 +394,15 @@ class _DesktopAddressBook extends ConsumerState { ), all: allContacts.isEmpty ? contacts.isNotEmpty - ? null - : RoundedWhiteContainer( - child: Center( - child: Text( - "Your contacts will appear here", - style: STextStyles.itemSubtitle(context), + ? null + : RoundedWhiteContainer( + child: Center( + child: Text( + "Your contacts will appear here", + style: STextStyles.itemSubtitle(context), + ), ), - ), - ) + ) : Column( children: [ RoundedWhiteContainer( @@ -426,9 +414,9 @@ class _DesktopAddressBook extends ConsumerState { children: [ if (i > 0) Container( - color: Theme.of(context) - .extension()! - .background, + color: Theme.of( + context, + ).extension()!.background, height: 1, ), Padding( @@ -481,9 +469,7 @@ class _DesktopAddressBook extends ConsumerState { ), details: currentContactId == null ? Container() - : DesktopContactDetails( - contactId: currentContactId!, - ), + : DesktopContactDetails(contactId: currentContactId!), ), ), ); diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart index 23e925c0c3..27d0b68a64 100644 --- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart +++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart @@ -24,14 +24,12 @@ import '../../../providers/global/wallets_provider.dart'; import '../../../route_generator.dart'; import '../../../services/exchange/exchange_response.dart'; import '../../../services/notifications_api.dart'; -import '../../../services/wallets.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/assets.dart'; import '../../../utilities/enums/exchange_rate_type_enum.dart'; import '../../../utilities/text_styles.dart'; -import '../../../wallets/wallet/intermediate/external_wallet.dart'; -import '../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; +import '../../../utilities/util.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../widgets/custom_loading_overlay.dart'; import '../../../widgets/desktop/desktop_dialog.dart'; @@ -80,19 +78,18 @@ class _StepScaffoldState extends ConsumerState { showDialog( context: context, barrierDismissible: false, - builder: - (_) => WillPopScope( - onWillPop: () async => false, - child: Container( - color: Theme.of( - context, - ).extension()!.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()!.overlay.withOpacity(0.6), + child: const CustomLoadingOverlay( + message: "Creating a trade", + eventBus: null, ), + ), + ), ), ); @@ -100,18 +97,21 @@ class _StepScaffoldState extends ConsumerState { .read(efExchangeProvider) .createTrade( from: ref.read(desktopExchangeModelProvider)!.sendTicker, - fromNetwork: - ref.read(desktopExchangeModelProvider)!.sendCurrency.network, + fromNetwork: ref + .read(desktopExchangeModelProvider)! + .sendCurrency + .network, to: ref.read(desktopExchangeModelProvider)!.receiveTicker, - toNetwork: - ref.read(desktopExchangeModelProvider)!.receiveCurrency.network, + toNetwork: ref + .read(desktopExchangeModelProvider)! + .receiveCurrency + .network, fixedRate: ref.read(desktopExchangeModelProvider)!.rateType != ExchangeRateType.estimated, - amount: - ref.read(desktopExchangeModelProvider)!.reversed - ? ref.read(desktopExchangeModelProvider)!.receiveAmount - : ref.read(desktopExchangeModelProvider)!.sendAmount, + amount: ref.read(desktopExchangeModelProvider)!.reversed + ? ref.read(desktopExchangeModelProvider)!.receiveAmount + : ref.read(desktopExchangeModelProvider)!.sendAmount, addressTo: ref.read(desktopExchangeModelProvider)!.recipientAddress!, extraId: null, addressRefund: ref.read(desktopExchangeModelProvider)!.refundAddress!, @@ -138,11 +138,10 @@ class _StepScaffoldState extends ConsumerState { showDialog( context: context, barrierDismissible: true, - builder: - (_) => SimpleDesktopDialog( - title: "Failed to create trade", - message: message ?? "", - ), + builder: (_) => SimpleDesktopDialog( + title: "Failed to create trade", + message: message ?? "", + ), ), ); } @@ -222,49 +221,28 @@ class _StepScaffoldState extends ConsumerState { showDialog( context: context, - builder: - (context) => Navigator( - initialRoute: SendFromView.routeName, - onGenerateRoute: RouteGenerator.generateRoute, - onGenerateInitialRoutes: (_, __) { - return [ - FadePageRoute( - SendFromView( - coin: coin, - trade: trade, - amount: amount, - address: address, - shouldPopRoot: true, - fromDesktopStep4: true, - ), - const RouteSettings(name: SendFromView.routeName), - ), - ]; - }, - ), + builder: (context) => Navigator( + initialRoute: SendFromView.routeName, + onGenerateRoute: RouteGenerator.generateRoute, + onGenerateInitialRoutes: (_, __) { + return [ + FadePageRoute( + SendFromView( + coin: coin, + trade: trade, + amount: amount, + address: address, + shouldPopRoot: true, + fromDesktopStep4: true, + ), + const RouteSettings(name: SendFromView.routeName), + ), + ]; + }, + ), ); } - bool isWalletCoinAndCanSendWithoutWalletOpened( - String ticker, - Wallets walletsInstance, - ) { - try { - final coin = AppConfig.getCryptoCurrencyForTicker(ticker); - return walletsInstance.wallets - .where( - (e) => - e.info.coin == coin && - (e is! ExternalWallet || - e is MwebInterface), // ltc mweb is external but swaps - // should not use mweb, hence the odd logic check here - ) - .isNotEmpty; - } catch (_) { - return false; - } - } - @override void initState() { duration = const Duration(milliseconds: 250); @@ -281,10 +259,11 @@ class _StepScaffoldState extends ConsumerState { // set to true anyways to show back button canSendFromStack = true; } else { - canSendFromStack = isWalletCoinAndCanSendWithoutWalletOpened( - model?.sendTicker ?? "", - ref.read(pWallets), - ); + canSendFromStack = + Util.isWalletCoinAndCanSendWithoutWalletOpenedIgnoringXMR( + model?.sendTicker ?? "", + ref.read(pWallets).wallets, + ); } return Column( @@ -297,10 +276,10 @@ class _StepScaffoldState extends ConsumerState { children: [ currentStep != 4 ? AppBarBackButton( - isCompact: true, - iconSize: 23, - onPressed: onBack, - ) + isCompact: true, + iconSize: 23, + onPressed: onBack, + ) : const SizedBox(width: 32), Text( "Exchange ${model?.sendTicker.toUpperCase()} to ${model?.receiveTicker.toUpperCase()}", @@ -345,39 +324,36 @@ class _StepScaffoldState extends ConsumerState { children: [ canSendFromStack ? Expanded( - child: AnimatedCrossFade( - duration: const Duration(milliseconds: 250), - crossFadeState: - currentStep == 4 - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - firstChild: SecondaryButton( - label: "Back", - buttonHeight: ButtonHeight.l, - onPressed: onBack, - ), - secondChild: SecondaryButton( - label: "Send from ${AppConfig.appName}", - buttonHeight: ButtonHeight.l, - onPressed: sendFromStack, + child: AnimatedCrossFade( + duration: const Duration(milliseconds: 250), + crossFadeState: currentStep == 4 + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: SecondaryButton( + label: "Back", + buttonHeight: ButtonHeight.l, + onPressed: onBack, + ), + secondChild: SecondaryButton( + label: "Send from ${AppConfig.appName}", + buttonHeight: ButtonHeight.l, + onPressed: sendFromStack, + ), ), - ), - ) + ) : const Spacer(), const SizedBox(width: 16), Expanded( child: AnimatedCrossFade( duration: const Duration(milliseconds: 250), - crossFadeState: - currentStep == 4 - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, + crossFadeState: currentStep == 4 + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, firstChild: AnimatedCrossFade( duration: const Duration(milliseconds: 250), - crossFadeState: - currentStep == 3 - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, + crossFadeState: currentStep == 3 + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, firstChild: PrimaryButton( label: "Next", enabled: currentStep != 2 ? true : enableNext, diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart index c7ba641cab..46038a58d6 100644 --- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart +++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart @@ -31,7 +31,7 @@ import '../../../../widgets/rounded_white_container.dart'; import '../../../../widgets/stack_text_field.dart'; import '../../../../widgets/textfield_icon_button.dart'; import '../../../my_stack_view/wallet_view/sub_widgets/address_book_address_chooser/address_book_address_chooser.dart'; -import '../../subwidgets/desktop_choose_from_stack.dart'; +import '../../subwidgets/desktop_choose_address_from_stack.dart'; import '../step_scaffold.dart'; class DesktopStep2 extends ConsumerStatefulWidget { @@ -59,23 +59,21 @@ class _DesktopStep2State extends ConsumerState { void selectRecipientAddressFromStack() async { try { - final coin = - AppConfig.getCryptoCurrencyForTicker( - ref.read(desktopExchangeModelProvider)!.receiveTicker, - )!; + final coin = AppConfig.getCryptoCurrencyForTicker( + ref.read(desktopExchangeModelProvider)!.receiveTicker, + )!; final info = await showDialog?>( context: context, barrierColor: Colors.transparent, - builder: - (context) => DesktopDialog( - maxWidth: 720, - maxHeight: 670, - child: Padding( - padding: const EdgeInsets.all(32), - child: DesktopChooseFromStack(coin: coin), - ), - ), + builder: (context) => DesktopDialog( + maxWidth: 720, + maxHeight: 670, + child: Padding( + padding: const EdgeInsets.all(32), + child: DesktopChooseAddressFromStack(coin: coin), + ), + ), ); if (info is Tuple2) { @@ -91,23 +89,21 @@ class _DesktopStep2State extends ConsumerState { void selectRefundAddressFromStack() async { try { - final coin = - AppConfig.getCryptoCurrencyForTicker( - ref.read(desktopExchangeModelProvider)!.sendTicker, - )!; + final coin = AppConfig.getCryptoCurrencyForTicker( + ref.read(desktopExchangeModelProvider)!.sendTicker, + )!; final info = await showDialog?>( context: context, barrierColor: Colors.transparent, - builder: - (context) => DesktopDialog( - maxWidth: 720, - maxHeight: 670, - child: Padding( - padding: const EdgeInsets.all(32), - child: DesktopChooseFromStack(coin: coin), - ), - ), + builder: (context) => DesktopDialog( + maxWidth: 720, + maxHeight: 670, + child: Padding( + padding: const EdgeInsets.all(32), + child: DesktopChooseAddressFromStack(coin: coin), + ), + ), ); if (info is Tuple2) { _refundController.text = info.item1; @@ -127,30 +123,29 @@ class _DesktopStep2State extends ConsumerState { final entry = await showDialog( context: context, barrierColor: Colors.transparent, - builder: - (context) => DesktopDialog( - maxWidth: 720, - maxHeight: 670, - child: Column( - mainAxisSize: MainAxisSize.min, + builder: (context) => DesktopDialog( + maxWidth: 720, + maxHeight: 670, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Address book", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Address book", + style: STextStyles.desktopH3(context), + ), ), - Expanded(child: AddressBookAddressChooser(coin: coin)), + const DesktopDialogCloseButton(), ], ), - ), + Expanded(child: AddressBookAddressChooser(coin: coin)), + ], + ), + ), ); if (entry != null) { @@ -168,30 +163,29 @@ class _DesktopStep2State extends ConsumerState { final entry = await showDialog( context: context, barrierColor: Colors.transparent, - builder: - (context) => DesktopDialog( - maxWidth: 720, - maxHeight: 670, - child: Column( - mainAxisSize: MainAxisSize.min, + builder: (context) => DesktopDialog( + maxWidth: 720, + maxHeight: 670, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Address book", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Address book", + style: STextStyles.desktopH3(context), + ), ), - Expanded(child: AddressBookAddressChooser(coin: coin)), + const DesktopDialogCloseButton(), ], ), - ), + Expanded(child: AddressBookAddressChooser(coin: coin)), + ], + ), + ), ); if (entry != null) { @@ -234,12 +228,11 @@ class _DesktopStep2State extends ConsumerState { if (tuple != null) { if (ref.read(desktopExchangeModelProvider)!.receiveTicker.toLowerCase() == tuple.item2.ticker.toLowerCase()) { - _toController.text = - ref - .read(pWallets) - .getWallet(tuple.item1) - .info - .cachedReceivingAddress; + _toController.text = ref + .read(pWallets) + .getWallet(tuple.item1) + .info + .cachedReceivingAddress; WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(desktopExchangeModelProvider)!.recipientAddress = @@ -249,12 +242,11 @@ class _DesktopStep2State extends ConsumerState { if (doesRefundAddress && ref.read(desktopExchangeModelProvider)!.sendTicker.toUpperCase() == tuple.item2.ticker.toUpperCase()) { - _refundController.text = - ref - .read(pWallets) - .getWallet(tuple.item1) - .info - .cachedReceivingAddress; + _refundController.text = ref + .read(pWallets) + .getWallet(tuple.item1) + .info + .cachedReceivingAddress; WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(desktopExchangeModelProvider)!.refundAddress = _refundController.text; @@ -300,10 +292,9 @@ class _DesktopStep2State extends ConsumerState { Text( "Recipient Wallet", style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, ), ), if (AppConfig.isStackCoin( @@ -347,81 +338,82 @@ class _DesktopStep2State extends ConsumerState { _toController.text; widget.enableNextChanged.call(_next()); }, - decoration: standardInputDecoration( - "Enter the ${ref.watch(desktopExchangeModelProvider.select((value) => value!.receiveTicker.toUpperCase()))} payout address", - _toFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.only( - left: 16, - top: 6, - bottom: 8, - right: 5, - ), - suffixIcon: Padding( - padding: - _toController.text.isEmpty + decoration: + standardInputDecoration( + "Enter the ${ref.watch(desktopExchangeModelProvider.select((value) => value!.receiveTicker.toUpperCase()))} payout address", + _toFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _toController.text.isEmpty ? const EdgeInsets.only(right: 8) : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _toController.text.isNotEmpty - ? TextFieldIconButton( - key: const Key( - "sendViewClearAddressFieldButtonKey", - ), - onTap: () { - _toController.text = ""; - ref - .read(desktopExchangeModelProvider)! - .recipientAddress = _toController.text; - widget.enableNextChanged.call(_next()); - }, - child: const XIcon(), - ) - : TextFieldIconButton( - key: const Key( - "sendViewPasteAddressFieldButtonKey", - ), - onTap: () async { - final ClipboardData? data = await clipboard - .getData(Clipboard.kTextPlain); - if (data?.text != null && - data!.text!.isNotEmpty) { - final content = data.text!.trim(); - _toController.text = content; - ref - .read(desktopExchangeModelProvider)! - .recipientAddress = _toController.text; - widget.enableNextChanged.call(_next()); - } - }, - child: - _toController.text.isEmpty - ? const ClipboardIcon() - : const XIcon(), - ), - if (_toController.text.isEmpty && - AppConfig.isStackCoin( - ref.watch( - desktopExchangeModelProvider.select( - (value) => value!.receiveTicker, - ), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _toController.text.isNotEmpty + ? TextFieldIconButton( + key: const Key( + "sendViewClearAddressFieldButtonKey", + ), + onTap: () { + _toController.text = ""; + ref + .read(desktopExchangeModelProvider)! + .recipientAddress = + _toController.text; + widget.enableNextChanged.call(_next()); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "sendViewPasteAddressFieldButtonKey", + ), + onTap: () async { + final ClipboardData? data = await clipboard + .getData(Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + final content = data.text!.trim(); + _toController.text = content; + ref + .read(desktopExchangeModelProvider)! + .recipientAddress = _toController + .text; + widget.enableNextChanged.call(_next()); + } + }, + child: _toController.text.isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_toController.text.isEmpty && + AppConfig.isStackCoin( + ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.receiveTicker, + ), + ), + )) + TextFieldIconButton( + key: const Key("sendViewAddressBookButtonKey"), + onTap: selectRecipientFromAddressBook, + child: const AddressBookIcon(), ), - )) - TextFieldIconButton( - key: const Key("sendViewAddressBookButtonKey"), - onTap: selectRecipientFromAddressBook, - child: const AddressBookIcon(), - ), - ], + ], + ), + ), ), ), - ), - ), ), ), const SizedBox(height: 10), @@ -440,10 +432,9 @@ class _DesktopStep2State extends ConsumerState { Text( "Refund Wallet (required)", style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( - color: - Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, ), ), if (AppConfig.isStackCoin( @@ -487,84 +478,87 @@ class _DesktopStep2State extends ConsumerState { _refundController.text; widget.enableNextChanged.call(_next()); }, - decoration: standardInputDecoration( - "Enter ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker.toUpperCase()))} refund address", - _refundFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.only( - left: 16, - top: 6, - bottom: 8, - right: 5, - ), - suffixIcon: Padding( - padding: - _refundController.text.isEmpty + decoration: + standardInputDecoration( + "Enter ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker.toUpperCase()))} refund address", + _refundFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: _refundController.text.isEmpty ? const EdgeInsets.only(right: 16) : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _refundController.text.isNotEmpty - ? TextFieldIconButton( - key: const Key( - "sendViewClearAddressFieldButtonKey", - ), - onTap: () { - _refundController.text = ""; - ref - .read(desktopExchangeModelProvider)! - .refundAddress = _refundController.text; - - widget.enableNextChanged.call(_next()); - }, - child: const XIcon(), - ) - : TextFieldIconButton( - key: const Key( - "sendViewPasteAddressFieldButtonKey", - ), - onTap: () async { - final ClipboardData? data = await clipboard - .getData(Clipboard.kTextPlain); - if (data?.text != null && - data!.text!.isNotEmpty) { - final content = data.text!.trim(); - - _refundController.text = content; - ref - .read(desktopExchangeModelProvider)! - .refundAddress = _refundController.text; - - widget.enableNextChanged.call(_next()); - } - }, - child: - _refundController.text.isEmpty - ? const ClipboardIcon() - : const XIcon(), - ), - if (_refundController.text.isEmpty && - AppConfig.isStackCoin( - ref.watch( - desktopExchangeModelProvider.select( - (value) => value!.sendTicker, - ), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _refundController.text.isNotEmpty + ? TextFieldIconButton( + key: const Key( + "sendViewClearAddressFieldButtonKey", + ), + onTap: () { + _refundController.text = ""; + ref + .read(desktopExchangeModelProvider)! + .refundAddress = _refundController + .text; + + widget.enableNextChanged.call(_next()); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "sendViewPasteAddressFieldButtonKey", + ), + onTap: () async { + final ClipboardData? data = + await clipboard.getData( + Clipboard.kTextPlain, + ); + if (data?.text != null && + data!.text!.isNotEmpty) { + final content = data.text!.trim(); + + _refundController.text = content; + ref + .read(desktopExchangeModelProvider)! + .refundAddress = _refundController + .text; + + widget.enableNextChanged.call(_next()); + } + }, + child: _refundController.text.isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (_refundController.text.isEmpty && + AppConfig.isStackCoin( + ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.sendTicker, + ), + ), + )) + TextFieldIconButton( + key: const Key("sendViewAddressBookButtonKey"), + onTap: selectRefundFromAddressBook, + child: const AddressBookIcon(), ), - )) - TextFieldIconButton( - key: const Key("sendViewAddressBookButtonKey"), - onTap: selectRefundFromAddressBook, - child: const AddressBookIcon(), - ), - ], + ], + ), + ), ), ), - ), - ), ), ), if (doesRefundAddress) const SizedBox(height: 10), @@ -572,7 +566,8 @@ class _DesktopStep2State extends ConsumerState { RoundedWhiteContainer( borderColor: Theme.of(context).extension()!.background, child: Text( - "In case something goes wrong during the exchange, we might need a refund address so we can return your coins back to you.", + "In case something goes wrong during the exchange, we might need " + "a refund address so we can return your coins back to you.", style: STextStyles.desktopTextExtraExtraSmall(context), ), ), diff --git a/lib/pages_desktop_specific/desktop_exchange/subwidgets/desktop_choose_address_from_stack.dart b/lib/pages_desktop_specific/desktop_exchange/subwidgets/desktop_choose_address_from_stack.dart new file mode 100644 index 0000000000..8eaf949914 --- /dev/null +++ b/lib/pages_desktop_specific/desktop_exchange/subwidgets/desktop_choose_address_from_stack.dart @@ -0,0 +1,426 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:tuple/tuple.dart'; + +import '../../../app_config.dart'; +import '../../../providers/providers.dart'; +import '../../../providers/wallet/public_private_balance_state_provider.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/amount/amount_formatter.dart'; +import '../../../utilities/assets.dart'; +import '../../../utilities/constants.dart'; +import '../../../utilities/show_loading.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; +import '../../../widgets/custom_buttons/blue_text_button.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/icon_widgets/x_icon.dart'; +import '../../../widgets/rounded_white_container.dart'; +import '../../../widgets/stack_dialog.dart'; +import '../../../widgets/stack_text_field.dart'; +import '../../../widgets/textfield_icon_button.dart'; +import '../../../widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart'; + +class DesktopChooseAddressFromStack extends ConsumerStatefulWidget { + const DesktopChooseAddressFromStack({super.key, required this.coin}); + + final CryptoCurrency coin; + + @override + ConsumerState createState() => + _DesktopChooseFromStackState(); +} + +class _DesktopChooseFromStackState + extends ConsumerState { + late final TextEditingController _searchController; + late final FocusNode searchFieldFocusNode; + + String _searchTerm = ""; + + List filter(List walletIds, String searchTerm) { + if (searchTerm.isEmpty) { + return walletIds; + } + + final List result = []; + for (final walletId in walletIds) { + final name = ref.read(pWalletName(walletId)); + + if (name.toLowerCase().contains(searchTerm.toLowerCase())) { + result.add(walletId); + } + } + + return result; + } + + @override + void initState() { + searchFieldFocusNode = FocusNode(); + _searchController = TextEditingController(); + super.initState(); + } + + @override + void dispose() { + _searchController.dispose(); + searchFieldFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Choose from ${AppConfig.prefix}", + style: STextStyles.desktopH3(context), + ), + const SizedBox(height: 28), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: false, + enableSuggestions: false, + controller: _searchController, + focusNode: searchFieldFocusNode, + onChanged: (value) { + setState(() { + _searchTerm = value; + }); + }, + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.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, + ), + ), + 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 = ""; + _searchTerm = ""; + }); + }, + ), + ], + ), + ), + ) + : null, + ), + ), + ), + const SizedBox(height: 16), + Flexible( + child: Builder( + builder: (context) { + final wallets = ref + .watch(pWallets) + .wallets + .where((e) => e.info.coin == widget.coin); + + if (wallets.isEmpty) { + return Column( + children: [ + RoundedWhiteContainer( + borderColor: Theme.of( + context, + ).extension()!.background, + child: Center( + child: Text( + "No ${widget.coin.ticker.toUpperCase()} wallets", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + ), + ), + ], + ); + } + + List walletIds = wallets.map((e) => e.walletId).toList(); + + walletIds = filter(walletIds, _searchTerm); + + return ListView.separated( + primary: false, + itemCount: walletIds.length, + separatorBuilder: (_, __) => const SizedBox(height: 5), + itemBuilder: (context, index) => + _WalletRow(walletId: walletIds[index]), + ); + }, + ), + ), + const SizedBox(height: 20), + Row( + children: [ + const Spacer(), + const SizedBox(width: 16), + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: ButtonHeight.l, + onPressed: Navigator.of(context).pop, + ), + ), + ], + ), + ], + ); + } +} + +class _BalanceDisplay extends ConsumerWidget { + const _BalanceDisplay({super.key, required this.walletId, this.balanceType}); + + final String walletId; + final BalanceType? balanceType; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final coin = ref.watch(pWalletCoin(walletId)); + final total = balanceType == BalanceType.public + ? ref.watch(pWalletBalance(walletId)).total + : balanceType == BalanceType.private + ? ref.watch(pWalletBalanceSecondary(walletId)).total + + ref.watch(pWalletBalanceTertiary(walletId)).total + : ref.watch(pWalletBalance(walletId)).total + + ref.watch(pWalletBalanceSecondary(walletId)).total + + ref.watch(pWalletBalanceTertiary(walletId)).total; + + return Text( + ref.watch(pAmountFormatter(coin)).format(total), + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of(context).extension()!.textSubtitle1, + ), + textAlign: TextAlign.right, + ); + } +} + +class _WalletRow extends ConsumerWidget { + const _WalletRow({super.key, required this.walletId}); + + final String walletId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final coin = ref.watch(pWalletCoin(walletId)); + + if (coin is! Firo) { + return RoundedWhiteContainer( + borderColor: Theme.of(context).extension()!.background, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + children: [ + Row( + children: [ + WalletInfoCoinIcon(coin: coin), + const SizedBox(width: 12), + Text( + ref.watch(pWalletName(walletId)), + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + ), + ], + ), + const Spacer(), + _BalanceDisplay(walletId: walletId), + const SizedBox(width: 80), + CustomTextButton( + text: "Select wallet", + onTap: () async { + final wallet = ref.read(pWallets).getWallet(walletId); + final address = + (await wallet.getCurrentReceivingAddress())?.value ?? + wallet.info.cachedReceivingAddress; + + if (context.mounted) { + Navigator.of(context).pop(Tuple2(wallet.info.name, address)); + } + }, + ), + ], + ), + ); + } + + return RoundedWhiteContainer( + borderColor: Theme.of(context).extension()!.background, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Row( + children: [ + WalletInfoCoinIcon(coin: coin, size: 32), + const SizedBox(width: 12), + Text( + ref.watch(pWalletName(walletId)), + style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of(context).extension()!.textDark, + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + const SizedBox( + width: 12 + 32, // space + size of WalletInfoCoinIcon + ), + Text( + "Spark", + style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of(context).extension()!.textDark, + ), + ), + const Spacer(), + _BalanceDisplay(walletId: walletId, balanceType: .private), + const SizedBox(width: 80), + CustomTextButton( + text: "Select wallet", + onTap: () async { + Future _future() async { + final wallet = + ref.read(pWallets).getWallet(walletId) + as SparkInterface; + + final sparkAddress = await wallet + .getCurrentReceivingSparkAddress(); + if (sparkAddress != null) { + return sparkAddress.value; + } + + return (await wallet.generateNextSparkAddress( + saveToDB: true, + )).value; + } + + Exception? ex; + final sparkAddress = await showLoading( + context: context, + message: "Fetching Spark address", + rootNavigator: Util.isDesktop, + delay: const Duration(milliseconds: 1200), + whileFutureAlt: _future, + onException: (e) => ex = e, + ); + + if (context.mounted) { + if (ex != null) { + await showDialog( + context: context, + builder: (context) => StackOkDialog( + title: "Error", + message: ex + .toString() + .replaceFirst("Exception:", "") + .trim(), + maxWidth: 400, + desktopPopRootNavigator: true, + ), + ); + } else { + Navigator.of(context).pop( + sparkAddress == null + ? null + : Tuple2( + "${ref.read(pWalletName(walletId))} (Spark)", + sparkAddress, + ), + ); + } + } + }, + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + const SizedBox( + width: 12 + 32, // space + size of WalletInfoCoinIcon + ), + Text( + "Transparent", + style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of(context).extension()!.textDark, + ), + ), + const Spacer(), + _BalanceDisplay(walletId: walletId, balanceType: .public), + const SizedBox(width: 80), + CustomTextButton( + text: "Select wallet", + onTap: () async { + final wallet = ref.read(pWallets).getWallet(walletId); + final address = + (await wallet.getCurrentReceivingAddress())?.value ?? + wallet.info.cachedReceivingAddress; + + if (context.mounted) { + Navigator.of( + context, + ).pop(Tuple2("${wallet.info.name} (Transparent)", address)); + } + }, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/pages_desktop_specific/desktop_exchange/subwidgets/desktop_choose_from_stack.dart b/lib/pages_desktop_specific/desktop_exchange/subwidgets/desktop_choose_from_stack.dart deleted file mode 100644 index 1dec08ff86..0000000000 --- a/lib/pages_desktop_specific/desktop_exchange/subwidgets/desktop_choose_from_stack.dart +++ /dev/null @@ -1,312 +0,0 @@ -/* - * This file is part of Stack Wallet. - * - * Copyright (c) 2023 Cypher Stack - * All Rights Reserved. - * The code is distributed under GPLv3 license, see LICENSE file for details. - * Generated by Cypher Stack on 2023-05-26 - * - */ - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/svg.dart'; -import 'package:tuple/tuple.dart'; - -import '../../../app_config.dart'; -import '../../../providers/providers.dart'; -import '../../../themes/stack_colors.dart'; -import '../../../utilities/amount/amount.dart'; -import '../../../utilities/amount/amount_formatter.dart'; -import '../../../utilities/assets.dart'; -import '../../../utilities/constants.dart'; -import '../../../utilities/text_styles.dart'; -import '../../../wallets/crypto_currency/crypto_currency.dart'; -import '../../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../../widgets/custom_buttons/blue_text_button.dart'; -import '../../../widgets/desktop/secondary_button.dart'; -import '../../../widgets/icon_widgets/x_icon.dart'; -import '../../../widgets/rounded_white_container.dart'; -import '../../../widgets/stack_text_field.dart'; -import '../../../widgets/textfield_icon_button.dart'; -import '../../../widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart'; - -class DesktopChooseFromStack extends ConsumerStatefulWidget { - const DesktopChooseFromStack({ - super.key, - required this.coin, - }); - - final CryptoCurrency coin; - - @override - ConsumerState createState() => - _DesktopChooseFromStackState(); -} - -class _DesktopChooseFromStackState - extends ConsumerState { - late final TextEditingController _searchController; - late final FocusNode searchFieldFocusNode; - - String _searchTerm = ""; - - List filter(List walletIds, String searchTerm) { - if (searchTerm.isEmpty) { - return walletIds; - } - - final List result = []; - for (final walletId in walletIds) { - final name = ref.read(pWalletName(walletId)); - - if (name.toLowerCase().contains(searchTerm.toLowerCase())) { - result.add(walletId); - } - } - - return result; - } - - @override - void initState() { - searchFieldFocusNode = FocusNode(); - _searchController = TextEditingController(); - super.initState(); - } - - @override - void dispose() { - _searchController.dispose(); - searchFieldFocusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Choose from ${AppConfig.prefix}", - style: STextStyles.desktopH3(context), - ), - const SizedBox( - height: 28, - ), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - autocorrect: false, - enableSuggestions: false, - controller: _searchController, - focusNode: searchFieldFocusNode, - onChanged: (value) { - setState(() { - _searchTerm = value; - }); - }, - style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .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, - ), - ), - 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 = ""; - _searchTerm = ""; - }); - }, - ), - ], - ), - ), - ) - : null, - ), - ), - ), - const SizedBox( - height: 16, - ), - Flexible( - child: Builder( - builder: (context) { - final wallets = ref - .watch(pWallets) - .wallets - .where((e) => e.info.coin == widget.coin); - - if (wallets.isEmpty) { - return Column( - children: [ - RoundedWhiteContainer( - borderColor: Theme.of(context) - .extension()! - .background, - child: Center( - child: Text( - "No ${widget.coin.ticker.toUpperCase()} wallets", - style: - STextStyles.desktopTextExtraExtraSmall(context), - ), - ), - ), - ], - ); - } - - List walletIds = wallets.map((e) => e.walletId).toList(); - - walletIds = filter(walletIds, _searchTerm); - - return ListView.separated( - primary: false, - itemCount: walletIds.length, - separatorBuilder: (_, __) => const SizedBox( - height: 5, - ), - itemBuilder: (context, index) { - final wallet = ref.watch( - pWallets - .select((value) => value.getWallet(walletIds[index])), - ); - - return RoundedWhiteContainer( - borderColor: - Theme.of(context).extension()!.background, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 14, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Row( - children: [ - WalletInfoCoinIcon(coin: widget.coin), - const SizedBox( - width: 12, - ), - Text( - wallet.info.name, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ), - ), - ], - ), - const Spacer(), - _BalanceDisplay( - walletId: walletIds[index], - ), - const SizedBox( - width: 80, - ), - CustomTextButton( - text: "Select wallet", - onTap: () async { - final address = - (await wallet.getCurrentReceivingAddress()) - ?.value ?? - wallet.info.cachedReceivingAddress; - - if (mounted) { - Navigator.of(context).pop( - Tuple2( - wallet.info.name, - address, - ), - ); - } - }, - ), - ], - ), - ); - }, - ); - }, - ), - ), - const SizedBox( - height: 20, - ), - Row( - children: [ - const Spacer(), - const SizedBox( - width: 16, - ), - Expanded( - child: SecondaryButton( - label: "Cancel", - buttonHeight: ButtonHeight.l, - onPressed: Navigator.of(context).pop, - ), - ), - ], - ), - ], - ); - } -} - -class _BalanceDisplay extends ConsumerWidget { - const _BalanceDisplay({ - super.key, - required this.walletId, - }); - - final String walletId; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final coin = ref.watch(pWalletCoin(walletId)); - Amount total = ref.watch(pWalletBalance(walletId)).total; - if (coin is Firo) { - total += ref.watch(pWalletBalanceSecondary(walletId)).total; - total += ref.watch(pWalletBalanceTertiary(walletId)).total; - } - - return Text( - ref.watch(pAmountFormatter(coin)).format(total), - style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of(context).extension()!.textSubtitle1, - ), - textAlign: TextAlign.right, - ); - } -} diff --git a/lib/pages_desktop_specific/desktop_home_view.dart b/lib/pages_desktop_specific/desktop_home_view.dart index d29aeb4bc9..f61cc94111 100644 --- a/lib/pages_desktop_specific/desktop_home_view.dart +++ b/lib/pages_desktop_specific/desktop_home_view.dart @@ -34,6 +34,7 @@ import 'desktop_menu.dart'; import 'my_stack_view/my_stack_view.dart'; import 'notifications/desktop_notifications_view.dart'; import 'password/desktop_unlock_app_dialog.dart'; +import 'services/desktop_services_view.dart'; import 'settings/desktop_settings_view.dart'; import 'settings/settings_menu/desktop_about_view.dart'; import 'settings/settings_menu/desktop_support_view.dart'; @@ -59,10 +60,8 @@ class _DesktopHomeViewState extends ConsumerState { barrierDismissible: false, context: context, useSafeArea: false, - builder: - (context) => const Background( - child: Center(child: DesktopUnlockAppDialog()), - ), + builder: (context) => + const Background(child: Center(child: DesktopUnlockAppDialog())), ); } } @@ -135,6 +134,11 @@ class _DesktopHomeViewState extends ConsumerState { onGenerateRoute: RouteGenerator.generateRoute, initialRoute: DesktopBuyView.routeName, ), + DesktopMenuItemId.services: const Navigator( + key: Key("desktopServicesHomeKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: DesktopServicesView.routeName, + ), DesktopMenuItemId.notifications: const Navigator( key: Key("desktopNotificationsHomeKey"), onGenerateRoute: RouteGenerator.generateRoute, @@ -201,8 +205,9 @@ class _DesktopHomeViewState extends ConsumerState { if (ref.read(currentDesktopMenuItemProvider.state).state == DesktopMenuItemId.notifications && newKey != DesktopMenuItemId.notifications) { - final Set unreadNotificationIds = - ref.read(unreadNotificationsStateProvider.state).state; + final Set unreadNotificationIds = ref + .read(unreadNotificationsStateProvider.state) + .state; if (unreadNotificationIds.isNotEmpty) { final List> futures = []; @@ -244,12 +249,12 @@ class _DesktopHomeViewState extends ConsumerState { child: IndexedStack( index: ref - .watch(currentDesktopMenuItemProvider.state) - .state - .index > - 0 - ? 1 - : 0, + .watch(currentDesktopMenuItemProvider.state) + .state + .index > + 0 + ? 1 + : 0, children: [ myStackViewNav, contentViews[ref diff --git a/lib/pages_desktop_specific/desktop_menu.dart b/lib/pages_desktop_specific/desktop_menu.dart index c0cbf107f5..7602ca532b 100644 --- a/lib/pages_desktop_specific/desktop_menu.dart +++ b/lib/pages_desktop_specific/desktop_menu.dart @@ -29,6 +29,7 @@ enum DesktopMenuItemId { myStack, exchange, buy, + services, notifications, addressBook, settings, @@ -95,6 +96,7 @@ class _DesktopMenuState extends ConsumerState { DMIController(), DMIController(), DMIController(), + DMIController(), ]; torButtonController = DMIController(); @@ -178,101 +180,124 @@ class _DesktopMenuState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - DesktopMenuItem( - key: const ValueKey('myStack'), - duration: duration, - icon: const DesktopMyStackIcon(), - label: "My ${AppConfig.prefix}", - value: DesktopMenuItemId.myStack, - onChanged: updateSelectedMenuItem, - controller: controllers[0], - isExpandedInitially: !_isMinimized, - ), - if (AppConfig.hasFeature(AppFeature.swap) && - showExchange) ...[ - const SizedBox(height: 2), - DesktopMenuItem( - key: const ValueKey('swap'), - duration: duration, - icon: const DesktopExchangeIcon(), - label: "Swap", - value: DesktopMenuItemId.exchange, - onChanged: updateSelectedMenuItem, - controller: controllers[1], - isExpandedInitially: !_isMinimized, - ), - ], - if (AppConfig.hasFeature(AppFeature.buy) && - showExchange) ...[ - const SizedBox(height: 2), - DesktopMenuItem( - key: const ValueKey('buy'), - duration: duration, - icon: const DesktopBuyIcon(), - label: "Buy crypto", - value: DesktopMenuItemId.buy, - onChanged: updateSelectedMenuItem, - controller: controllers[2], - isExpandedInitially: !_isMinimized, + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DesktopMenuItem( + key: const ValueKey('myStack'), + duration: duration, + icon: const DesktopMyStackIcon(), + label: "My ${AppConfig.prefix}", + value: DesktopMenuItemId.myStack, + onChanged: updateSelectedMenuItem, + controller: controllers[0], + isExpandedInitially: !_isMinimized, + ), + if (AppConfig.hasFeature(AppFeature.swap) && + showExchange) ...[ + const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('swap'), + duration: duration, + icon: const DesktopExchangeIcon(), + label: "Swap", + value: DesktopMenuItemId.exchange, + onChanged: updateSelectedMenuItem, + controller: controllers[1], + isExpandedInitially: !_isMinimized, + ), + ], + if (AppConfig.hasFeature(AppFeature.buy) && + showExchange) ...[ + const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('buy'), + duration: duration, + icon: const DesktopBuyIcon(), + label: "Buy crypto", + value: DesktopMenuItemId.buy, + onChanged: updateSelectedMenuItem, + controller: controllers[2], + isExpandedInitially: !_isMinimized, + ), + ], + if (AppConfig.hasFeature(.shopinBit) || + AppConfig.hasFeature(.cakePay)) ...[ + const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('services'), + duration: duration, + icon: const DesktopServicesIcon(), + label: "Services", + value: DesktopMenuItemId.services, + onChanged: updateSelectedMenuItem, + controller: controllers[3], + isExpandedInitially: !_isMinimized, + ), + ], + const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('notifications'), + duration: duration, + icon: const DesktopNotificationsIcon(), + label: "Notifications", + value: DesktopMenuItemId.notifications, + onChanged: updateSelectedMenuItem, + controller: controllers[4], + isExpandedInitially: !_isMinimized, + ), + const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('addressBook'), + duration: duration, + icon: const DesktopAddressBookIcon(), + label: "Address Book", + value: DesktopMenuItemId.addressBook, + onChanged: updateSelectedMenuItem, + controller: controllers[5], + isExpandedInitially: !_isMinimized, + ), + const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('settings'), + duration: duration, + icon: const DesktopSettingsIcon(), + label: "Settings", + value: DesktopMenuItemId.settings, + onChanged: updateSelectedMenuItem, + controller: controllers[6], + isExpandedInitially: !_isMinimized, + ), + const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('support'), + duration: duration, + icon: const DesktopSupportIcon(), + label: "Support", + value: DesktopMenuItemId.support, + onChanged: updateSelectedMenuItem, + controller: controllers[7], + isExpandedInitially: !_isMinimized, + ), + const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('about'), + duration: duration, + icon: const DesktopAboutIcon(), + label: "About", + value: DesktopMenuItemId.about, + onChanged: updateSelectedMenuItem, + controller: controllers[8], + isExpandedInitially: !_isMinimized, + ), + ], + ), ), - ], - const SizedBox(height: 2), - DesktopMenuItem( - key: const ValueKey('notifications'), - duration: duration, - icon: const DesktopNotificationsIcon(), - label: "Notifications", - value: DesktopMenuItemId.notifications, - onChanged: updateSelectedMenuItem, - controller: controllers[3], - isExpandedInitially: !_isMinimized, - ), - const SizedBox(height: 2), - DesktopMenuItem( - key: const ValueKey('addressBook'), - duration: duration, - icon: const DesktopAddressBookIcon(), - label: "Address Book", - value: DesktopMenuItemId.addressBook, - onChanged: updateSelectedMenuItem, - controller: controllers[4], - isExpandedInitially: !_isMinimized, ), - const SizedBox(height: 2), - DesktopMenuItem( - key: const ValueKey('settings'), - duration: duration, - icon: const DesktopSettingsIcon(), - label: "Settings", - value: DesktopMenuItemId.settings, - onChanged: updateSelectedMenuItem, - controller: controllers[5], - isExpandedInitially: !_isMinimized, - ), - const SizedBox(height: 2), - DesktopMenuItem( - key: const ValueKey('support'), - duration: duration, - icon: const DesktopSupportIcon(), - label: "Support", - value: DesktopMenuItemId.support, - onChanged: updateSelectedMenuItem, - controller: controllers[6], - isExpandedInitially: !_isMinimized, - ), - const SizedBox(height: 2), - DesktopMenuItem( - key: const ValueKey('about'), - duration: duration, - icon: const DesktopAboutIcon(), - label: "About", - value: DesktopMenuItemId.about, - onChanged: updateSelectedMenuItem, - controller: controllers[7], - isExpandedInitially: !_isMinimized, - ), - const Spacer(), - if (!Platform.isIOS) + if (!Platform.isIOS) ...[ + const SizedBox(height: 16), DesktopMenuItem( key: const ValueKey('exit'), duration: duration, @@ -291,9 +316,10 @@ class _DesktopMenuState extends ConsumerState { // SystemNavigator.pop(); // } }, - controller: controllers[8], + controller: controllers[9], isExpandedInitially: !_isMinimized, ), + ], ], ), ), diff --git a/lib/pages_desktop_specific/desktop_menu_item.dart b/lib/pages_desktop_specific/desktop_menu_item.dart index ea0d69a81c..60daf23ea8 100644 --- a/lib/pages_desktop_specific/desktop_menu_item.dart +++ b/lib/pages_desktop_specific/desktop_menu_item.dart @@ -15,7 +15,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../providers/desktop/current_desktop_menu_item.dart'; -import '../providers/global/notifications_provider.dart'; +import '../providers/global/shopin_bit_service_provider.dart'; import '../themes/stack_colors.dart'; import '../themes/theme_providers.dart'; import '../utilities/assets.dart'; @@ -41,13 +41,13 @@ class DesktopMyStackIcon extends ConsumerWidget { Assets.svg.walletDesktop, width: 20, height: 20, - color: DesktopMenuItemId.myStack == + color: + DesktopMenuItemId.myStack == ref.watch(currentDesktopMenuItemProvider.state).state ? Theme.of(context).extension()!.accentColorDark - : Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -61,13 +61,13 @@ class DesktopExchangeIcon extends ConsumerWidget { Assets.svg.exchangeDesktop, width: 20, height: 20, - color: DesktopMenuItemId.exchange == + color: + DesktopMenuItemId.exchange == ref.watch(currentDesktopMenuItemProvider.state).state ? Theme.of(context).extension()!.accentColorDark - : Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -81,13 +81,33 @@ class DesktopBuyIcon extends ConsumerWidget { File(ref.watch(themeAssetsProvider).buy), width: 20, height: 20, - color: DesktopMenuItemId.buy == + color: + DesktopMenuItemId.buy == ref.watch(currentDesktopMenuItemProvider.state).state ? Theme.of(context).extension()!.accentColorDark - : Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), + ); + } +} + +class DesktopServicesIcon extends ConsumerWidget { + const DesktopServicesIcon({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SvgPicture.asset( + Assets.svg.solidSliders, + width: 20, + height: 20, + color: + DesktopMenuItemId.services == + ref.watch(currentDesktopMenuItemProvider.state).state + ? Theme.of(context).extension()!.accentColorDark + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -97,16 +117,11 @@ class DesktopNotificationsIcon extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return ref.watch( - notificationsProvider.select((value) => value.hasUnreadNotifications), - ) + final hasUnread = ref.watch(pAnyGlobalUnreadNotifications); + return hasUnread ? SvgPicture.file( File( - ref.watch( - themeProvider.select( - (value) => value.assets.bellNew, - ), - ), + ref.watch(themeProvider.select((value) => value.assets.bellNew)), ), width: 20, height: 20, @@ -115,20 +130,13 @@ class DesktopNotificationsIcon extends ConsumerWidget { Assets.svg.bell, width: 20, height: 20, - color: ref.watch( - notificationsProvider - .select((value) => value.hasUnreadNotifications), - ) - ? null - : DesktopMenuItemId.notifications == - ref.watch(currentDesktopMenuItemProvider.state).state - ? Theme.of(context) - .extension()! - .accentColorDark - : Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + color: + DesktopMenuItemId.notifications == + ref.watch(currentDesktopMenuItemProvider.state).state + ? Theme.of(context).extension()!.accentColorDark + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -142,13 +150,13 @@ class DesktopAddressBookIcon extends ConsumerWidget { Assets.svg.addressBookDesktop, width: 20, height: 20, - color: DesktopMenuItemId.addressBook == + color: + DesktopMenuItemId.addressBook == ref.watch(currentDesktopMenuItemProvider.state).state ? Theme.of(context).extension()!.accentColorDark - : Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -162,13 +170,13 @@ class DesktopSettingsIcon extends ConsumerWidget { Assets.svg.gear, width: 20, height: 20, - color: DesktopMenuItemId.settings == + color: + DesktopMenuItemId.settings == ref.watch(currentDesktopMenuItemProvider.state).state ? Theme.of(context).extension()!.accentColorDark - : Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -182,13 +190,13 @@ class DesktopSupportIcon extends ConsumerWidget { Assets.svg.messageQuestion, width: 20, height: 20, - color: DesktopMenuItemId.support == + color: + DesktopMenuItemId.support == ref.watch(currentDesktopMenuItemProvider.state).state ? Theme.of(context).extension()!.accentColorDark - : Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -202,13 +210,13 @@ class DesktopAboutIcon extends ConsumerWidget { Assets.svg.aboutDesktop, width: 20, height: 20, - color: DesktopMenuItemId.about == + color: + DesktopMenuItemId.about == ref.watch(currentDesktopMenuItemProvider.state).state ? Theme.of(context).extension()!.accentColorDark - : Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -222,10 +230,9 @@ class DesktopExitIcon extends ConsumerWidget { Assets.svg.exitDesktop, width: 20, height: 20, - color: Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + color: Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -294,10 +301,7 @@ class _DesktopMenuItemState extends ConsumerState> _iconOnly = !widget.isExpandedInitially; controller?.toggle = toggle; - animationController = AnimationController( - vsync: this, - duration: duration, - ); + animationController = AnimationController(vsync: this, duration: duration); if (_iconOnly) { animationController.value = 0; } else { @@ -321,25 +325,20 @@ class _DesktopMenuItemState extends ConsumerState> return TextButton( style: value == group ? Theme.of(context) - .extension()! - .getDesktopMenuButtonStyleSelected(context) - : Theme.of(context) - .extension()! - .getDesktopMenuButtonStyle(context), + .extension()! + .getDesktopMenuButtonStyleSelected(context) + : Theme.of( + context, + ).extension()!.getDesktopMenuButtonStyle(context), onPressed: () { onChanged(value); }, child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 16, - ), + padding: const EdgeInsets.symmetric(vertical: 16), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - AnimatedContainer( - duration: duration, - width: _iconOnly ? 0 : 16, - ), + AnimatedContainer(duration: duration, width: _iconOnly ? 0 : 16), icon, AnimatedOpacity( duration: duration, @@ -352,9 +351,7 @@ class _DesktopMenuItemState extends ConsumerState> width: labelLength, child: Row( children: [ - const SizedBox( - width: 12, - ), + const SizedBox(width: 12), Text( label, style: value == group diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart new file mode 100644 index 0000000000..c8a5a66422 --- /dev/null +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart @@ -0,0 +1,281 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:event_bus/event_bus.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; +import '../../../pages/token_view/solana_token_contract_details_view.dart'; +import '../../../pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart'; +import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/assets.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../wallets/isar/providers/solana/solana_wallet_provider.dart'; +import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../widgets/coin_ticker_tag.dart'; +import '../../../widgets/custom_buttons/blue_text_button.dart'; +import '../../../widgets/desktop/desktop_app_bar.dart'; +import '../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../widgets/desktop/desktop_scaffold.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/dialogs/s_dialog.dart'; +import '../../../widgets/icon_widgets/sol_token_icon.dart'; +import '../../../widgets/rounded_white_container.dart'; +import 'sub_widgets/desktop_wallet_features.dart'; +import 'sub_widgets/desktop_wallet_summary.dart'; +import 'sub_widgets/my_wallet.dart'; + +/// [eventBus] should only be set during testing. +class DesktopSolTokenView extends ConsumerStatefulWidget { + const DesktopSolTokenView({super.key, required this.walletId, this.eventBus}); + + static const String routeName = "/desktopSolTokenView"; + + final String walletId; + final EventBus? eventBus; + + @override + ConsumerState createState() => _DesktopTokenViewState(); +} + +class _DesktopTokenViewState extends ConsumerState { + static const double sendReceiveColumnWidth = 460; + + late final WalletSyncStatus initialSyncStatus; + + @override + void initState() { + // Get the initial sync status from the Solana wallet's refresh mutex. + final solanaWallet = ref.read(pSolanaWallet(widget.walletId)); + initialSyncStatus = solanaWallet?.refreshMutex.isLocked ?? false + ? WalletSyncStatus.syncing + : WalletSyncStatus.synced; + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + return DesktopScaffold( + appBar: DesktopAppBar( + background: Theme.of(context).extension()!.popupBG, + leading: Expanded( + flex: 3, + child: Row( + children: [ + const SizedBox(width: 32), + SecondaryButton( + padding: const EdgeInsets.only(left: 12, right: 18), + buttonHeight: ButtonHeight.s, + label: ref.watch(pWalletName(widget.walletId)), + icon: SvgPicture.asset( + Assets.svg.arrowLeft, + width: 18, + height: 18, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + ), + onPressed: () { + ref.refresh(feeSheetSessionCacheProvider); + Navigator.of(context).pop(); + }, + ), + const SizedBox(width: 15), + ], + ), + ), + center: Expanded( + flex: 4, + child: GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (context) => SDialog( + child: SizedBox( + width: 580, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Token details", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: SolanaTokenContractDetailsView( + tokenMint: ref + .read(pCurrentSolanaTokenWallet)! + .tokenMint, + walletId: widget.walletId, + ), + ), + ], + ), + ), + ), + ); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Row( + children: [ + SolTokenIcon( + mintAddress: ref.watch( + pCurrentSolanaTokenWallet.select( + (value) => value!.tokenMint, + ), + ), + size: 32, + ), + const SizedBox(width: 12), + Text( + ref.watch( + pCurrentSolanaTokenWallet.select( + (value) => value!.tokenName, + ), + ), + style: STextStyles.desktopH3(context), + ), + const SizedBox(width: 12), + CoinTickerTag( + ticker: ref.watch( + pWalletCoin(widget.walletId).select((s) => s.ticker), + ), + ), + ], + ), + ), + ), + ), + useSpacers: false, + isCompactHeight: true, + ), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + RoundedWhiteContainer( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + SolTokenIcon( + mintAddress: ref.watch( + pCurrentSolanaTokenWallet.select( + (value) => value!.tokenMint, + ), + ), + size: 40, + ), + const SizedBox(width: 10), + DesktopWalletSummary( + walletId: widget.walletId, + isToken: true, + initialSyncStatus: initialSyncStatus, + ), + const Spacer(), + DesktopWalletFeatures(walletId: widget.walletId), + ], + ), + ), + const SizedBox(height: 24), + Row( + children: [ + SizedBox( + width: sendReceiveColumnWidth, + child: Text( + "My wallet", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconLeft, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Recent transactions", + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconLeft, + ), + ), + CustomTextButton( + text: "See all", + onTap: () { + // TODO: Navigate to all transactions for this token + // Navigator.of(context).pushNamed( + // AllTransactionsV2View.routeName, + // arguments: ( + // walletId: widget.walletId, + // tokenMint: "TODO_TOKEN_MINT", + // ), + // ); + }, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: sendReceiveColumnWidth, + child: MyWallet( + walletId: widget.walletId, + contractAddress: ref.watch( + pCurrentSolanaTokenWallet.select( + (value) => value!.tokenMint, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: SolanaTokenTransactionsList( + walletId: widget.walletId, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart index 577d4e322a..f112ffe7e1 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart @@ -15,6 +15,7 @@ import 'package:flutter_svg/svg.dart'; import '../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; import '../../../pages/token_view/sub_widgets/token_transaction_list_widget.dart'; +import '../../../pages/token_view/token_contract_details_view.dart'; import '../../../pages/wallet_view/transaction_views/tx_v2/all_transactions_v2_view.dart'; import '../../../providers/providers.dart'; import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; @@ -26,8 +27,10 @@ import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../widgets/coin_ticker_tag.dart'; import '../../../widgets/custom_buttons/blue_text_button.dart'; import '../../../widgets/desktop/desktop_app_bar.dart'; +import '../../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../../widgets/desktop/desktop_scaffold.dart'; import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/dialogs/s_dialog.dart'; import '../../../widgets/icon_widgets/eth_token_icon.dart'; import '../../../widgets/rounded_white_container.dart'; import 'sub_widgets/desktop_wallet_features.dart'; @@ -54,10 +57,9 @@ class _DesktopTokenViewState extends ConsumerState { @override void initState() { - initialSyncStatus = - ref.read(pCurrentTokenWallet)!.refreshMutex.isLocked - ? WalletSyncStatus.syncing - : WalletSyncStatus.synced; + initialSyncStatus = ref.read(pCurrentTokenWallet)!.refreshMutex.isLocked + ? WalletSyncStatus.syncing + : WalletSyncStatus.synced; super.initState(); } @@ -86,10 +88,9 @@ class _DesktopTokenViewState extends ConsumerState { Assets.svg.arrowLeft, width: 18, height: 18, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), onPressed: () { ref.refresh(feeSheetSessionCacheProvider); @@ -102,32 +103,76 @@ class _DesktopTokenViewState extends ConsumerState { ), center: Expanded( flex: 4, - child: Row( - children: [ - EthTokenIcon( - contractAddress: ref.watch( - pCurrentTokenWallet.select( - (value) => value!.tokenContract.address, + child: GestureDetector( + onTap: () { + final contractAddress = ref + .read(pCurrentTokenWallet)! + .tokenContract + .address; + + showDialog( + context: context, + builder: (context) => SDialog( + child: SizedBox( + width: 580, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Token details", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: TokenContractDetailsView( + contractAddress: contractAddress, + walletId: widget.walletId, + ), + ), + ], + ), ), ), - size: 32, - ), - const SizedBox(width: 12), - Text( - ref.watch( - pCurrentTokenWallet.select( - (value) => value!.tokenContract.name, + ); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Row( + children: [ + EthTokenIcon( + contractAddress: ref.watch( + pCurrentTokenWallet.select( + (value) => value!.tokenContract.address, + ), + ), + size: 32, ), - ), - style: STextStyles.desktopH3(context), - ), - const SizedBox(width: 12), - CoinTickerTag( - ticker: ref.watch( - pWalletCoin(widget.walletId).select((s) => s.ticker), - ), + const SizedBox(width: 12), + Text( + ref.watch( + pCurrentTokenWallet.select( + (value) => value!.tokenContract.name, + ), + ), + style: STextStyles.desktopH3(context), + ), + const SizedBox(width: 12), + CoinTickerTag( + ticker: ref.watch( + pWalletCoin(widget.walletId).select((s) => s.ticker), + ), + ), + ], ), - ], + ), ), ), useSpacers: false, @@ -155,12 +200,12 @@ class _DesktopTokenViewState extends ConsumerState { isToken: true, initialSyncStatus: ref - .watch(pWallets) - .getWallet(widget.walletId) - .refreshMutex - .isLocked - ? WalletSyncStatus.syncing - : WalletSyncStatus.synced, + .watch(pWallets) + .getWallet(widget.walletId) + .refreshMutex + .isLocked + ? WalletSyncStatus.syncing + : WalletSyncStatus.synced, ), const Spacer(), DesktopWalletFeatures(walletId: widget.walletId), @@ -175,10 +220,9 @@ class _DesktopTokenViewState extends ConsumerState { child: Text( "My wallet", style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of(context) - .extension()! - .textFieldActiveSearchIconLeft, + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconLeft, ), ), ), @@ -189,14 +233,12 @@ class _DesktopTokenViewState extends ConsumerState { children: [ Text( "Recent transactions", - style: STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of(context) .extension()! .textFieldActiveSearchIconLeft, - ), + ), ), CustomTextButton( text: "See all", diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart index bd6dac13fe..c9a0c50740 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart @@ -13,6 +13,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../../../notifications/show_flush_bar.dart'; import '../../../../pages/add_wallet_views/new_wallet_recovery_phrase_view/sub_widgets/mnemonic_table.dart'; import '../../../../providers/global/secure_store_provider.dart'; @@ -21,6 +22,7 @@ import '../../../../route_generator.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/assets.dart'; import '../../../../utilities/clipboard_interface.dart'; +import '../../../../utilities/logger.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; @@ -52,6 +54,11 @@ class _DeleteWalletKeysPopup extends ConsumerState { late final List _words; late final ClipboardInterface _clipboardInterface; + static const _recoveryPhraseInfo = + "Please write down your recovery phrase in the correct order and save it " + "to keep your funds secure. " + "You will be shown your recovery phrase on the next screen."; + @override void initState() { _walletId = widget.walletId; @@ -72,9 +79,7 @@ class _DeleteWalletKeysPopup extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( - padding: const EdgeInsets.only( - left: 32, - ), + padding: const EdgeInsets.only(left: 32), child: Text( "Wallet keys", style: STextStyles.desktopH3(context), @@ -82,51 +87,37 @@ class _DeleteWalletKeysPopup extends ConsumerState { ), DesktopDialogCloseButton( onPressedOverride: () { - Navigator.of( - context, - rootNavigator: true, - ).pop(); + Navigator.of(context, rootNavigator: true).pop(); }, ), ], ), - const SizedBox( - height: 28, - ), + const SizedBox(height: 28), Text( "Recovery phrase", style: STextStyles.desktopTextMedium(context), ), - const SizedBox( - height: 8, - ), + const SizedBox(height: 8), Center( child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - ), + padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( - "Please write down your recovery phrase in the correct order and " - "save it to keep your funds secure. You will be shown your recovery phrase on the next screen.", + _recoveryPhraseInfo, style: STextStyles.desktopTextExtraExtraSmall(context), textAlign: TextAlign.center, ), ), ), - const SizedBox( - height: 24, - ), + const SizedBox(height: 24), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - ), + padding: const EdgeInsets.symmetric(horizontal: 32), child: RawMaterialButton( hoverColor: Colors.transparent, onPressed: () async { await _clipboardInterface.setData( ClipboardData(text: _words.join(" ")), ); - if (mounted) { + if (context.mounted) { unawaited( showFloatingFlushBar( type: FlushBarType.info, @@ -140,19 +131,15 @@ class _DeleteWalletKeysPopup extends ConsumerState { child: MnemonicTable( words: widget.words, isDesktop: true, - itemBorderColor: Theme.of(context) - .extension()! - .buttonBackSecondary, + itemBorderColor: Theme.of( + context, + ).extension()!.buttonBackSecondary, ), ), ), - const SizedBox( - height: 24, - ), + const SizedBox(height: 24), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - ), + padding: const EdgeInsets.symmetric(horizontal: 32), child: Row( children: [ Expanded( @@ -162,9 +149,7 @@ class _DeleteWalletKeysPopup extends ConsumerState { await Navigator.of(context).push( RouteGenerator.getRoute( builder: (context) { - return ConfirmDelete( - walletId: _walletId, - ); + return ConfirmDelete(walletId: _walletId); }, settings: const RouteSettings( name: "/desktopConfirmDelete", @@ -177,9 +162,7 @@ class _DeleteWalletKeysPopup extends ConsumerState { ], ), ), - const SizedBox( - height: 32, - ), + const SizedBox(height: 32), ], ), ); @@ -187,10 +170,7 @@ class _DeleteWalletKeysPopup extends ConsumerState { } class ConfirmDelete extends ConsumerStatefulWidget { - const ConfirmDelete({ - super.key, - required this.walletId, - }); + const ConfirmDelete({super.key, required this.walletId}); final String walletId; @@ -207,9 +187,7 @@ class _ConfirmDeleteState extends ConsumerState { children: [ const Row( mainAxisAlignment: MainAxisAlignment.end, - children: [ - DesktopDialogCloseButton(), - ], + children: [DesktopDialogCloseButton()], ), Column( crossAxisAlignment: CrossAxisAlignment.center, @@ -238,12 +216,22 @@ class _ConfirmDeleteState extends ConsumerState { buttonHeight: ButtonHeight.xl, label: "Continue", onPressed: () async { - await ref.read(pWallets).deleteWallet( - ref.read(pWalletInfo(widget.walletId)), - ref.read(secureStoreProvider), - ); + try { + await ref + .read(pWallets) + .deleteWallet( + ref.read(pWalletInfo(widget.walletId)), + ref.read(secureStoreProvider), + ); + } catch (e, s) { + Logging.instance.f( + "Wallet deletion errors", + error: e, + stackTrace: s, + ); + } - if (mounted) { + if (context.mounted) { Navigator.of(context, rootNavigator: true).pop(true); } }, diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart index 032a61bf45..17de7cd217 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart @@ -44,6 +44,12 @@ class DesktopAttentionDeleteWallet extends ConsumerStatefulWidget { class _DesktopAttentionDeleteWallet extends ConsumerState { + static const _deleteWarning = + "You are going to permanently delete your wallet.\n\nIf you delete your" + " wallet, the only way you can have access to your funds is by using your" + " backup key.\n\n${AppConfig.appName} does not keep nor is able to " + "restore your backup key or your wallet.\n\nPLEASE SAVE YOUR BACKUP KEY."; + @override Widget build(BuildContext context) { return DesktopDialog( @@ -68,25 +74,19 @@ class _DesktopAttentionDeleteWallet Text("Attention!", style: STextStyles.desktopH2(context)), const SizedBox(height: 16), RoundedContainer( - color: - Theme.of( - context, - ).extension()!.snackBarBackError, + color: Theme.of( + context, + ).extension()!.snackBarBackError, child: Padding( padding: const EdgeInsets.all(10.0), child: Text( - "You are going to permanently delete your wallet.\n\nIf you delete your wallet, " - "the only way you can have access to your funds is by using your backup key." - "\n\n${AppConfig.appName} does not keep nor is able to restore your backup key or your wallet." - "\n\nPLEASE SAVE YOUR BACKUP KEY.", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of( + _deleteWarning, + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( context, ).extension()!.snackBarTextError, - ), + ), ), ), ), @@ -119,51 +119,46 @@ class _DesktopAttentionDeleteWallet if (context.mounted) { await Navigator.of(context).push( MaterialPageRoute( - builder: - (builder) => DesktopDialog( - maxWidth: 614, - maxHeight: double.infinity, - child: Column( + builder: (builder) => DesktopDialog( + maxWidth: 614, + maxHeight: double.infinity, + child: Column( + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, children: [ - Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Padding( - padding: - const EdgeInsets.only( - left: 32, - ), - child: Text( - "Wallet keys", - style: - STextStyles.desktopH3( - context, - ), - ), - ), - DesktopDialogCloseButton( - onPressedOverride: () { - Navigator.of( - context, - rootNavigator: true, - ).pop(); - }, + Padding( + padding: const EdgeInsets.only( + left: 32, + ), + child: Text( + "Wallet keys", + style: STextStyles.desktopH3( + context, ), - ], + ), ), - Padding( - padding: const EdgeInsets.all(32), - child: - DeleteViewOnlyWalletKeysView( - walletId: widget.walletId, - data: data, - ), + DesktopDialogCloseButton( + onPressedOverride: () { + Navigator.of( + context, + rootNavigator: true, + ).pop(); + }, ), ], ), - ), + Padding( + padding: const EdgeInsets.all(32), + child: DeleteViewOnlyWalletKeysView( + walletId: widget.walletId, + data: data, + ), + ), + ], + ), + ), ), ); } @@ -182,7 +177,8 @@ class _DesktopAttentionDeleteWallet } } on BadDecryption catch (e, s) { Logging.instance.f( - "Desktop wallet delete error. Showing decryption error continue dialog.", + "Desktop wallet delete error. " + "Showing decryption error continue dialog.", error: e, stackTrace: s, ); @@ -220,6 +216,10 @@ class ErrorLoadingKeysDialog extends StatelessWidget { final String walletId; + static const _errorInfoExtra = + "Could not retrieve wallet keys/mnemonic phrase/seed.\n\n" + "Are you certain you would like to continue with wallet deletion?"; + @override Widget build(BuildContext context) { return DesktopDialog( @@ -251,8 +251,7 @@ class ErrorLoadingKeysDialog extends StatelessWidget { children: [ RoundedWhiteContainer( child: Text( - "Could not retrieve wallet keys/mnemonic phrase/seed.\n\n" - "Are you certain you would like to continue with wallet deletion?", + _errorInfoExtra, style: STextStyles.label(context).copyWith(fontSize: 16), ), ), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart index c38b33a61b..242e71e8d1 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart @@ -26,12 +26,10 @@ import '../../../../widgets/loading_indicator.dart'; import '../../../../widgets/stack_text_field.dart'; class DesktopAuthSend extends ConsumerStatefulWidget { - const DesktopAuthSend({ - super.key, - required this.coin, - }); + const DesktopAuthSend({super.key, required this.coin, this.tokenTicker}); final CryptoCurrency coin; + final String? tokenTicker; @override ConsumerState createState() => _DesktopAuthSendState(); @@ -59,12 +57,7 @@ class _DesktopAuthSendState extends ConsumerState { builder: (context) => const Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, - children: [ - LoadingIndicator( - width: 200, - height: 200, - ), - ], + children: [LoadingIndicator(width: 200, height: 200)], ), ), ); @@ -77,15 +70,8 @@ class _DesktopAuthSendState extends ConsumerState { if (mounted) { Navigator.of(context).pop(); - Navigator.of( - context, - rootNavigator: true, - ).pop(passwordIsValid); - await Future.delayed( - const Duration( - milliseconds: 100, - ), - ); + Navigator.of(context, rootNavigator: true).pop(passwordIsValid); + await Future.delayed(const Duration(milliseconds: 100)); } } finally { _lock = false; @@ -113,29 +99,17 @@ class _DesktopAuthSendState extends ConsumerState { return Column( mainAxisSize: MainAxisSize.min, children: [ - SvgPicture.asset( - Assets.svg.keys, - width: 100, - ), - const SizedBox( - height: 56, - ), + SvgPicture.asset(Assets.svg.keys, width: 100), + const SizedBox(height: 56), + Text("Confirm transaction", style: STextStyles.desktopH3(context)), + const SizedBox(height: 16), Text( - "Confirm transaction", - style: STextStyles.desktopH3(context), - ), - const SizedBox( - height: 16, - ), - Text( - "Enter your wallet password to send ${widget.coin.ticker.toUpperCase()}", + "Enter your wallet password to send ${widget.tokenTicker?.toUpperCase() ?? widget.coin.ticker.toUpperCase()}", style: STextStyles.desktopTextMedium(context).copyWith( color: Theme.of(context).extension()!.textDark3, ), ), - const SizedBox( - height: 24, - ), + const SizedBox(height: 24), ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -144,9 +118,7 @@ class _DesktopAuthSendState extends ConsumerState { key: const Key("desktopLoginPasswordFieldKey"), focusNode: passwordFocusNode, controller: passwordController, - style: STextStyles.desktopTextMedium(context).copyWith( - height: 2, - ), + style: STextStyles.desktopTextMedium(context).copyWith(height: 2), obscureText: hidePassword, enableSuggestions: false, autocorrect: false, @@ -156,45 +128,44 @@ class _DesktopAuthSendState extends ConsumerState { _confirmPressed(); } }, - decoration: standardInputDecoration( - "Enter password", - passwordFocusNode, - context, - ).copyWith( - suffixIcon: UnconstrainedBox( - child: SizedBox( - height: 70, - child: Row( - children: [ - const SizedBox( - width: 24, - ), - GestureDetector( - key: const Key( - "restoreFromFilePasswordFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: SvgPicture.asset( - hidePassword ? Assets.svg.eye : Assets.svg.eyeSlash, - color: Theme.of(context) - .extension()! - .textDark3, - width: 24, - height: 24, - ), + decoration: + standardInputDecoration( + "Enter password", + passwordFocusNode, + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: SizedBox( + height: 70, + child: Row( + children: [ + const SizedBox(width: 24), + GestureDetector( + key: const Key( + "restoreFromFilePasswordFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of( + context, + ).extension()!.textDark3, + width: 24, + height: 24, + ), + ), + const SizedBox(width: 12), + ], ), - const SizedBox( - width: 12, - ), - ], + ), ), ), - ), - ), onChanged: (newValue) { setState(() { _confirmEnabled = passwordController.text.isNotEmpty; @@ -202,9 +173,7 @@ class _DesktopAuthSendState extends ConsumerState { }, ), ), - const SizedBox( - height: 48, - ), + const SizedBox(height: 48), Row( children: [ Expanded( @@ -214,9 +183,7 @@ class _DesktopAuthSendState extends ConsumerState { onPressed: Navigator.of(context).pop, ), ), - const SizedBox( - width: 16, - ), + const SizedBox(width: 16), Expanded( child: PrimaryButton( enabled: _confirmEnabled, diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart index 607bf3f9c5..eb354eb1e0 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart @@ -22,6 +22,7 @@ import '../../../../models/isar/models/isar_models.dart'; import '../../../../models/keys/view_only_wallet_data.dart'; import '../../../../notifications/show_flush_bar.dart'; import '../../../../pages/receive_view/generate_receiving_uri_qr_code_view.dart'; +import '../../../../pages/receive_view/sub_widgets/epic_slatepack_import_dialog.dart'; import '../../../../pages/receive_view/sub_widgets/mwc_slatepack_import_dialog.dart'; import '../../../../providers/providers.dart'; import '../../../../providers/ui/preview_tx_button_state_provider.dart'; @@ -39,6 +40,7 @@ import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../wallets/wallet/impl/bitcoin_wallet.dart'; +import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; import '../../../../wallets/wallet/intermediate/bip39_hd_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/bcash_interface.dart'; @@ -56,6 +58,7 @@ import '../../../../widgets/desktop/secondary_button.dart'; import '../../../../widgets/dialogs/s_dialog.dart'; import '../../../../widgets/icon_widgets/clipboard_icon.dart'; import '../../../../widgets/icon_widgets/x_icon.dart'; +import '../../../../widgets/epic_txs_method_toggle.dart'; import '../../../../widgets/mwc_txs_method_toggle.dart'; import '../../../../widgets/qr.dart'; import '../../../../widgets/rounded_white_container.dart'; @@ -87,6 +90,7 @@ class _DesktopReceiveState extends ConsumerState { late bool supportsMweb; late final bool showMultiType; late final bool isMimblewimblecoin; + late final bool isEpiccash; late TextEditingController _receiveSlateController; String? _slate; bool _slateToggleFlag = false; @@ -169,6 +173,66 @@ class _DesktopReceiveState extends ConsumerState { } } + Future _onEpicReceiveSlatePressed() async { + final wallet = + ref.read(pWallets).getWallet(walletId) as EpiccashWallet; + + Exception? ex; + final result = await showLoading( + whileFuture: wallet.fullDecodeSlatepack(_receiveSlateController.text), + context: context, + message: "Decoding slatepack...", + rootNavigator: Util.isDesktop, + onException: (e) => ex = e, + ); + + if (result == null || ex != null) { + if (mounted) { + await showDialog( + context: context, + useRootNavigator: true, + builder: (context) => StackOkDialog( + desktopPopRootNavigator: true, + title: "Slatepack receive error", + message: ex?.toString() ?? "Unexpected result without exception", + maxWidth: 400, + ), + ); + } + return; + } + + if (mounted) { + final response = + await showDialog<({String responseSlatepack, bool wasEncrypted})>( + context: context, + builder: (context) => SDialog( + child: SizedBox( + width: 700, + child: EpicSlatepackImportDialog( + walletId: widget.walletId, + clipboard: widget.clipboard, + rawSlatepack: result.raw, + decoded: result.result, + slatepackType: result.type, + ), + ), + ), + ); + + if (mounted && response != null) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => EpicSlatepackResponseDialog( + responseSlatepack: response.responseSlatepack, + wasEncrypted: response.wasEncrypted, + ), + ); + } + } + } + Future generateNewAddress() async { final wallet = ref.read(pWallets).getWallet(walletId); if (wallet is MultiAddressInterface) { @@ -196,7 +260,9 @@ class _DesktopReceiveState extends ConsumerState { final Address? address; if (wallet is Bip39HDWallet && wallet is! BCashInterface) { DerivePathType? type; - if (wallet.isViewOnly && wallet is ExtendedKeysInterface) { + if (wallet.isViewOnly && + wallet is ExtendedKeysInterface && + wallet.viewOnlyType != .spark) { final voData = await wallet.getViewOnlyWalletData() as ExtendedKeysViewOnlyWalletData; @@ -270,10 +336,7 @@ class _DesktopReceiveState extends ConsumerState { ), ); - final address = await wallet.generateNextSparkAddress(); - await ref.read(mainDBProvider).isar.writeTxn(() async { - await ref.read(mainDBProvider).isar.addresses.put(address); - }); + final address = await wallet.generateNextSparkAddress(saveToDB: true); shouldPop = true; @@ -312,6 +375,32 @@ class _DesktopReceiveState extends ConsumerState { } } + StreamSubscription _sub(AddressType type) { + return ref + .read(mainDBProvider) + .isar + .addresses + .where() + .walletIdEqualTo(walletId) + .filter() + .typeEqualTo(type) + .and() + .subTypeEqualTo(AddressSubType.receiving) + .sortByDerivationIndexDesc() + .findFirst() + .asStream() + .listen((event) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _addressMap[type] = + event?.value ?? _addressMap[type] ?? "[No address yet]"; + }); + } + }); + }); + } + @override void initState() { _receiveSlateController = TextEditingController(); @@ -326,6 +415,7 @@ class _DesktopReceiveState extends ConsumerState { wallet.info.isMwebEnabled; isMimblewimblecoin = wallet is MimblewimblecoinWallet; + isEpiccash = wallet is EpiccashWallet; if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { showMultiType = false; @@ -356,7 +446,9 @@ class _DesktopReceiveState extends ConsumerState { } } - if (_walletAddressTypes.length > 1 && wallet is BitcoinWallet) { + if (_walletAddressTypes.length > 1 && + wallet is BitcoinWallet && + !wallet.info.isLegacyAddressesEnabled) { _walletAddressTypes.removeWhere((e) => e == AddressType.p2pkh); } @@ -366,30 +458,7 @@ class _DesktopReceiveState extends ConsumerState { if (showMultiType) { for (final type in _walletAddressTypes) { - _addressSubMap[type] = ref - .read(mainDBProvider) - .isar - .addresses - .where() - .walletIdEqualTo(walletId) - .filter() - .typeEqualTo(type) - .and() - .not() - .subTypeEqualTo(AddressSubType.change) - .sortByDerivationIndexDesc() - .findFirst() - .asStream() - .listen((event) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _addressMap[type] = - event?.value ?? _addressMap[type] ?? "[No address yet]"; - }); - } - }); - }); + _addressSubMap[type] = _sub(type); } } @@ -413,42 +482,39 @@ class _DesktopReceiveState extends ConsumerState { if (prev?.isMwebEnabled != next.isMwebEnabled) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { + const type = AddressType.mweb; setState(() { supportsMweb = next.isMwebEnabled; - if (supportsMweb && - !_walletAddressTypes.contains(AddressType.mweb)) { - _walletAddressTypes.insert(0, AddressType.mweb); - _addressSubMap[AddressType.mweb] = ref - .read(mainDBProvider) - .isar - .addresses - .where() - .walletIdEqualTo(walletId) - .filter() - .typeEqualTo(AddressType.mweb) - .and() - .not() - .subTypeEqualTo(AddressSubType.change) - .sortByDerivationIndexDesc() - .findFirst() - .asStream() - .listen((event) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _addressMap[AddressType.mweb] = - event?.value ?? - _addressMap[AddressType.mweb] ?? - "[No address yet]"; - }); - } - }); - }); + if (supportsMweb && !_walletAddressTypes.contains(type)) { + _walletAddressTypes.insert(0, type); + _addressSubMap[type] = _sub(type); + } else { + _walletAddressTypes.remove(type); + _addressSubMap[type]?.cancel(); + _addressSubMap.remove(type); + } + + if (_currentIndex >= _walletAddressTypes.length) { + _currentIndex = _walletAddressTypes.length - 1; + } + }); + } + }); + } + + if (prev?.isLegacyAddressesEnabled != next.isLegacyAddressesEnabled) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + const type = AddressType.p2pkh; + setState(() { + if (!_walletAddressTypes.contains(type)) { + _walletAddressTypes.insert(0, type); + _addressSubMap[type] = _sub(type); } else { - _walletAddressTypes.remove(AddressType.mweb); - _addressSubMap[AddressType.mweb]?.cancel(); - _addressSubMap.remove(AddressType.mweb); + _walletAddressTypes.remove(type); + _addressSubMap[type]?.cancel(); + _addressSubMap.remove(type); } if (_currentIndex >= _walletAddressTypes.length) { @@ -512,9 +578,36 @@ class _DesktopReceiveState extends ConsumerState { ), ), ), - if (!(isMimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId)))) + if (isEpiccash) + Padding( + padding: const EdgeInsets.all(0), + child: Container( + decoration: BoxDecoration( + color: + Theme.of( + context, + ).extension()?.textFieldDefaultBG ?? + Colors.white, // Fallback color + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + Theme.of( + context, + ).extension()?.backgroundAppBar ?? + Colors.grey, // Fallback color + width: 1, + ), + ), + child: const SizedBox( + height: + 60, // Provide an explicit height to avoid infinite constraints + child: EpicTxsMethodToggle(), + ), + ), + ), + if (!((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) const SizedBox(height: 20), - if (!(isMimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId)))) + if (!((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) ConditionalParent( condition: showMultiType, builder: (child) => Column( @@ -621,7 +714,9 @@ class _DesktopReceiveState extends ConsumerState { Row( children: [ Text( - "Your ${widget.contractAddress == null ? coin.ticker : ref.watch(pCurrentTokenWallet.select((value) => value!.tokenContract.symbol))} address", + // "Your ${widget.contractAddress == null ? coin.ticker : ref.watch(pCurrentTokenWallet.select((value) => value!.tokenContract.symbol))} address", + // TODO [prio=high]: Make the above work for Sol tokens instead of the placeholder below. + "Your ${widget.contractAddress == null ? coin.ticker : "token"} address", style: STextStyles.itemSubtitle(context), ), const Spacer(), @@ -683,7 +778,7 @@ class _DesktopReceiveState extends ConsumerState { label: "Generate new address", ), const SizedBox(height: 20), - if (isMimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId))) + if ((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId))) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -814,14 +909,16 @@ class _DesktopReceiveState extends ConsumerState { // TODO: create transparent button class to account for hover // Conditional logic for 'Submit' button or QR code - if (isMimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId))) + if ((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId))) Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: PrimaryButton( buttonHeight: ButtonHeight.l, label: "Receive Slatepack", enabled: _slateToggleFlag, - onPressed: _slateToggleFlag ? _onReceiveSlatePressed : null, + onPressed: _slateToggleFlag + ? (isEpiccash ? _onEpicReceiveSlatePressed : _onReceiveSlatePressed) + : null, ), ) else diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index c476879db0..5060d2bdb0 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -17,6 +17,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import '../../../../models/epic_slatepack_models.dart'; import '../../../../models/isar/models/blockchain_data/address.dart'; import '../../../../models/isar/models/blockchain_data/utxo.dart'; import '../../../../models/isar/models/contact_entry.dart'; @@ -25,6 +26,7 @@ import '../../../../models/paynym/paynym_account_lite.dart'; import '../../../../models/send_view_auto_fill_data.dart'; import '../../../../pages/send_view/confirm_transaction_view.dart'; import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; +import '../../../../pages/send_view/sub_widgets/epic_slatepack_dialog.dart'; import '../../../../pages/send_view/sub_widgets/mwc_slatepack_dialog.dart'; import '../../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; import '../../../../providers/providers.dart'; @@ -51,8 +53,10 @@ import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../wallets/models/tx_data.dart'; +import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../../../wallets/wallet/impl/firo_wallet.dart'; import '../../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../../../wallets/wallet/impl/salvium_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; @@ -64,6 +68,7 @@ import '../../../../widgets/desktop/primary_button.dart'; import '../../../../widgets/desktop/qr_code_scanner_dialog.dart'; import '../../../../widgets/desktop/secondary_button.dart'; import '../../../../widgets/dialogs/firo_exchange_address_dialog.dart'; +import '../../../../widgets/epic_txs_method_toggle.dart'; import '../../../../widgets/eth_fee_form.dart'; import '../../../../widgets/icon_widgets/addressbook_icon.dart'; import '../../../../widgets/icon_widgets/clipboard_icon.dart'; @@ -116,8 +121,9 @@ class _DesktopSendState extends ConsumerState { final _memoFocus = FocusNode(); final _nonceFocusNode = FocusNode(); - late final bool isStellar; + late final bool hasOptionalMemo; late final bool isMimblewimblecoin; + late final bool isEpiccash; String? _note; String? _onChainNote; @@ -292,6 +298,131 @@ class _DesktopSendState extends ConsumerState { } } + /// Handle Epic Cash slate creation for desktop. + Future _handleDesktopEpicSlatepackCreation( + EpiccashWallet wallet, + ) async { + try { + final amount = ref.read(pSendAmount)!; + + Future wrappedFutureWithDelay() async { + await Future.delayed(const Duration(seconds: 1)); + return wallet.createSlatepack( + amount: amount, + recipientAddress: null, // No specific recipient for manual slatepack. + message: _onChainNote?.isNotEmpty == true ? _onChainNote : null, + ); + } + + // Create slatepack. + Exception? ex; + final slatepackResult = await showLoading( + whileFuture: wrappedFutureWithDelay(), + context: context, + rootNavigator: true, + message: "Building slate...", + delay: const Duration(seconds: 2), + onException: (e) => ex = e, + ); + + if (slatepackResult == null || + !slatepackResult.success || + slatepackResult.slatepack == null || + ex != null) { + String error = + ex?.toString() ?? + slatepackResult?.error ?? + 'Failed to create slate'; + if (error.startsWith("Exception:")) { + error = error.replaceFirst("Exception:", "").trim(); + } + throw Exception(error); + } + + // refresh asap to show the pending slate tx in history + unawaited(() async { + await Future.delayed(Duration.zero); + await wallet.refresh(); + }()); + + // Show slatepack dialog. + if (mounted) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => DesktopDialog( + maxHeight: double.infinity, + maxWidth: 700, + child: EpicSlatepackDialog(slatepackResult: slatepackResult), + ), + ); + + // Clear form after slatepack dialog is closed. + clearSendForm(); + } + } catch (e, s) { + Logging.instance.e( + 'Failed to create Epic Cash slate on desktop', + error: e, + stackTrace: s, + ); + + if (mounted) { + await showDialog( + context: context, + builder: (context) => DesktopDialog( + maxWidth: 450, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.only(left: 32, bottom: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Slate Creation Failed', + style: STextStyles.desktopH3(context), + ), + const DesktopDialogCloseButton(), + ], + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.only(right: 32), + child: Text( + 'Failed to create slate: $e', + textAlign: TextAlign.left, + style: STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith(fontSize: 18), + ), + ), + const SizedBox(height: 40), + Padding( + padding: const EdgeInsets.only(right: 32), + child: Row( + children: [ + Expanded( + child: PrimaryButton( + buttonHeight: ButtonHeight.l, + label: 'OK', + onPressed: () => Navigator.of(context).pop(), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + } + } + Future previewSend() async { final wallet = ref.read(pWallets).getWallet(walletId); @@ -301,6 +432,12 @@ class _DesktopSendState extends ConsumerState { return; } + // Handle Epic Cash slatepack transactions directly. + if (isEpiccash && ref.read(pIsSlatepack(widget.walletId))) { + await _handleDesktopEpicSlatepackCreation(wallet as EpiccashWallet); + return; + } + final Amount amount = ref.read(pSendAmount)!; final Amount availableBalance; if (coin is Firo || ref.read(pWalletInfo(walletId)).isMwebEnabled) { @@ -322,7 +459,9 @@ class _DesktopSendState extends ConsumerState { .read(prefsChangeNotifierProvider) .enableCoinControl; - if (!(wallet is CoinControlInterface && coinControlEnabled) || + if (!(wallet is CoinControlInterface && + wallet is! SalviumWallet && + coinControlEnabled) || (coinControlEnabled && ref.read(desktopUseUTXOs).isEmpty)) { // confirm send all if (amount == availableBalance) { @@ -461,6 +600,7 @@ class _DesktopSendState extends ConsumerState { feeRateType: feeRate, utxos: (wallet is CoinControlInterface && + wallet is! SalviumWallet && coinControlEnabled && ref.read(pDesktopUseUTXOs).isNotEmpty) ? ref.read(pDesktopUseUTXOs) @@ -510,6 +650,7 @@ class _DesktopSendState extends ConsumerState { ref.read(pDesktopUseUTXOs).isNotEmpty) ? ref.read(pDesktopUseUTXOs) : null, + opReturnData: ref.read(pOpReturnData), ), ); } @@ -569,7 +710,7 @@ class _DesktopSendState extends ConsumerState { ), ); } else { - final memo = isStellar ? memoController.text : null; + final memo = hasOptionalMemo ? memoController.text : null; txDataFuture = wallet.prepareSend( txData: TxData( recipients: [ @@ -588,6 +729,7 @@ class _DesktopSendState extends ConsumerState { : null, utxos: (wallet is CoinControlInterface && + wallet is! SalviumWallet && coinControlEnabled && ref.read(pDesktopUseUTXOs).isNotEmpty) ? ref.read(pDesktopUseUTXOs) @@ -705,6 +847,9 @@ class _DesktopSendState extends ConsumerState { } void clearSendForm() { + if (!mounted) { + return; + } sendToController.text = ""; cryptoAmountController.text = ""; baseAmountController.text = ""; @@ -712,9 +857,15 @@ class _DesktopSendState extends ConsumerState { nonceController.text = ""; _address = ""; _addressToggleFlag = false; - if (mounted) { - setState(() {}); + _setOpReturnData(null); + setState(() {}); + } + + void _setOpReturnData(String? data) { + if (!mounted) { + return; } + ref.read(pOpReturnData.notifier).state = data; } void _cryptoAmountChanged() async { @@ -779,8 +930,10 @@ class _DesktopSendState extends ConsumerState { if (paymentData != null && paymentData.coin?.uriScheme == coin.uriScheme) { + _setOpReturnData(paymentData.additionalParams['op_return']); _applyUri(paymentData); } else { + _setOpReturnData(null); _address = qrCodeData.split("\n").first.trim(); sendToController.text = _address ?? ""; @@ -909,8 +1062,10 @@ class _DesktopSendState extends ConsumerState { ); if (paymentData != null && paymentData.coin?.uriScheme == coin.uriScheme) { + _setOpReturnData(paymentData.additionalParams['op_return']); _applyUri(paymentData); } else { + _setOpReturnData(null); if (coin is Epiccash) { content = AddressUtils().formatEpicCashAddress(content); } @@ -927,6 +1082,7 @@ class _DesktopSendState extends ConsumerState { }); } } catch (e) { + _setOpReturnData(null); // If parsing fails, treat it as a plain address. if (coin is Epiccash) { // strip http:// and https:// if content contains @ @@ -1076,8 +1232,9 @@ class _DesktopSendState extends ConsumerState { coin = ref.read(pWalletInfo(walletId)).coin; clipboard = widget.clipboard; - isStellar = coin is Stellar; + hasOptionalMemo = coin is Stellar || coin is Solana; isMimblewimblecoin = coin is Mimblewimblecoin; + isEpiccash = coin is Epiccash; sendToController = TextEditingController(); cryptoAmountController = TextEditingController(); @@ -1089,12 +1246,27 @@ class _DesktopSendState extends ConsumerState { cryptoAmountController.addListener(onCryptoAmountChanged); if (_data != null) { - if (_data.amount != null) { - cryptoAmountController.text = _data.amount!.toString(); + final hasAmount = _data.amount != null; + if (hasAmount) { + _cryptoAmountChangeLock = true; + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .format( + _data.amount!.toAmount(fractionDigits: coin.fractionDigits), + withUnitName: false, + ); + _cryptoAmountChangeLock = false; } sendToController.text = _data.contactLabel; _address = _data.address; _addressToggleFlag = true; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (hasAmount) { + _cryptoAmountChanged(); + } + _setValidAddressProviders(_address); + }); } if (isPaynymSend) { @@ -1214,6 +1386,7 @@ class _DesktopSendState extends ConsumerState { ), ) && ref.watch(pWallets).getWallet(walletId) is CoinControlInterface && + ref.watch(pWallets).getWallet(walletId) is! SalviumWallet && (showPrivateBalance ? balType == BalanceType.public : true); return Column( @@ -1249,6 +1422,34 @@ class _DesktopSendState extends ConsumerState { ), ), + if (isEpiccash) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Container( + decoration: BoxDecoration( + color: + Theme.of( + context, + ).extension()?.textFieldDefaultBG ?? + Colors.white, // Fallback color + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + Theme.of( + context, + ).extension()?.backgroundAppBar ?? + Colors.grey, // Fallback color + width: 1, + ), + ), + child: const SizedBox( + height: + 60, // Provide an explicit height to avoid infinite constraints + child: EpicTxsMethodToggle(), + ), + ), + ), + if (coin is Firo) Text( "Send from", @@ -1540,7 +1741,8 @@ class _DesktopSendState extends ConsumerState { ), const SizedBox(height: 20), if (!isPaynymSend && - !(isMimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId)))) + !((isMimblewimblecoin || isEpiccash) && + ref.watch(pIsSlatepack(widget.walletId)))) Text( "Send to", style: STextStyles.desktopTextExtraSmall(context).copyWith( @@ -1551,10 +1753,12 @@ class _DesktopSendState extends ConsumerState { textAlign: TextAlign.left, ), if (!isPaynymSend && - !(isMimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId)))) + !((isMimblewimblecoin || isEpiccash) && + ref.watch(pIsSlatepack(widget.walletId)))) const SizedBox(height: 10), if (!isPaynymSend && - !(isMimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId)))) + !((isMimblewimblecoin || isEpiccash) && + ref.watch(pIsSlatepack(widget.walletId)))) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -1580,17 +1784,21 @@ class _DesktopSendState extends ConsumerState { onChanged: (newValue) async { final trimmed = newValue; - if ((trimmed.length - (_address?.length ?? 0)).abs() > 1) { + if ((trimmed.length - (_address?.length ?? 0)).abs() > 1 || + trimmed.contains(':')) { final parsed = AddressUtils.parsePaymentUri( trimmed, logging: Logging.instance, ); if (parsed != null) { + _setOpReturnData(parsed.additionalParams['op_return']); _applyUri(parsed); } else { + _setOpReturnData(null); await _checkSparkNameAndOrSetAddress(newValue); } } else { + _setOpReturnData(null); await _checkSparkNameAndOrSetAddress( newValue, setController: false, @@ -1641,6 +1849,7 @@ class _DesktopSendState extends ConsumerState { onTap: () { sendToController.text = ""; _address = ""; + _setOpReturnData(null); _setValidAddressProviders(_address); setState(() { _addressToggleFlag = false; @@ -1703,6 +1912,7 @@ class _DesktopSendState extends ConsumerState { ); if (entry != null) { + _setOpReturnData(null); sendToController.text = entry.other ?? entry.label; @@ -1733,7 +1943,8 @@ class _DesktopSendState extends ConsumerState { ), ), if (!isPaynymSend && - !(isMimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId)))) + !((isMimblewimblecoin || isEpiccash) && + ref.watch(pIsSlatepack(widget.walletId)))) Builder( builder: (_) { final String? error; @@ -1752,9 +1963,9 @@ class _DesktopSendState extends ConsumerState { } else { if (_data != null && _data.contactLabel == _address) { error = null; - } else if (coin is Mimblewimblecoin && + } else if ((coin is Mimblewimblecoin || coin is Epiccash) && ref.watch(pIsSlatepack(widget.walletId))) { - // For MWC slatepack transactions, address validation is not required. + // For MWC/Epic slatepack transactions, address validation is not required. // TODO: When implementing encrypted slatepacks, address validation will be required. error = null; } else if (!ref.watch(pValidSendToAddress)) { @@ -1785,9 +1996,69 @@ class _DesktopSendState extends ConsumerState { } }, ), - if (isStellar || ref.watch(pValidSparkSendToAddress)) + // OP_RETURN metadata info (green, public mode only, with tooltip) + Builder( + builder: (context) { + final opData = ref.watch(pOpReturnData); + final balType = ref.watch(publicPrivateBalanceStateProvider); + if (opData == null || + opData.isEmpty || + balType != BalanceType.public) { + return Container(); + } + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(left: 12.0, top: 4.0), + child: Tooltip( + message: AddressUtils.formatOpReturnTooltip(opData), + child: Text( + "Transaction includes metadata " + "(${opData.length ~/ 2} bytes)", + textAlign: TextAlign.left, + style: STextStyles.label(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorGreen, + ), + ), + ), + ), + ); + }, + ), + // OP_RETURN bridge warning (red, private mode only) + Builder( + builder: (context) { + final opData = ref.watch(pOpReturnData); + final balType = ref.watch(publicPrivateBalanceStateProvider); + if (opData == null || + opData.isEmpty || + balType != BalanceType.private) { + return Container(); + } + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(left: 12.0, top: 4.0), + child: Text( + "Bridge data detected but Spark (private) transactions " + "cannot carry OP_RETURN data. Switch to public balance " + "to complete the bridge transaction.", + textAlign: TextAlign.left, + style: STextStyles.label(context).copyWith( + color: Theme.of( + context, + ).extension()!.textError, + ), + ), + ), + ); + }, + ), + if (hasOptionalMemo || ref.watch(pValidSparkSendToAddress)) const SizedBox(height: 10), - if (isStellar || ref.watch(pValidSparkSendToAddress)) + if (hasOptionalMemo || ref.watch(pValidSparkSendToAddress)) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart index 462262d9cd..b1e2b468e1 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart @@ -3,25 +3,28 @@ 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/preview_tx_button_state_provider.dart'; import '../../../../providers/wallet/desktop_fee_providers.dart'; import '../../../../providers/wallet/public_private_balance_state_provider.dart'; import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/address_utils.dart'; import '../../../../utilities/amount/amount.dart'; import '../../../../utilities/enums/fee_rate_type_enum.dart'; import '../../../../utilities/eth_commons.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/crypto_currency/interfaces/electrumx_currency_interface.dart'; +import '../../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; import '../../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../wallets/wallet/impl/firo_wallet.dart'; +import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../widgets/animated_text.dart'; import '../../../../widgets/conditional_parent.dart'; import '../../../../widgets/custom_buttons/blue_text_button.dart'; import '../../../../widgets/desktop/desktop_fee_dialog.dart'; import '../../../../widgets/eth_fee_form.dart'; import '../../../../widgets/fee_slider.dart'; -import '../../../../wl_gen/interfaces/cs_monero_interface.dart'; class DesktopSendFeeForm extends ConsumerStatefulWidget { const DesktopSendFeeForm({ @@ -66,6 +69,33 @@ class _DesktopSendFeeFormState extends ConsumerState { (FeeRateType, String?, String?)? feeSelectionResult; + Amount _addFiroOpReturnFee({ + required Amount fee, + required BigInt feeRate, + required FiroWallet wallet, + }) { + final opReturnData = ref.read(pOpReturnData); + if (opReturnData == null || + opReturnData.isEmpty || + ref.read(publicPrivateBalanceStateProvider) != BalanceType.public) { + return fee; + } + + final extraOutputVSize = AddressUtils.opReturnOutputVSizeFromHex( + opReturnData, + ); + final extraFee = wallet.estimateTxFee( + vSize: extraOutputVSize, + feeRatePerKB: feeRate, + ); + + return fee + + Amount( + rawValue: BigInt.from(extraFee), + fractionDigits: cryptoCurrency.fractionDigits, + ); + } + @override void initState() { super.initState(); @@ -76,6 +106,7 @@ class _DesktopSendFeeFormState extends ConsumerState { Widget build(BuildContext context) { final canEditFees = isEth || + cryptoCurrency is Solana || (cryptoCurrency is ElectrumXCurrencyInterface && !(((cryptoCurrency is Firo) && (ref.watch(publicPrivateBalanceStateProvider.state).state == @@ -154,6 +185,30 @@ class _DesktopSendFeeFormState extends ConsumerState { required BigInt feeRate, required CryptoCurrency coin, }) async { + if (!widget.isToken && + coin is Firo && + ref.read( + publicPrivateBalanceStateProvider, + ) == + BalanceType.public && + (ref.read(pOpReturnData)?.isNotEmpty ?? + false)) { + final wallet = + ref + .read(pWallets) + .getWallet(widget.walletId) + as FiroWallet; + final fee = await wallet.estimateFeeFor( + amount, + feeRate, + ); + return _addFiroOpReturnFee( + fee: fee, + feeRate: feeRate, + wallet: wallet, + ); + } + if (ref .read( widget.isToken @@ -167,11 +222,12 @@ class _DesktopSendFeeFormState extends ConsumerState { .read(pWallets) .getWallet(widget.walletId); - if (coin is Monero || coin is Wownero) { + if (coin is CryptonoteCurrency) { final fee = await wallet.estimateFeeFor( amount, BigInt.from( - csMonero.getTxPriorityMedium(), + (wallet as CryptonoteWallet) + .getTxPriorityMedium(), ), ); ref @@ -209,15 +265,25 @@ class _DesktopSendFeeFormState extends ConsumerState { .estimateFeeFor(amount, feeRate); } } else { - final tokenWallet = ref.read( - pCurrentTokenWallet, - )!; - final fee = await tokenWallet - .estimateFeeFor(amount, feeRate); - ref - .read(tokenFeeSessionCacheProvider) - .average[amount] = - fee; + // Token fee estimation (works for ERC20 and SOL tokens). + try { + final tokenWallet = ref.read( + pCurrentTokenWallet, + )!; + final fee = await tokenWallet + .estimateFeeFor(amount, feeRate); + ref + .read( + tokenFeeSessionCacheProvider, + ) + .average[amount] = + fee; + } catch (_) { + // Token wallet not available. + debugPrint( + "Token fee estimation not available", + ); + } } } return ref diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart new file mode 100644 index 0000000000..cd4e227a51 --- /dev/null +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart @@ -0,0 +1,1119 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../models/isar/models/contact_entry.dart'; +import '../../../../models/paynym/paynym_account_lite.dart'; +import '../../../../models/send_view_auto_fill_data.dart'; +import '../../../../pages/send_view/confirm_transaction_view.dart'; +import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; +import '../../../../providers/providers.dart'; +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_input_formatter.dart'; +import '../../../../utilities/clipboard_interface.dart'; +import '../../../../utilities/constants.dart'; +import '../../../../utilities/logger.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../utilities/util.dart'; +import '../../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; +import '../../../../wallets/models/tx_data.dart'; +import '../../../../widgets/custom_buttons/blue_text_button.dart'; +import '../../../../widgets/desktop/desktop_dialog.dart'; +import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../../widgets/desktop/primary_button.dart'; +import '../../../../widgets/desktop/qr_code_scanner_dialog.dart'; +import '../../../../widgets/desktop/secondary_button.dart'; +import '../../../../widgets/icon_widgets/addressbook_icon.dart'; +import '../../../../widgets/icon_widgets/clipboard_icon.dart'; +import '../../../../widgets/icon_widgets/x_icon.dart'; +import '../../../../widgets/stack_text_field.dart'; +import '../../../../widgets/textfield_icon_button.dart'; +import '../../../desktop_home_view.dart'; +import 'address_book_address_chooser/address_book_address_chooser.dart'; + +class DesktopSolTokenSend extends ConsumerStatefulWidget { + const DesktopSolTokenSend({ + super.key, + required this.walletId, + this.autoFillData, + this.clipboard = const ClipboardWrapper(), + + this.accountLite, + }); + + final String walletId; + final SendViewAutoFillData? autoFillData; + final ClipboardInterface clipboard; + final PaynymAccountLite? accountLite; + + @override + ConsumerState createState() => + _DesktopSolTokenSendState(); +} + +class _DesktopSolTokenSendState extends ConsumerState { + late final String walletId; + late final CryptoCurrency coin; + late final ClipboardInterface clipboard; + + late TextEditingController sendToController; + late TextEditingController cryptoAmountController; + late TextEditingController baseAmountController; + late TextEditingController memoController; + + late final SendViewAutoFillData? _data; + + final _addressFocusNode = FocusNode(); + final _cryptoFocus = FocusNode(); + final _baseFocus = FocusNode(); + final _memoFocusNode = FocusNode(); + + String? _note; + + Amount? _amountToSend; + Amount? _cachedAmountToSend; + String? _address; + + bool _addressToggleFlag = false; + + bool _cryptoAmountChangeLock = false; + late VoidCallback onCryptoAmountChanged; + + Future pasteMemo() async { + if (memoController.text.isNotEmpty) { + setState(() { + memoController.text = ""; + }); + } else { + final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null && data!.text!.isNotEmpty) { + final String content = data.text!.trim(); + + setState(() { + memoController.text = content; + }); + } + } + } + + Future previewSend() async { + final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!; + + final Amount amount = _amountToSend!; + + // Get the current balance from the database. + final balance = ref.read( + pSolanaTokenBalance(( + walletId: walletId, + tokenMint: tokenWallet.tokenMint, + )), + ); + + final availableBalance = balance.spendable; + + // confirm send all + if (amount == availableBalance) { + final bool? shouldSendAll = await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return DesktopDialog( + maxWidth: 450, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.only(left: 32, bottom: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Confirm send all", + style: STextStyles.desktopH3(context), + ), + const DesktopDialogCloseButton(), + ], + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.only(right: 32), + 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), + ), + ), + const SizedBox(height: 40), + Padding( + padding: const EdgeInsets.only(right: 32), + child: Row( + children: [ + Expanded( + child: SecondaryButton( + buttonHeight: ButtonHeight.l, + label: "Cancel", + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + buttonHeight: ButtonHeight.l, + label: "Yes", + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + + if (shouldSendAll == null || shouldSendAll == false) { + // cancel preview + return; + } + } + + try { + bool wasCancelled = false; + + if (mounted) { + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return DesktopDialog( + maxWidth: 400, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.all(32), + child: BuildingTransactionDialog( + coin: tokenWallet.cryptoCurrency, + isSpark: false, + onCancel: () { + wasCancelled = true; + + Navigator.of(context).pop(); + }, + ), + ), + ); + }, + ), + ); + } + + final time = Future.delayed(const Duration(milliseconds: 2500)); + + TxData txData; + Future txDataFuture; + + final tokenSymbol = tokenWallet.tokenSymbol; + final tokenMint = tokenWallet.tokenMint; + final tokenDecimals = tokenWallet.tokenDecimals; + final memo = memoController.text.isEmpty ? null : memoController.text; + + txDataFuture = tokenWallet.prepareSend( + txData: TxData( + recipients: [ + TxRecipient( + address: _address!, + amount: amount, + isChange: false, + addressType: tokenWallet.cryptoCurrency.getAddressType( + _address!, + )!, + ), + ], + memo: memo, + ), + ); + + final results = await Future.wait([txDataFuture, time]); + + txData = results.first as TxData; + + if (!wasCancelled && mounted) { + txData = txData.copyWith(note: _note ?? ""); + + // pop building dialog + Navigator.of(context, rootNavigator: true).pop(); + + unawaited( + showDialog( + context: context, + builder: (context) => DesktopDialog( + maxHeight: MediaQuery.of(context).size.height - 64, + maxWidth: 580, + child: ConfirmTransactionView( + txData: txData, + walletId: walletId, + onSuccess: clearSendForm, + isTokenTx: true, + routeOnSuccessName: DesktopHomeView.routeName, + ), + ), + ), + ); + } + } catch (e) { + if (mounted) { + // pop building dialog + Navigator.of(context, rootNavigator: true).pop(); + + unawaited( + showDialog( + context: context, + builder: (context) { + return DesktopDialog( + maxWidth: 450, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.only(left: 32, bottom: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Transaction failed", + style: STextStyles.desktopH3(context), + ), + const DesktopDialogCloseButton(), + ], + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.only(right: 32), + child: SelectableText( + e.toString(), + textAlign: TextAlign.left, + style: STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith(fontSize: 18), + ), + ), + const SizedBox(height: 40), + Row( + children: [ + Expanded( + child: SecondaryButton( + buttonHeight: ButtonHeight.l, + label: "Ok", + onPressed: () { + Navigator.of( + context, + rootNavigator: true, + ).pop(); + }, + ), + ), + const SizedBox(width: 32), + ], + ), + ], + ), + ), + ); + }, + ), + ); + } + } + } + + void clearSendForm() { + sendToController.text = ""; + cryptoAmountController.text = ""; + baseAmountController.text = ""; + memoController.text = ""; + _address = ""; + _addressToggleFlag = false; + if (mounted) { + setState(() {}); + } + } + + void _cryptoAmountChanged() async { + if (!_cryptoAmountChangeLock) { + // Get the token's decimal places for proper amount parsing + final tokenDecimals = ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals; + + 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; + } + } + } catch (e) { + // Probably an invalid decimal input. + _amountToSend = null; + _cachedAmountToSend = null; + baseAmountController.text = ""; + } + } else { + _amountToSend = null; + _cachedAmountToSend = null; + baseAmountController.text = ""; + } + + _updatePreviewButtonState(_address, _amountToSend); + } + } + + String? _updateInvalidAddressText(String address) { + if (_data != null && _data!.contactLabel == address) { + return null; + } + if (address.isNotEmpty && + !ref + .read(pWallets) + .getWallet(walletId) + .cryptoCurrency + .validateAddress(address)) { + return "Invalid address"; + } + return null; + } + + void _updatePreviewButtonState(String? address, Amount? amount) { + final wallet = ref.read(pWallets).getWallet(walletId); + + final isValidAddress = wallet.cryptoCurrency.validateAddress(address ?? ""); + ref.read(previewTokenTxButtonStateProvider.state).state = + (isValidAddress && amount != null && amount > Amount.zero); + } + + Future scanQr() async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + + final qrResult = await showDialog( + context: context, + builder: (context) => const QrCodeScannerDialog(), + ); + + if (qrResult == null) { + Logging.instance.w("Qr scanning cancelled"); + return; + } + + Logging.instance.d("qrResult content: $qrResult"); + + final paymentData = AddressUtils.parsePaymentUri( + qrResult, + logging: Logging.instance, + ); + + Logging.instance.d("qrResult parsed: $paymentData"); + + if (paymentData != null && + paymentData.coin?.uriScheme == coin.uriScheme) { + // auto fill address + _address = paymentData.address.trim(); + sendToController.text = _address!; + + // autofill notes field + if (paymentData.message != null) { + _note = paymentData.message!; + } else if (paymentData.label != null) { + _note = paymentData.label!; + } + + // autofill amount field + if (paymentData.amount != null) { + final Amount amount = Decimal.parse(paymentData.amount!).toAmount( + fractionDigits: ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals, + ); + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .format(amount, withUnitName: false); + + _amountToSend = amount; + } + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + + // now check for non standard encoded basic address + } else { + _address = qrResult.split("\n").first.trim(); + sendToController.text = _address ?? ""; + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + } on PlatformException catch (e, s) { + // here we ignore the exception caused by not giving permission + // to use the camera to scan a qr code + Logging.instance.w( + "Failed to get camera permissions while trying to scan qr code in SendView: ", + error: e, + stackTrace: s, + ); + } + } + + Future pasteAddress() async { + final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null && data!.text!.isNotEmpty) { + String content = data.text!.trim(); + if (content.contains("\n")) { + content = content.substring(0, content.indexOf("\n")); + } + + sendToController.text = content; + _address = content; + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + } + + void fiatTextFieldOnChanged(String baseAmountString) { + final int tokenDecimals = ref + .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 Decimal? _price = ref + .read(priceAnd24hChangeNotifierProvider) + .getTokenPrice(ref.read(pCurrentSolanaTokenWallet)!.tokenMint) + ?.value; + + if (_price == null || _price == Decimal.zero) { + _amountToSend = Decimal.zero.toAmount(fractionDigits: tokenDecimals); + } else { + _amountToSend = baseAmount <= Amount.zero + ? Decimal.zero.toAmount(fractionDigits: tokenDecimals) + : (baseAmount.decimal / _price) + .toDecimal(scaleOnInfinitePrecision: tokenDecimals) + .toAmount(fractionDigits: tokenDecimals); + } + if (_cachedAmountToSend != null && _cachedAmountToSend == _amountToSend) { + return; + } + _cachedAmountToSend = _amountToSend; + + final amountString = ref + .read(pAmountFormatter(coin)) + .format(_amountToSend!, withUnitName: false); + + _cryptoAmountChangeLock = true; + cryptoAmountController.text = amountString; + _cryptoAmountChangeLock = false; + } else { + _amountToSend = Decimal.zero.toAmount(fractionDigits: tokenDecimals); + _cryptoAmountChangeLock = true; + cryptoAmountController.text = ""; + _cryptoAmountChangeLock = false; + } + + _updatePreviewButtonState(_address, _amountToSend); + } + + Future sendAllTapped() async { + final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!; + final balance = ref.read( + pSolanaTokenBalance(( + walletId: walletId, + tokenMint: tokenWallet.tokenMint, + )), + ); + + cryptoAmountController.text = balance.spendable.decimal.toStringAsFixed( + tokenWallet.tokenDecimals, + ); + } + + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) { + // ref.refresh(tokenFeeSessionCacheProvider); // Ethereum-specific + ref.read(previewTokenTxButtonStateProvider.state).state = false; + }); + + // _calculateFeesFuture = calculateFees(0); + _data = widget.autoFillData; + walletId = widget.walletId; + final wallet = ref.read(pWallets).getWallet(walletId); + coin = wallet.info.coin; + clipboard = widget.clipboard; + + sendToController = TextEditingController(); + cryptoAmountController = TextEditingController(); + baseAmountController = TextEditingController(); + memoController = TextEditingController(); + // feeController = TextEditingController(); + + onCryptoAmountChanged = _cryptoAmountChanged; + cryptoAmountController.addListener(onCryptoAmountChanged); + + if (_data != null) { + if (_data!.amount != null) { + cryptoAmountController.text = _data!.amount!.toString(); + } + sendToController.text = _data!.contactLabel; + _address = _data!.address; + _addressToggleFlag = true; + } + + super.initState(); + } + + @override + void dispose() { + cryptoAmountController.removeListener(onCryptoAmountChanged); + + sendToController.dispose(); + cryptoAmountController.dispose(); + baseAmountController.dispose(); + memoController.dispose(); + // feeController.dispose(); + + _addressFocusNode.dispose(); + _cryptoFocus.dispose(); + _baseFocus.dispose(); + _memoFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + + // If wallet is not initialized, show a placeholder. + if (tokenWallet == null) { + return Center( + child: Text( + "Loading token data...", + style: STextStyles.subtitle500(context), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + if (coin is Firo) + Text( + "Send from", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + textAlign: TextAlign.left, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Amount", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + textAlign: TextAlign.left, + ), + CustomTextButton( + text: "Send all ${tokenWallet.tokenSymbol}", + onTap: sendAllTapped, + ), + ], + ), + const SizedBox(height: 10), + TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of(context).extension()!.textDark, + ), + key: const Key("amountInputFieldCryptoTextFieldKey"), + controller: cryptoAmountController, + focusNode: _cryptoFocus, + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + textAlign: TextAlign.right, + inputFormatters: [ + AmountInputFormatter( + decimals: tokenWallet.tokenDecimals, + unit: ref.watch(pAmountUnit(coin)), + locale: ref.watch( + localeServiceChangeNotifierProvider.select( + (value) => value.locale, + ), + ), + ), + // regex to validate a crypto amount with 8 decimal places + // TextInputFormatter.withFunction((oldValue, newValue) => RegExp( + // _kCryptoAmountRegex.replaceAll( + // "0,8", + // "0,${tokenContract.decimals}", + // ), + // ).hasMatch(newValue.text) + // ? newValue + // : oldValue), + ], + onChanged: (newValue) {}, + decoration: InputDecoration( + contentPadding: const EdgeInsets.only( + top: 22, + right: 12, + bottom: 22, + ), + hintText: "0", + hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldDefaultText, + ), + prefixIcon: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + tokenWallet.tokenSymbol, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + ), + ), + if (ref.watch( + prefsChangeNotifierProvider.select((s) => s.externalCalls), + )) + const SizedBox(height: 10), + if (ref.watch( + prefsChangeNotifierProvider.select((s) => s.externalCalls), + )) + TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of(context).extension()!.textDark, + ), + key: const Key("amountInputFieldFiatTextFieldKey"), + controller: baseAmountController, + focusNode: _baseFocus, + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + textAlign: TextAlign.right, + inputFormatters: [ + AmountInputFormatter( + decimals: 2, + locale: ref.watch( + localeServiceChangeNotifierProvider.select( + (value) => value.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})$') + // .hasMatch(newValue.text) + // ? newValue + // : oldValue), + ], + onChanged: fiatTextFieldOnChanged, + decoration: InputDecoration( + contentPadding: const EdgeInsets.only( + top: 22, + right: 12, + bottom: 22, + ), + hintText: "0", + hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldDefaultText, + ), + prefixIcon: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + ref.watch( + prefsChangeNotifierProvider.select( + (value) => value.currency, + ), + ), + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 20), + Text( + "Send to", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + textAlign: TextAlign.left, + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + minLines: 1, + maxLines: 5, + key: const Key("sendViewAddressFieldKey"), + controller: sendToController, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + // inputFormatters: [ + // FilteringTextInputFormatter.allow( + // RegExp("[a-zA-Z0-9]{34}")), + // ], + toolbarOptions: const ToolbarOptions( + copy: false, + cut: false, + paste: true, + selectAll: false, + ), + onChanged: (newValue) { + _address = newValue; + _updatePreviewButtonState(_address, _amountToSend); + + setState(() { + _addressToggleFlag = newValue.isNotEmpty; + }); + }, + focusNode: _addressFocusNode, + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + height: 1.8, + ), + decoration: + standardInputDecoration( + "Enter Solana address", + _addressFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 11, + bottom: 12, + right: 5, + ), + suffixIcon: Padding( + padding: sendToController.text.isEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _addressToggleFlag + ? TextFieldIconButton( + key: const Key( + "sendTokenViewClearAddressFieldButtonKey", + ), + onTap: () { + sendToController.text = ""; + _address = ""; + _updatePreviewButtonState( + _address, + _amountToSend, + ); + setState(() { + _addressToggleFlag = false; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "sendTokenViewPasteAddressFieldButtonKey", + ), + onTap: pasteAddress, + child: sendToController.text.isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (sendToController.text.isEmpty) + TextFieldIconButton( + key: const Key( + "sendTokenViewAddressBookButtonKey", + ), + onTap: () async { + final entry = + await showDialog( + context: context, + builder: (context) => DesktopDialog( + maxWidth: 696, + maxHeight: 600, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment + .spaceBetween, + children: [ + Padding( + padding: + const EdgeInsets.only( + left: 32, + ), + child: Text( + "Address book", + style: + STextStyles.desktopH3( + context, + ), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: AddressBookAddressChooser( + coin: coin, + ), + ), + ], + ), + ), + ); + + if (entry != null) { + sendToController.text = + entry.other ?? entry.label; + + _address = entry.address; + + _updatePreviewButtonState( + _address, + _amountToSend, + ); + + setState(() { + _addressToggleFlag = true; + }); + } + }, + child: const AddressBookIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + Builder( + builder: (_) { + final error = _updateInvalidAddressText(_address ?? ""); + + if (error == null || error.isEmpty) { + return Container(); + } else { + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(left: 12.0, top: 4.0), + child: Text( + error, + textAlign: TextAlign.left, + style: STextStyles.label(context).copyWith( + color: Theme.of( + context, + ).extension()!.textError, + ), + ), + ), + ); + } + }, + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + maxLength: (coin is Firo) ? 31 : null, + minLines: 1, + maxLines: 5, + key: const Key("sendViewMemoFieldKey"), + controller: memoController, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + focusNode: _memoFocusNode, + onChanged: (_) { + setState(() {}); + }, + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + height: 1.8, + ), + decoration: + standardInputDecoration( + "Enter memo (optional)", + _memoFocusNode, + context, + desktopMed: true, + ).copyWith( + counterText: '', + contentPadding: const EdgeInsets.only( + left: 16, + top: 11, + bottom: 12, + right: 5, + ), + suffixIcon: Padding( + padding: memoController.text.isEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + TextFieldIconButton( + key: const Key("sendViewPasteMemoButtonKey"), + onTap: pasteMemo, + child: memoController.text.isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + 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, + ), + ], + ); + } +} diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart index bf57331ea9..f01cdd2464 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart @@ -108,15 +108,14 @@ class _DesktopTokenSendState extends ConsumerState { final tokenWallet = ref.read(pCurrentTokenWallet)!; final Amount amount = _amountToSend!; - final Amount availableBalance = - ref - .read( - pTokenBalance(( - walletId: walletId, - contractAddress: tokenWallet.tokenContract.address, - )), - ) - .spendable; + final Amount availableBalance = ref + .read( + pTokenBalance(( + walletId: walletId, + contractAddress: tokenWallet.tokenContract.address, + )), + ) + .spendable; // confirm send all if (amount == availableBalance) { @@ -237,8 +236,9 @@ class _DesktopTokenSendState extends ConsumerState { address: _address!, amount: amount, isChange: false, - addressType: - tokenWallet.cryptoCurrency.getAddressType(_address!)!, + addressType: tokenWallet.cryptoCurrency.getAddressType( + _address!, + )!, ), ], feeRateType: ref.read(feeRateTypeDesktopStateProvider), @@ -260,18 +260,17 @@ class _DesktopTokenSendState extends ConsumerState { unawaited( showDialog( context: context, - builder: - (context) => DesktopDialog( - maxHeight: MediaQuery.of(context).size.height - 64, - maxWidth: 580, - child: ConfirmTransactionView( - txData: txData, - walletId: walletId, - onSuccess: clearSendForm, - isTokenTx: true, - routeOnSuccessName: DesktopHomeView.routeName, - ), - ), + builder: (context) => DesktopDialog( + maxHeight: MediaQuery.of(context).size.height - 64, + maxWidth: 580, + child: ConfirmTransactionView( + txData: txData, + walletId: walletId, + onSuccess: clearSendForm, + isTokenTx: true, + routeOnSuccessName: DesktopHomeView.routeName, + ), + ), ), ); } @@ -360,7 +359,7 @@ class _DesktopTokenSendState extends ConsumerState { .read(pAmountFormatter(coin)) .tryParse( cryptoAmountController.text, - ethContract: ref.read(pCurrentTokenWallet)!.tokenContract, + tokenContract: ref.read(pCurrentTokenWallet)!.tokenContract, ); if (cryptoAmount != null) { @@ -371,21 +370,19 @@ class _DesktopTokenSendState extends ConsumerState { } _cachedAmountToSend = _amountToSend; - final price = - ref - .read(priceAnd24hChangeNotifierProvider) - .getTokenPrice( - ref.read(pCurrentTokenWallet)!.tokenContract.address, - ) - ?.value; + final price = ref + .read(priceAnd24hChangeNotifierProvider) + .getTokenPrice(ref.read(pCurrentTokenWallet)!.tokenContract.address) + ?.value; if (price != null && price > Decimal.zero) { - final String fiatAmountString = Amount.fromDecimal( - _amountToSend!.decimal * price, - fractionDigits: 2, - ).fiatString( - locale: ref.read(localeServiceChangeNotifierProvider).locale, - ); + final String fiatAmountString = + Amount.fromDecimal( + _amountToSend!.decimal * price, + fractionDigits: 2, + ).fiatString( + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); baseAmountController.text = fiatAmountString; } @@ -464,8 +461,10 @@ class _DesktopTokenSendState extends ConsumerState { // autofill amount field if (paymentData.amount != null) { final Amount amount = Decimal.parse(paymentData.amount!).toAmount( - fractionDigits: - ref.read(pCurrentTokenWallet)!.tokenContract.decimals, + fractionDigits: ref + .read(pCurrentTokenWallet)! + .tokenContract + .decimals, ); cryptoAmountController.text = ref .read(pAmountFormatter(coin)) @@ -519,36 +518,33 @@ class _DesktopTokenSendState extends ConsumerState { } void fiatTextFieldOnChanged(String baseAmountString) { - final int tokenDecimals = - ref.read(pCurrentTokenWallet)!.tokenContract.decimals; + final int tokenDecimals = ref + .read(pCurrentTokenWallet)! + .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 Decimal? _price = - ref - .read(priceAnd24hChangeNotifierProvider) - .getTokenPrice( - ref.read(pCurrentTokenWallet)!.tokenContract.address, - ) - ?.value; + final baseAmount = baseAmountString.contains(",") + ? Decimal.parse( + baseAmountString.replaceFirst(",", "."), + ).toAmount(fractionDigits: 2) + : Decimal.parse(baseAmountString).toAmount(fractionDigits: 2); + + final Decimal? _price = ref + .read(priceAnd24hChangeNotifierProvider) + .getTokenPrice(ref.read(pCurrentTokenWallet)!.tokenContract.address) + ?.value; if (_price == null || _price == Decimal.zero) { _amountToSend = Decimal.zero.toAmount(fractionDigits: tokenDecimals); } else { - _amountToSend = - baseAmount <= Amount.zero - ? Decimal.zero.toAmount(fractionDigits: tokenDecimals) - : (baseAmount.decimal / _price) - .toDecimal(scaleOnInfinitePrecision: tokenDecimals) - .toAmount(fractionDigits: tokenDecimals); + _amountToSend = baseAmount <= Amount.zero + ? Decimal.zero.toAmount(fractionDigits: tokenDecimals) + : (baseAmount.decimal / _price) + .toDecimal(scaleOnInfinitePrecision: tokenDecimals) + .toAmount(fractionDigits: tokenDecimals); } if (_cachedAmountToSend != null && _cachedAmountToSend == _amountToSend) { return; @@ -560,7 +556,7 @@ class _DesktopTokenSendState extends ConsumerState { .format( _amountToSend!, withUnitName: false, - ethContract: ref.read(pCurrentTokenWallet)!.tokenContract, + tokenContract: ref.read(pCurrentTokenWallet)!.tokenContract, ); _cryptoAmountChangeLock = true; @@ -581,8 +577,10 @@ class _DesktopTokenSendState extends ConsumerState { .read( pTokenBalance(( walletId: walletId, - contractAddress: - ref.read(pCurrentTokenWallet)!.tokenContract.address, + contractAddress: ref + .read(pCurrentTokenWallet)! + .tokenContract + .address, )), ) .spendable @@ -679,10 +677,9 @@ class _DesktopTokenSendState extends ConsumerState { Text( "Send from", style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, ), textAlign: TextAlign.left, ), @@ -692,10 +689,9 @@ class _DesktopTokenSendState extends ConsumerState { Text( "Amount", style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, ), textAlign: TextAlign.left, ), @@ -715,13 +711,12 @@ class _DesktopTokenSendState extends ConsumerState { key: const Key("amountInputFieldCryptoTextFieldKey"), controller: cryptoAmountController, focusNode: _cryptoFocus, - keyboardType: - Util.isDesktop - ? null - : const TextInputType.numberWithOptions( - signed: false, - decimal: true, - ), + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( @@ -752,10 +747,9 @@ class _DesktopTokenSendState extends ConsumerState { ), hintText: "0", hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultText, + color: Theme.of( + context, + ).extension()!.textFieldDefaultText, ), prefixIcon: FittedBox( fit: BoxFit.scaleDown, @@ -764,10 +758,9 @@ class _DesktopTokenSendState extends ConsumerState { child: Text( ref.watch(pAmountUnit(coin)).unitForContract(tokenContract), style: STextStyles.smallMed14(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -790,13 +783,12 @@ class _DesktopTokenSendState extends ConsumerState { key: const Key("amountInputFieldFiatTextFieldKey"), controller: baseAmountController, focusNode: _baseFocus, - keyboardType: - Util.isDesktop - ? null - : const TextInputType.numberWithOptions( - signed: false, - decimal: true, - ), + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( @@ -823,10 +815,9 @@ class _DesktopTokenSendState extends ConsumerState { ), hintText: "0", hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultText, + color: Theme.of( + context, + ).extension()!.textFieldDefaultText, ), prefixIcon: FittedBox( fit: BoxFit.scaleDown, @@ -839,10 +830,9 @@ class _DesktopTokenSendState extends ConsumerState { ), ), style: STextStyles.smallMed14(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -853,10 +843,9 @@ class _DesktopTokenSendState extends ConsumerState { Text( "Send to", style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, ), textAlign: TextAlign.left, ), @@ -893,127 +882,128 @@ class _DesktopTokenSendState extends ConsumerState { }, focusNode: _addressFocusNode, style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveText, + color: Theme.of( + context, + ).extension()!.textFieldActiveText, height: 1.8, ), - decoration: standardInputDecoration( - "Enter ${tokenContract.symbol} address", - _addressFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.only( - left: 16, - top: 11, - bottom: 12, - right: 5, - ), - suffixIcon: Padding( - padding: - sendToController.text.isEmpty + decoration: + standardInputDecoration( + "Enter ${tokenContract.symbol} address", + _addressFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 11, + bottom: 12, + right: 5, + ), + suffixIcon: Padding( + padding: sendToController.text.isEmpty ? const EdgeInsets.only(right: 8) : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _addressToggleFlag - ? TextFieldIconButton( - key: const Key( - "sendTokenViewClearAddressFieldButtonKey", - ), - onTap: () { - sendToController.text = ""; - _address = ""; - _updatePreviewButtonState( - _address, - _amountToSend, - ); - setState(() { - _addressToggleFlag = false; - }); - }, - child: const XIcon(), - ) - : TextFieldIconButton( - key: const Key( - "sendTokenViewPasteAddressFieldButtonKey", - ), - onTap: pasteAddress, - child: - sendToController.text.isEmpty - ? const ClipboardIcon() - : const XIcon(), - ), - if (sendToController.text.isEmpty) - TextFieldIconButton( - key: const Key("sendTokenViewAddressBookButtonKey"), - onTap: () async { - final entry = await showDialog< - ContactAddressEntry? - >( - context: context, - builder: - (context) => DesktopDialog( - maxWidth: 696, - maxHeight: 600, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _addressToggleFlag + ? TextFieldIconButton( + key: const Key( + "sendTokenViewClearAddressFieldButtonKey", + ), + onTap: () { + sendToController.text = ""; + _address = ""; + _updatePreviewButtonState( + _address, + _amountToSend, + ); + setState(() { + _addressToggleFlag = false; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "sendTokenViewPasteAddressFieldButtonKey", + ), + onTap: pasteAddress, + child: sendToController.text.isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (sendToController.text.isEmpty) + TextFieldIconButton( + key: const Key( + "sendTokenViewAddressBookButtonKey", + ), + onTap: () async { + final entry = + await showDialog( + context: context, + builder: (context) => DesktopDialog( + maxWidth: 696, + maxHeight: 600, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.only( - left: 32, - ), - child: Text( - "Address book", - style: STextStyles.desktopH3( - context, + Row( + mainAxisAlignment: + MainAxisAlignment + .spaceBetween, + children: [ + Padding( + padding: + const EdgeInsets.only( + left: 32, + ), + child: Text( + "Address book", + style: + STextStyles.desktopH3( + context, + ), + ), ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: AddressBookAddressChooser( + coin: coin, ), ), - const DesktopDialogCloseButton(), ], ), - Expanded( - child: AddressBookAddressChooser( - coin: coin, - ), - ), - ], - ), - ), - ); + ), + ); - if (entry != null) { - sendToController.text = - entry.other ?? entry.label; + if (entry != null) { + sendToController.text = + entry.other ?? entry.label; - _address = entry.address; + _address = entry.address; - _updatePreviewButtonState( - _address, - _amountToSend, - ); + _updatePreviewButtonState( + _address, + _amountToSend, + ); - setState(() { - _addressToggleFlag = true; - }); - } - }, - child: const AddressBookIcon(), - ), - ], + setState(() { + _addressToggleFlag = true; + }); + } + }, + child: const AddressBookIcon(), + ), + ], + ), + ), ), ), - ), - ), ), ), Builder( @@ -1031,8 +1021,9 @@ class _DesktopTokenSendState extends ConsumerState { error, textAlign: TextAlign.left, style: STextStyles.label(context).copyWith( - color: - Theme.of(context).extension()!.textError, + color: Theme.of( + context, + ).extension()!.textError, ), ), ), @@ -1054,10 +1045,9 @@ class _DesktopTokenSendState extends ConsumerState { Text( "Nonce", style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, ), textAlign: TextAlign.left, ), @@ -1077,25 +1067,25 @@ class _DesktopTokenSendState extends ConsumerState { keyboardType: const TextInputType.numberWithOptions(), focusNode: _nonceFocusNode, style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveText, + color: Theme.of( + context, + ).extension()!.textFieldActiveText, height: 1.8, ), - decoration: standardInputDecoration( - "Leave empty to auto select nonce", - _nonceFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.only( - left: 16, - top: 11, - bottom: 12, - right: 5, - ), - ), + decoration: + standardInputDecoration( + "Leave empty to auto select nonce", + _nonceFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 11, + bottom: 12, + right: 5, + ), + ), ), ), const SizedBox(height: 36), @@ -1103,10 +1093,9 @@ class _DesktopTokenSendState extends ConsumerState { buttonHeight: ButtonHeight.l, label: "Preview send", enabled: ref.watch(previewTokenTxButtonStateProvider.state).state, - onPressed: - ref.watch(previewTokenTxButtonStateProvider.state).state - ? previewSend - : null, + onPressed: ref.watch(previewTokenTxButtonStateProvider.state).state + ? previewSend + : null, ), ], ); diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart index 8d14c789d9..4793f0ada3 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart @@ -20,11 +20,13 @@ import 'package:flutter_svg/svg.dart'; import '../../../../app_config.dart'; import '../../../../models/keys/view_only_wallet_data.dart'; import '../../../../notifications/show_flush_bar.dart'; +import '../../../../pages/masternodes/masternodes_home_view.dart'; import '../../../../pages/monkey/monkey_view.dart'; import '../../../../pages/namecoin_names/namecoin_names_home_view.dart'; import '../../../../pages/paynym/paynym_claim_view.dart'; import '../../../../pages/paynym/paynym_home_view.dart'; import '../../../../pages/salvium_stake/salvium_create_stake_view.dart'; +import '../../../../pages/signing/signing_view.dart'; import '../../../../pages/spark_names/spark_names_home_view.dart'; import '../../../../providers/desktop/current_desktop_menu_item.dart'; import '../../../../providers/global/paynym_api_provider.dart'; @@ -39,9 +41,11 @@ import '../../../../utilities/logger.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../wallets/crypto_currency/coins/banano.dart'; import '../../../../wallets/crypto_currency/coins/firo.dart'; +import '../../../../wallets/wallet/impl/bitcoin_wallet.dart'; import '../../../../wallets/wallet/impl/firo_wallet.dart'; import '../../../../wallets/wallet/impl/namecoin_wallet.dart'; -import '../../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; +import '../../../../wallets/wallet/impl/salvium_wallet.dart'; +import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../wallets/wallet/intermediate/lib_salvium_wallet.dart'; import '../../../../wallets/wallet/wallet.dart' show Wallet; import '../../../../wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart'; @@ -51,6 +55,7 @@ import '../../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/rbf_interface.dart'; +import '../../../../wallets/wallet/wallet_mixin_interfaces/sign_verify_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/custom_loading_overlay.dart'; @@ -88,12 +93,15 @@ enum WalletFeature { namecoinName("Domains", "Namecoin DNS"), sparkNames("Names", "Spark names"), salviumStaking("Staking", "Staking"), + sign("Sign/Verify", "Sign / Verify messages"), + masternodes("Masternodes", "Manage masternodes"), // special cases clearSparkCache("", ""), rbf("", ""), reuseAddress("", ""), - enableMweb("", ""); + enableMweb("", ""), + enableLegacyAddresses("", ""); final String label; final String description; @@ -417,6 +425,44 @@ class _DesktopWalletFeaturesState extends ConsumerState { ); } + Future _onSignPressed() async { + await showDialog( + context: context, + builder: (context) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Sign/Verify", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: SigningView(walletId: widget.walletId), + ), + const SizedBox(height: 32), + ], + ), + ), + ); + } + + void _onMasternodesPressed() { + Navigator.of( + context, + ).pushNamed(MasternodesHomeView.routeName, arguments: widget.walletId); + } + List<(WalletFeature, String, FutureOr Function())> _getOptions( Wallet wallet, bool showExchange, @@ -425,6 +471,7 @@ class _DesktopWalletFeaturesState extends ConsumerState { ) { final coin = wallet.info.coin; final isViewOnly = wallet is ViewOnlyOptionInterface && wallet.isViewOnly; + final isSparkViewOnly = isViewOnly && wallet.viewOnlyType == .spark; return [ if (!isViewOnly && @@ -436,7 +483,7 @@ class _DesktopWalletFeaturesState extends ConsumerState { _onAnonymizeAllPressed, ), - if (wallet is SparkInterface) + if (wallet is SparkInterface && !isViewOnly || isSparkViewOnly) (WalletFeature.sparkNames, Assets.svg.robotHead, _onSparkNamesPressed), if (!isViewOnly && @@ -455,6 +502,11 @@ class _DesktopWalletFeaturesState extends ConsumerState { _onSalviumStakePressed, ), + if (wallet is SignVerifyInterface && !isViewOnly) + (WalletFeature.sign, Assets.svg.pencil, _onSignPressed), + + if (!isViewOnly && wallet is FiroWallet) + (WalletFeature.masternodes, Assets.svg.recycle, _onMasternodesPressed), if (showCoinControl) ( WalletFeature.coinControl, @@ -490,8 +542,7 @@ class _DesktopWalletFeaturesState extends ConsumerState { wallet is CashFusionInterface) (WalletFeature.fusion, Assets.svg.cashFusion, _onFusionPressed), - if (!isViewOnly && - (wallet is LibMoneroWallet || wallet is LibSalviumWallet)) + if (!isViewOnly && (wallet is CryptonoteWallet)) (WalletFeature.churn, Assets.svg.churn, _onChurnPressed), if (wallet is NamecoinWallet) @@ -510,6 +561,7 @@ class _DesktopWalletFeaturesState extends ConsumerState { prefsChangeNotifierProvider.select((value) => value.enableExchange), ), (wallet is CoinControlInterface && + wallet is! SalviumWallet && ref.watch( prefsChangeNotifierProvider.select( (value) => value.enableCoinControl, @@ -540,11 +592,15 @@ class _DesktopWalletFeaturesState extends ConsumerState { final showMwebOption = wallet is MwebInterface && !wallet.isViewOnly; final extraOptions = [ - if (wallet is SparkInterface && !isViewOnly) + if (wallet is SparkInterface && + (!isViewOnly || (isViewOnly && wallet.viewOnlyType == .spark))) (WalletFeature.clearSparkCache, Assets.svg.key, () => ()), if (wallet is RbfInterface) (WalletFeature.rbf, Assets.svg.key, () => ()), + if (wallet is BitcoinWallet) + (WalletFeature.enableLegacyAddresses, Assets.svg.key, () => ()), + if (canGen) (WalletFeature.reuseAddress, Assets.svg.key, () => ()), if (showMwebOption) (WalletFeature.enableMweb, Assets.svg.key, () => ()), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart index c6ad2694d3..ac01104ff3 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../models/balance.dart'; +import '../../../../models/isar/models/contract.dart'; import '../../../../pages/wallet_view/sub_widgets/wallet_refresh_button.dart'; import '../../../../providers/providers.dart'; import '../../../../providers/wallet/public_private_balance_state_provider.dart'; @@ -22,11 +23,15 @@ import '../../../../utilities/amount/amount.dart'; import '../../../../utilities/amount/amount_formatter.dart'; import '../../../../utilities/enums/wallet_balance_toggle_state.dart'; import '../../../../utilities/text_styles.dart'; +import '../../../../wallets/crypto_currency/coins/ethereum.dart'; import '../../../../wallets/crypto_currency/coins/firo.dart'; +import '../../../../wallets/crypto_currency/coins/solana.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart' show CryptoCurrency; import '../../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../../../wallets/isar/providers/eth/token_balance_provider.dart'; +import '../../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import 'desktop_balance_toggle_button.dart'; @@ -77,25 +82,41 @@ class _WDesktopWalletSummaryState extends ConsumerState { prefsChangeNotifierProvider.select((value) => value.currency), ); - final tokenContract = - widget.isToken - ? ref.watch( - pCurrentTokenWallet.select((value) => value!.tokenContract), - ) - : null; - - final price = - widget.isToken - ? ref.watch( - priceAnd24hChangeNotifierProvider.select( - (value) => value.getTokenPrice(tokenContract!.address), - ), - ) - : ref.watch( - priceAnd24hChangeNotifierProvider.select( - (value) => value.getPrice(coin), - ), - ); + final coin = ref.watch(pWalletCoin(walletId)); + final Contract? tokenContract; + + if (widget.isToken) { + switch (coin) { + case Ethereum(): + tokenContract = ref.watch( + pCurrentTokenWallet.select((value) => value!.tokenContract), + ); + break; + + case Solana(): + tokenContract = ref.watch( + pCurrentSolanaTokenWallet.select((value) => value!.solContract), + ); + break; + + default: + tokenContract = null; + } + } else { + tokenContract = null; + } + + final price = tokenContract != null + ? ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getTokenPrice(tokenContract!.address), + ), + ) + : ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getPrice(coin), + ), + ); final _showAvailable = ref.watch(walletBalanceToggleStateProvider.state).state == @@ -115,15 +136,27 @@ class _WDesktopWalletSummaryState extends ConsumerState { break; } } else { - final Balance balance = - widget.isToken - ? ref.watch( - pTokenBalance(( - walletId: walletId, - contractAddress: tokenContract!.address, - )), - ) - : ref.watch(pWalletBalance(walletId)); + final Balance balance; + if (tokenContract != null && coin is Ethereum) { + // Ethereum token balance + balance = ref.watch( + pTokenBalance(( + walletId: walletId, + contractAddress: tokenContract.address, + )), + ); + } else if (tokenContract != null && coin is Solana) { + // Watch Solana token balance from db. + balance = ref.watch( + pSolanaTokenBalance(( + walletId: walletId, + tokenMint: tokenContract.address, + )), + ); + } else { + // Regular wallet balance. + balance = ref.watch(pWalletBalance(walletId)); + } balanceToShow = _showAvailable ? balance.spendable : balance.total; } @@ -141,7 +174,7 @@ class _WDesktopWalletSummaryState extends ConsumerState { child: SelectableText( ref .watch(pAmountFormatter(coin)) - .format(balanceToShow, ethContract: tokenContract), + .format(balanceToShow, tokenContract: tokenContract), style: STextStyles.desktopH3(context), ), ), @@ -149,10 +182,9 @@ class _WDesktopWalletSummaryState extends ConsumerState { SelectableText( "${Amount.fromDecimal(price.value * balanceToShow.decimal, fractionDigits: 2).fiatString(locale: locale)} $baseCurrency", style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), ), // if (coin is Firo) @@ -173,10 +205,9 @@ class _WDesktopWalletSummaryState extends ConsumerState { WalletRefreshButton( walletId: walletId, initialSyncStatus: widget.initialSyncStatus, - tokenContractAddress: - widget.isToken - ? ref.watch(pCurrentTokenWallet)!.tokenContract.address - : null, + tokenContractAddress: widget.isToken && tokenContract != null + ? tokenContract.address + : null, ), const SizedBox(width: 8), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/firo_desktop_wallet_summary.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/firo_desktop_wallet_summary.dart index 650f67f685..e193ab380c 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/firo_desktop_wallet_summary.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/firo_desktop_wallet_summary.dart @@ -55,6 +55,7 @@ class _WFiroDesktopWalletSummaryState void initState() { super.initState(); walletId = widget.walletId; + coin = ref.read(pWalletCoin(widget.walletId)) as Firo; } @@ -66,14 +67,13 @@ class _WFiroDesktopWalletSummaryState if (ref.watch( prefsChangeNotifierProvider.select((value) => value.externalCalls), )) { - price = - ref - .watch( - priceAnd24hChangeNotifierProvider.select( - (value) => value.getPrice(coin), - ), - ) - ?.value; + price = ref + .watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getPrice(coin), + ), + ) + ?.value; } final _showAvailable = @@ -81,14 +81,16 @@ class _WFiroDesktopWalletSummaryState WalletBalanceToggleState.available; final balance0 = ref.watch(pWalletBalanceTertiary(walletId)); - final balanceToShowSpark = - _showAvailable ? balance0.spendable : balance0.total; + final balanceToShowSpark = _showAvailable + ? balance0.spendable + : balance0.total; final balance1 = ref.watch(pWalletBalanceSecondary(walletId)); final balance2 = ref.watch(pWalletBalance(walletId)); - final balanceToShowPublic = - _showAvailable ? balance2.spendable : balance2.total; + final balanceToShowPublic = _showAvailable + ? balance2.spendable + : balance2.total; return Consumer( builder: (context, ref, __) { @@ -168,10 +170,9 @@ class _Prefix extends StatelessWidget { SizedBox( width: 20, height: 20, - child: - asset.endsWith(".png") - ? Image(image: AssetImage(asset)) - : SvgPicture.asset(asset), + child: asset.endsWith(".png") + ? Image(image: AssetImage(asset)) + : SvgPicture.asset(asset), ), const SizedBox(width: 6), @@ -194,7 +195,7 @@ class _Balance extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return SelectableText( - ref.watch(pAmountFormatter(coin)).format(amount, ethContract: null), + ref.watch(pAmountFormatter(coin)).format(amount, tokenContract: null), style: STextStyles.desktopH3(context), textAlign: TextAlign.end, ); diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart index 20f2524a68..877d67b71f 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart @@ -69,6 +69,27 @@ class _MoreFeaturesDialogState extends ConsumerState { } } + bool _switchLegacyToggledLock = false; // Mutex. + Future _switchLegacyToggled(bool newValue) async { + if (_switchLegacyToggledLock) { + return; + } + _switchLegacyToggledLock = true; // Lock mutex. + + try { + // Toggle enableLegacyAddresses in wallet info. + await ref + .read(pWalletInfo(widget.walletId)) + .updateOtherData( + newEntries: {WalletInfoKeys.enableLegacyAddresses: newValue}, + isar: ref.read(mainDBProvider).isar, + ); + } finally { + // ensure _switchLegacyToggledLock is set to false no matter what + _switchLegacyToggledLock = false; + } + } + late final DSBController _switchControllerAddressReuse; late final DSBController _switchControllerMwebToggle; @@ -113,7 +134,9 @@ class _MoreFeaturesDialogState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Text( - "Reusing addresses reduces your privacy and security. Are you sure you want to reuse addresses by default?", + "Reusing addresses reduces your privacy and " + "security. Are you sure you want to reuse " + "addresses by default?", style: STextStyles.desktopTextSmall(context), ), const SizedBox(height: 43), @@ -217,8 +240,9 @@ class _MoreFeaturesDialogState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Text( - "Activating MWEB requires synchronizing on-chain MWEB related data. " - "This currently requires about 800 MB of storage.", + "Activating MWEB requires synchronizing on-chain " + "MWEB related data. This currently requires about " + "800 MB of storage.", style: STextStyles.desktopTextSmall(context), ), const SizedBox(height: 43), @@ -307,10 +331,13 @@ class _MoreFeaturesDialogState extends ConsumerState { pWallets.select((value) => value.getWallet(widget.walletId)), ); + final maxDialogHeight = MediaQuery.sizeOf(context).height - 64; + return DesktopDialog( - maxHeight: double.infinity, + maxHeight: maxDialogHeight, child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -326,150 +353,199 @@ class _MoreFeaturesDialogState extends ConsumerState { ], ), - ...widget.options.map((option) { - switch (option.$1) { - case WalletFeature.buy: - // Buy has a special icon - return _MoreFeaturesItem( - label: option.$1.label, - detail: option.$1.description, - isSvgFile: true, - iconAsset: ref.watch( - themeProvider.select((value) => value.assets.buy), - ), - onPressed: () async { - Navigator.of(context, rootNavigator: true).pop(); - option.$3(); - }, - ); - - case WalletFeature.clearSparkCache: - return _MoreFeaturesClearSparkCacheItem( - cryptoCurrency: wallet.cryptoCurrency, - ); - - case WalletFeature.rbf: - return _MoreFeaturesItemBase( - child: Row( - children: [ - const SizedBox(width: 3), - SizedBox( - height: 20, - width: 40, - child: DraggableSwitchButton( - isOn: - ref.watch( - pWalletInfo( - widget.walletId, - ).select((value) => value.otherData), - )[WalletInfoKeys.enableOptInRbf] - as bool? ?? - false, - onValueChanged: _switchRbfToggled, - ), - ), - const SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Flag outgoing transactions with opt-in RBF", - style: STextStyles.w600_20(context), + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ...widget.options.map((option) { + switch (option.$1) { + case WalletFeature.buy: + // Buy has a special icon + return _MoreFeaturesItem( + label: option.$1.label, + detail: option.$1.description, + isSvgFile: true, + iconAsset: ref.watch( + themeProvider.select((value) => value.assets.buy), ), - ], - ), - ], - ), - ); - - case WalletFeature.reuseAddress: - return _MoreFeaturesItemBase( - onPressed: _switchReuseAddressToggled, - child: Row( - children: [ - const SizedBox(width: 3), - SizedBox( - height: 20, - width: 40, - child: IgnorePointer( - child: DraggableSwitchButton( - isOn: - ref.watch( - pWalletInfo( - widget.walletId, - ).select((value) => value.otherData), - )[WalletInfoKeys.reuseAddress] - as bool? ?? - false, - controller: _switchControllerAddressReuse, + onPressed: () async { + Navigator.of(context, rootNavigator: true).pop(); + option.$3(); + }, + ); + + case WalletFeature.clearSparkCache: + return _MoreFeaturesClearSparkCacheItem( + cryptoCurrency: wallet.cryptoCurrency, + ); + + case WalletFeature.rbf: + return _MoreFeaturesItemBase( + child: Row( + children: [ + const SizedBox(width: 3), + SizedBox( + height: 20, + width: 40, + child: DraggableSwitchButton( + isOn: + ref.watch( + pWalletInfo(widget.walletId).select( + (value) => value.otherData, + ), + )[WalletInfoKeys.enableOptInRbf] + as bool? ?? + false, + onValueChanged: _switchRbfToggled, + ), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Flag outgoing transactions with opt-in RBF", + style: STextStyles.w600_20(context), + ), + ], + ), + ], ), - ), - ), - const SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Reuse receiving address", - style: STextStyles.w600_20(context), + ); + + case WalletFeature.enableLegacyAddresses: + return _MoreFeaturesItemBase( + child: Row( + children: [ + const SizedBox(width: 3), + SizedBox( + height: 20, + width: 40, + child: DraggableSwitchButton( + isOn: + ref.watch( + pWalletInfo(widget.walletId).select( + (value) => value.otherData, + ), + )[WalletInfoKeys + .enableLegacyAddresses] + as bool? ?? + false, + onValueChanged: _switchLegacyToggled, + ), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Enable legacy (P2PKH) address generation", + style: STextStyles.w600_20(context), + ), + ], + ), + ], ), - ], - ), - ], - ), - ); - - case WalletFeature.enableMweb: - return _MoreFeaturesItemBase( - onPressed: _switchMwebToggleToggled, - child: Row( - children: [ - const SizedBox(width: 3), - SizedBox( - height: 20, - width: 40, - child: IgnorePointer( - child: DraggableSwitchButton( - isOn: - ref.watch( - pWalletInfo( - widget.walletId, - ).select((value) => value.otherData), - )[WalletInfoKeys.mwebEnabled] - as bool? ?? - false, - controller: _switchControllerMwebToggle, + ); + + case WalletFeature.reuseAddress: + return _MoreFeaturesItemBase( + onPressed: _switchReuseAddressToggled, + child: Row( + children: [ + const SizedBox(width: 3), + SizedBox( + height: 20, + width: 40, + child: IgnorePointer( + child: DraggableSwitchButton( + isOn: + ref.watch( + pWalletInfo( + widget.walletId, + ).select( + (value) => value.otherData, + ), + )[WalletInfoKeys.reuseAddress] + as bool? ?? + false, + controller: _switchControllerAddressReuse, + ), + ), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Reuse receiving address", + style: STextStyles.w600_20(context), + ), + ], + ), + ], ), - ), - ), - const SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Enable MWEB", - style: STextStyles.w600_20(context), + ); + + case WalletFeature.enableMweb: + return _MoreFeaturesItemBase( + onPressed: _switchMwebToggleToggled, + child: Row( + children: [ + const SizedBox(width: 3), + SizedBox( + height: 20, + width: 40, + child: IgnorePointer( + child: DraggableSwitchButton( + isOn: + ref.watch( + pWalletInfo( + widget.walletId, + ).select( + (value) => value.otherData, + ), + )[WalletInfoKeys.mwebEnabled] + as bool? ?? + false, + controller: _switchControllerMwebToggle, + ), + ), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Enable MWEB", + style: STextStyles.w600_20(context), + ), + ], + ), + ], ), - ], - ), - ], - ), - ); - - default: - return _MoreFeaturesItem( - label: option.$1.label, - detail: option.$1.description, - iconAsset: option.$2, - onPressed: () async { - Navigator.of(context, rootNavigator: true).pop(); - option.$3(); - }, - ); - } - }), - - const SizedBox(height: 28), + ); + + default: + return _MoreFeaturesItem( + label: option.$1.label, + detail: option.$1.description, + iconAsset: option.$2, + onPressed: () async { + Navigator.of(context, rootNavigator: true).pop(); + option.$3(); + }, + ); + } + }), + + const SizedBox(height: 28), + ], + ), + ), + ), ], ), ); @@ -525,26 +601,23 @@ class _MoreFeaturesItemState extends State<_MoreFeaturesItem> { height: _MoreFeaturesItem.iconSizeBG, radiusMultiplier: _MoreFeaturesItem.iconSizeBG, child: Center( - child: - widget.isSvgFile - ? SvgPicture.file( - File(widget.iconAsset), - width: _MoreFeaturesItem.iconSize, - height: _MoreFeaturesItem.iconSize, - color: - Theme.of( - context, - ).extension()!.settingsIconIcon, - ) - : SvgPicture.asset( - widget.iconAsset, - width: _MoreFeaturesItem.iconSize, - height: _MoreFeaturesItem.iconSize, - color: - Theme.of( - context, - ).extension()!.settingsIconIcon, - ), + child: widget.isSvgFile + ? SvgPicture.file( + File(widget.iconAsset), + width: _MoreFeaturesItem.iconSize, + height: _MoreFeaturesItem.iconSize, + color: Theme.of( + context, + ).extension()!.settingsIconIcon, + ) + : SvgPicture.asset( + widget.iconAsset, + width: _MoreFeaturesItem.iconSize, + height: _MoreFeaturesItem.iconSize, + color: Theme.of( + context, + ).extension()!.settingsIconIcon, + ), ), ), const SizedBox(width: 16), @@ -576,8 +649,9 @@ class _MoreFeaturesItemBase extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 32), child: RoundedContainer( color: Colors.transparent, - borderColor: - Theme.of(context).extension()!.textFieldDefaultBG, + borderColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, onPressed: onPressed, child: child, ), @@ -636,10 +710,9 @@ class _MoreFeaturesClearSparkCacheItemState Assets.svg.x, width: _MoreFeaturesItem.iconSize, height: _MoreFeaturesItem.iconSize, - color: - Theme.of( - context, - ).extension()!.settingsIconIcon, + color: Theme.of( + context, + ).extension()!.settingsIconIcon, ), ), ), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/mweb_desktop_wallet_summary.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/mweb_desktop_wallet_summary.dart index 6ce1d19e44..6b025f0dd7 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/mweb_desktop_wallet_summary.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/mweb_desktop_wallet_summary.dart @@ -62,14 +62,13 @@ class _WMwebDesktopWalletSummaryState if (ref.watch( prefsChangeNotifierProvider.select((value) => value.externalCalls), )) { - price = - ref - .watch( - priceAnd24hChangeNotifierProvider.select( - (value) => value.getPrice(coin), - ), - ) - ?.value; + price = ref + .watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getPrice(coin), + ), + ) + ?.value; } final _showAvailable = @@ -77,12 +76,14 @@ class _WMwebDesktopWalletSummaryState WalletBalanceToggleState.available; final balance0 = ref.watch(pWalletBalanceSecondary(walletId)); - final balanceToShowSpark = - _showAvailable ? balance0.spendable : balance0.total; + final balanceToShowSpark = _showAvailable + ? balance0.spendable + : balance0.total; final balance2 = ref.watch(pWalletBalance(walletId)); - final balanceToShowPublic = - _showAvailable ? balance2.spendable : balance2.total; + final balanceToShowPublic = _showAvailable + ? balance2.spendable + : balance2.total; return Consumer( builder: (context, ref, __) { @@ -169,7 +170,7 @@ class _Balance extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return SelectableText( - ref.watch(pAmountFormatter(coin)).format(amount, ethContract: null), + ref.watch(pAmountFormatter(coin)).format(amount, tokenContract: null), style: STextStyles.desktopH3(context), textAlign: TextAlign.end, ); diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart index 9a8d94da6f..dad37c3356 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart @@ -12,12 +12,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../frost_route_generator.dart'; +import '../../../../pages/epic_finalize_view/epic_finalize_view.dart'; import '../../../../pages/finalize_view/finalize_view.dart'; import '../../../../pages/send_view/frost_ms/frost_send_view.dart'; import '../../../../pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart'; import '../../../../providers/global/wallets_provider.dart'; +import '../../../../utilities/clipboard_interface.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import '../../../../wallets/wallet/impl/solana_wallet.dart' show SolanaWallet; import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/custom_tab_view.dart'; import '../../../../widgets/desktop/secondary_button.dart'; @@ -26,6 +29,7 @@ import '../../../../widgets/rounded_white_container.dart'; import '../../my_stack_view.dart'; import 'desktop_receive.dart'; import 'desktop_send.dart'; +import 'desktop_sol_token_send.dart'; import 'desktop_token_send.dart'; class MyWallet extends ConsumerStatefulWidget { @@ -42,9 +46,11 @@ class _MyWalletState extends ConsumerState { final titles = ["Send", "Receive"]; late final bool isEth; + late final bool isSolana; late final CryptoCurrency coin; late final bool isFrost; late final bool isMimblewimblecoin; + late final bool isEpiccash; late final bool isViewOnly; @override @@ -53,13 +59,15 @@ class _MyWalletState extends ConsumerState { coin = wallet.info.coin; isFrost = wallet is BitcoinFrostWallet; isEth = coin is Ethereum; + isSolana = wallet is SolanaWallet; isMimblewimblecoin = coin is Mimblewimblecoin; + isEpiccash = coin is Epiccash; - if (isMimblewimblecoin) { + if (isMimblewimblecoin || isEpiccash) { titles.add("Finalize"); } - if (isEth && widget.contractAddress == null) { + if ((isEth || isSolana) && widget.contractAddress == null) { titles.add("Transactions"); } @@ -101,58 +109,74 @@ class _MyWalletState extends ConsumerState { children: [ widget.contractAddress == null ? isFrost - ? Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, + ? Column( children: [ - Padding( - padding: const EdgeInsets.fromLTRB(0, 20, 0, 0), - child: SecondaryButton( - width: 200, - buttonHeight: ButtonHeight.l, - label: "Import sign config", - onPressed: () async { - final wallet = + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + 0, + 20, + 0, + 0, + ), + child: SecondaryButton( + width: 200, + buttonHeight: ButtonHeight.l, + label: "Import sign config", + onPressed: () async { + final wallet = + ref + .read(pWallets) + .getWallet(widget.walletId) + as BitcoinFrostWallet; ref - .read(pWallets) - .getWallet(widget.walletId) - as BitcoinFrostWallet; - ref.read(pFrostScaffoldArgs.state).state = ( - info: ( - walletName: wallet.info.name, - frostCurrency: wallet.cryptoCurrency, - ), - walletId: widget.walletId, - stepRoutes: - FrostRouteGenerator + .read(pFrostScaffoldArgs.state) + .state = ( + info: ( + walletName: wallet.info.name, + frostCurrency: + wallet.cryptoCurrency, + ), + walletId: widget.walletId, + stepRoutes: FrostRouteGenerator .signFrostTxStepRoutes, - parentNav: Navigator.of(context), - frostInterruptionDialogType: - FrostInterruptionDialogType - .transactionCreation, - callerRouteName: MyStackView.routeName, - ); - - await Navigator.of( - context, - ).pushNamed(FrostStepScaffold.routeName); - }, - ), + parentNav: Navigator.of(context), + frostInterruptionDialogType: + FrostInterruptionDialogType + .transactionCreation, + callerRouteName: + MyStackView.routeName, + ); + + await Navigator.of(context).pushNamed( + FrostStepScaffold.routeName, + ); + }, + ), + ), + ], + ), + FrostSendView( + walletId: widget.walletId, + coin: coin, ), ], - ), - FrostSendView(walletId: widget.walletId, coin: coin), - ], - ) - : Padding( - padding: const EdgeInsets.all(20), - child: DesktopSend(walletId: widget.walletId), - ) + ) + : Padding( + padding: const EdgeInsets.all(20), + child: DesktopSend(walletId: widget.walletId), + ) : Padding( - padding: const EdgeInsets.all(20), - child: DesktopTokenSend(walletId: widget.walletId), - ), + padding: const EdgeInsets.all(20), + child: isSolana + ? DesktopSolTokenSend( + walletId: widget.walletId, + clipboard: const ClipboardWrapper(), + ) + : DesktopTokenSend(walletId: widget.walletId), + ), Padding( padding: const EdgeInsets.all(20), child: DesktopReceive( @@ -165,8 +189,13 @@ class _MyWalletState extends ConsumerState { padding: const EdgeInsets.all(20), child: FinalizeView(walletId: widget.walletId), ), + if (isEpiccash) + Padding( + padding: const EdgeInsets.all(20), + child: EpicFinalizeView(walletId: widget.walletId), + ), - if (isEth && widget.contractAddress == null) + if ((isEth || isSolana) && widget.contractAddress == null) Padding( padding: const EdgeInsets.only(top: 8.0), child: ConstrainedBox( diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart index 4f1db4504b..dc15771830 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart @@ -23,8 +23,7 @@ import '../../../../utilities/assets.dart'; import '../../../../utilities/constants.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; -import '../../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; -import '../../../../wallets/wallet/intermediate/lib_salvium_wallet.dart'; +import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; @@ -61,12 +60,11 @@ class _UnlockWalletKeysDesktopState unawaited( showDialog( context: context, - builder: - (context) => const Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [LoadingIndicator(width: 200, height: 200)], - ), + builder: (context) => const Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [LoadingIndicator(width: 200, height: 200)], + ), ), ); @@ -108,10 +106,9 @@ class _UnlockWalletKeysDesktopState myName: wallet.frostInfo.myName, config: results[1]!, keys: results[0]!, - prevGen: - results[2] == null || results[3] == null - ? null - : (config: results[3]!, keys: results[2]!), + prevGen: results[2] == null || results[3] == null + ? null + : (config: results[3]!, keys: results[2]!), ); } } else { @@ -131,9 +128,7 @@ class _UnlockWalletKeysDesktopState keyData = await wallet.getViewOnlyWalletData(); } else if (wallet is ExtendedKeysInterface) { keyData = await wallet.getXPrivs(); - } else if (wallet is LibMoneroWallet) { - keyData = await wallet.getKeys(); - } else if (wallet is LibSalviumWallet) { + } else if (wallet is CryptonoteWallet) { keyData = await wallet.getKeys(); } @@ -191,8 +186,10 @@ class _UnlockWalletKeysDesktopState mainAxisAlignment: MainAxisAlignment.end, children: [ DesktopDialogCloseButton( - onPressedOverride: - Navigator.of(context, rootNavigator: true).pop, + onPressedOverride: Navigator.of( + context, + rootNavigator: true, + ).pop, ), ], ), @@ -230,53 +227,53 @@ class _UnlockWalletKeysDesktopState enterPassphrase(); } }, - decoration: standardInputDecoration( - "Enter password", - passwordFocusNode, - context, - ).copyWith( - suffixIcon: UnconstrainedBox( - child: SizedBox( - height: 70, - child: Row( - children: [ - GestureDetector( - key: const Key( - "enterUnlockWalletKeysDesktopFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: Container( - decoration: BoxDecoration( - color: Colors.transparent, - borderRadius: BorderRadius.circular(1000), - ), - height: 32, - width: 32, - child: Center( - child: SvgPicture.asset( - hidePassword - ? Assets.svg.eye - : Assets.svg.eyeSlash, - color: - Theme.of( + decoration: + standardInputDecoration( + "Enter password", + passwordFocusNode, + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: SizedBox( + height: 70, + child: Row( + children: [ + GestureDetector( + key: const Key( + "enterUnlockWalletKeysDesktopFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: Container( + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(1000), + ), + height: 32, + width: 32, + child: Center( + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of( context, ).extension()!.textDark3, - width: 24, - height: 19, + width: 24, + height: 19, + ), + ), ), ), - ), + const SizedBox(width: 10), + ], ), - const SizedBox(width: 10), - ], + ), ), ), - ), - ), onChanged: (newValue) { setState(() { continueEnabled = newValue.isNotEmpty; diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart index 8136133ca6..dc7fc8b3a4 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart @@ -14,9 +14,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import '../../../../pages/pinpad_views/pinpad_dialog.dart'; import '../../../../pages/settings_views/wallet_settings_view/frost_ms/frost_ms_options_view.dart'; import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/change_representative_view.dart'; import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart'; +import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart'; import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/xpub_view.dart'; import '../../../../providers/global/wallets_provider.dart'; import '../../../../route_generator.dart'; @@ -29,11 +31,14 @@ import '../../../../utilities/util.dart'; import '../../../../wallets/crypto_currency/intermediate/frost_currency.dart'; import '../../../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; -import '../../../../wallets/wallet/intermediate/lib_salvium_wallet.dart'; +import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; +import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; +import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../addresses/desktop_wallet_addresses_view.dart'; +import '../../../password/request_desktop_auth_dialog.dart'; +import '../../../settings/settings_menu/epicbox_settings/desktop_manage_epicbox_dialog.dart'; import 'desktop_delete_wallet_dialog.dart'; enum _WalletOptions { @@ -42,7 +47,9 @@ enum _WalletOptions { changeRepresentative, showXpub, frostOptions, - refreshFromHeight; + refreshFromHeight, + showSparkKey, + epicBoxSettings; String get prettyName { switch (this) { @@ -58,28 +65,46 @@ enum _WalletOptions { return "FROST settings"; case _WalletOptions.refreshFromHeight: return "Refresh height"; + case _WalletOptions.showSparkKey: + return "Show Spark View Key"; + case _WalletOptions.epicBoxSettings: + return "Epic Box settings"; } } } class WalletOptionsButton extends ConsumerWidget { - const WalletOptionsButton({ - super.key, - required this.walletId, - }); + const WalletOptionsButton({super.key, required this.walletId}); final String walletId; + Future _auth( + BuildContext context, + String message, + VoidCallback onAuth, + ) async { + final verified = await showDialog( + context: context, + builder: (context) => Util.isDesktop + ? RequestDesktopAuthDialog(title: message) + : PinpadDialog( + biometricsAuthenticationTitle: message, + biometricsLocalizedReason: "Authenticate to show view key", + biometricsCancelButtonString: "CANCEL", + ), + barrierDismissible: !Util.isDesktop, + ); + + if (verified == "verified success" && context.mounted) { + onAuth(); + } + } + @override Widget build(BuildContext context, WidgetRef ref) { return RawMaterialButton( - constraints: const BoxConstraints( - minHeight: 32, - minWidth: 32, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(1000), - ), + constraints: const BoxConstraints(minHeight: 32, minWidth: 32), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(1000)), onPressed: () async { final func = await showDialog<_WalletOptions?>( context: context, @@ -104,6 +129,9 @@ class WalletOptionsButton extends ConsumerWidget { onRefreshHeightPressed: () async { Navigator.of(context).pop(_WalletOptions.refreshFromHeight); }, + onEpicBoxSettingsPressed: () async { + Navigator.of(context).pop(_WalletOptions.epicBoxSettings); + }, walletId: walletId, ); }, @@ -145,43 +173,81 @@ class WalletOptionsButton extends ConsumerWidget { } } break; - case _WalletOptions.showXpub: - final xpubData = await showLoading( - delay: const Duration(milliseconds: 800), - whileFuture: (ref.read(pWallets).getWallet(walletId) - as ExtendedKeysInterface) - .getXPubs(), - context: context, - message: "Loading xpubs", - rootNavigator: Util.isDesktop, - ); + case _WalletOptions.showSparkKey: + await _auth(context, "Show Spark view key", () async { + final wallet = + ref.read(pWallets).getWallet(walletId) as SparkInterface; + final sparkViewKeyHex = wallet.sparkViewKey!; - if (context.mounted) { - final result = await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => Navigator( - initialRoute: XPubView.routeName, - onGenerateRoute: RouteGenerator.generateRoute, - onGenerateInitialRoutes: (_, __) { - return [ - RouteGenerator.generateRoute( - RouteSettings( - name: XPubView.routeName, - arguments: (walletId, xpubData), + if (context.mounted) { + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => Navigator( + initialRoute: SparkViewKeyView.routeName, + onGenerateRoute: RouteGenerator.generateRoute, + onGenerateInitialRoutes: (_, __) { + return [ + RouteGenerator.generateRoute( + RouteSettings( + name: SparkViewKeyView.routeName, + arguments: (walletId, sparkViewKeyHex), + ), ), - ), - ]; - }, - ), + ]; + }, + ), + ); + + if (result == true) { + if (context.mounted) { + Navigator.of(context).pop(); + } + } + } + }); + + break; + case _WalletOptions.showXpub: + await _auth(context, "Show xpub(s)", () async { + final xpubData = await showLoading( + delay: const Duration(milliseconds: 800), + whileFuture: + (ref.read(pWallets).getWallet(walletId) + as ExtendedKeysInterface) + .getXPubs(), + context: context, + message: "Loading xpubs", + rootNavigator: Util.isDesktop, ); - if (result == true) { - if (context.mounted) { - Navigator.of(context).pop(); + if (context.mounted) { + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => Navigator( + initialRoute: XPubView.routeName, + onGenerateRoute: RouteGenerator.generateRoute, + onGenerateInitialRoutes: (_, __) { + return [ + RouteGenerator.generateRoute( + RouteSettings( + name: XPubView.routeName, + arguments: (walletId, xpubData), + ), + ), + ]; + }, + ), + ); + + if (result == true) { + if (context.mounted) { + Navigator.of(context).pop(); + } } } - } + }); break; case _WalletOptions.changeRepresentative: final result = await showDialog( @@ -224,9 +290,8 @@ class WalletOptionsButton extends ConsumerWidget { unawaited( showDialog( context: context, - builder: (context) => EditRefreshHeightView( - walletId: walletId, - ), + builder: (context) => + EditRefreshHeightView(walletId: walletId), ), ); } else { @@ -238,23 +303,30 @@ class WalletOptionsButton extends ConsumerWidget { ); } break; + + case _WalletOptions.epicBoxSettings: + unawaited( + showDialog( + context: context, + builder: (context) => + DesktopManageEpicBoxDialog(walletId: walletId), + ), + ); + break; } } }, child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 19, - horizontal: 32, - ), + padding: const EdgeInsets.symmetric(vertical: 19, horizontal: 32), child: Row( children: [ SvgPicture.asset( Assets.svg.ellipsis, width: 20, height: 20, - color: Theme.of(context) - .extension()! - .buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), ], ), @@ -272,6 +344,7 @@ class WalletOptionsPopupMenu extends ConsumerWidget { required this.onChangeRepPressed, required this.onFrostMSWalletOptionsPressed, required this.onRefreshHeightPressed, + required this.onEpicBoxSettingsPressed, required this.walletId, }); @@ -281,6 +354,7 @@ class WalletOptionsPopupMenu extends ConsumerWidget { final VoidCallback onChangeRepPressed; final VoidCallback onFrostMSWalletOptionsPressed; final VoidCallback onRefreshHeightPressed; + final VoidCallback onEpicBoxSettingsPressed; final String walletId; @override @@ -290,15 +364,16 @@ class WalletOptionsPopupMenu extends ConsumerWidget { final wallet = ref.watch(pWallets).getWallet(walletId); bool xpubEnabled = wallet is ExtendedKeysInterface; - if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { - xpubEnabled = false; - } - final bool canChangeRep = coin is NanoCurrency; final bool isFrost = coin is FrostCurrency; - final bool isMoneroWow = wallet is LibMoneroWallet || wallet is LibSalviumWallet; + final bool isCN = wallet is CryptonoteWallet; + bool isSpark = wallet is SparkInterface; + if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { + xpubEnabled = false; + isSpark = false; + } return Stack( children: [ Positioned( @@ -339,23 +414,21 @@ class WalletOptionsPopupMenu extends ConsumerWidget { Expanded( child: Text( _WalletOptions.addressList.prettyName, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ), + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), ), ), ], ), ), ), - if (canChangeRep) - const SizedBox( - height: 8, - ), + if (canChangeRep) const SizedBox(height: 8), if (canChangeRep) TransparentButton( onPressed: onChangeRepPressed, @@ -376,23 +449,21 @@ class WalletOptionsPopupMenu extends ConsumerWidget { Expanded( child: Text( _WalletOptions.changeRepresentative.prettyName, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ), + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), ), ), ], ), ), ), - if (isFrost) - const SizedBox( - height: 8, - ), + if (isFrost) const SizedBox(height: 8), if (isFrost) TransparentButton( onPressed: onFrostMSWalletOptionsPressed, @@ -413,24 +484,23 @@ class WalletOptionsPopupMenu extends ConsumerWidget { Expanded( child: Text( _WalletOptions.frostOptions.prettyName, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ), + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), ), ), ], ), ), ), - if (isMoneroWow) - const SizedBox( - height: 8, - ), - if (isMoneroWow) + if (isCN || wallet is EpiccashWallet) + const SizedBox(height: 8), + if (isCN || wallet is EpiccashWallet) TransparentButton( onPressed: onRefreshHeightPressed, child: Padding( @@ -450,23 +520,56 @@ class WalletOptionsPopupMenu extends ConsumerWidget { Expanded( child: Text( _WalletOptions.refreshFromHeight.prettyName, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ), + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), ), ), ], ), ), ), - if (xpubEnabled) - const SizedBox( - height: 8, + if (wallet is EpiccashWallet) const SizedBox(height: 8), + if (wallet is EpiccashWallet) + TransparentButton( + onPressed: onEpicBoxSettingsPressed, + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SvgPicture.asset( + Assets.svg.node, + width: 20, + height: 20, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconLeft, + ), + const SizedBox(width: 14), + Expanded( + child: Text( + _WalletOptions.epicBoxSettings.prettyName, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + ), + ), + ], + ), + ), ), + if (xpubEnabled) const SizedBox(height: 8), if (xpubEnabled) TransparentButton( onPressed: onShowXpubPressed, @@ -487,22 +590,58 @@ class WalletOptionsPopupMenu extends ConsumerWidget { Expanded( child: Text( _WalletOptions.showXpub.prettyName, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ), + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), ), ), ], ), ), ), - const SizedBox( - height: 8, - ), + if (isSpark) const SizedBox(height: 8), + if (isSpark) + TransparentButton( + onPressed: () { + Navigator.of(context).pop(_WalletOptions.showSparkKey); + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SvgPicture.asset( + Assets.svg.eye, + width: 20, + height: 20, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconLeft, + ), + const SizedBox(width: 14), + Expanded( + child: Text( + _WalletOptions.showSparkKey.prettyName, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), TransparentButton( onPressed: onDeletePressed, child: Padding( @@ -522,13 +661,14 @@ class WalletOptionsPopupMenu extends ConsumerWidget { Expanded( child: Text( _WalletOptions.deleteWallet.prettyName, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ), + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), ), ), ], @@ -546,11 +686,7 @@ class WalletOptionsPopupMenu extends ConsumerWidget { } class TransparentButton extends StatelessWidget { - const TransparentButton({ - super.key, - required this.child, - this.onPressed, - }); + const TransparentButton({super.key, required this.child, this.onPressed}); final Widget child; final VoidCallback? onPressed; @@ -558,10 +694,7 @@ class TransparentButton extends StatelessWidget { @override Widget build(BuildContext context) { return RawMaterialButton( - constraints: const BoxConstraints( - minHeight: 32, - minWidth: 32, - ), + constraints: const BoxConstraints(minHeight: 32, minWidth: 32), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( diff --git a/lib/pages_desktop_specific/notifications/desktop_notifications_view.dart b/lib/pages_desktop_specific/notifications/desktop_notifications_view.dart index e4c04fa503..7b7f948dfe 100644 --- a/lib/pages_desktop_specific/notifications/desktop_notifications_view.dart +++ b/lib/pages_desktop_specific/notifications/desktop_notifications_view.dart @@ -8,11 +8,15 @@ * */ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../notifications/notification_card.dart'; -import '../../providers/providers.dart'; -import '../../providers/ui/unread_notifications_provider.dart'; + +import '../../notifications/notification_feed_entry_card.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../providers/ui/notification_feed_provider.dart'; +import '../../services/shopinbit/shopinbit_service.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; import '../../widgets/desktop/desktop_app_bar.dart'; @@ -31,10 +35,25 @@ class DesktopNotificationsView extends ConsumerStatefulWidget { class _DesktopNotificationsViewState extends ConsumerState { + late final ShopInBitService _shopInBitService; + + @override + void initState() { + super.initState(); + _shopInBitService = ref.read(pShopinBitService); + } + + @override + void dispose() { + // Viewing the (always-global) desktop list acknowledges ShopinBit + // notifications, clearing the bell/feed like the wallet ones. + unawaited(_shopInBitService.markAllNotificationsRead()); + super.dispose(); + } + @override Widget build(BuildContext context) { - final notifications = - ref.watch(notificationsProvider.select((value) => value.notifications)); + final entries = ref.watch(pNotificationFeed(null)); return DesktopScaffold( background: Theme.of(context).extension()!.background, @@ -42,13 +61,10 @@ class _DesktopNotificationsViewState isCompactHeight: true, leading: Padding( padding: const EdgeInsets.only(left: 24), - child: Text( - "Notifications", - style: STextStyles.desktopH3(context), - ), + child: Text("Notifications", style: STextStyles.desktopH3(context)), ), ), - body: notifications.isEmpty + body: entries.isEmpty ? Column( children: [ Padding( @@ -66,24 +82,14 @@ class _DesktopNotificationsViewState ) : ListView.builder( primary: false, - itemCount: notifications.length, + itemCount: entries.length, itemBuilder: (context, index) { - final notification = notifications[index]; - if (notification.read == false) { - ref - .read(unreadNotificationsStateProvider.state) - .state - .add(notification.id); - } - return Padding( padding: const EdgeInsets.symmetric( horizontal: 24, vertical: 5, ), - child: NotificationCard( - notification: notification, - ), + child: NotificationFeedEntryCard(entry: entries[index]), ); }, ), diff --git a/lib/pages_desktop_specific/ordinals/desktop_ordinal_details_view.dart b/lib/pages_desktop_specific/ordinals/desktop_ordinal_details_view.dart index 6c2a12e1d3..600ebc9c52 100644 --- a/lib/pages_desktop_specific/ordinals/desktop_ordinal_details_view.dart +++ b/lib/pages_desktop_specific/ordinals/desktop_ordinal_details_view.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -11,10 +12,13 @@ import '../../models/isar/models/blockchain_data/utxo.dart'; import '../../models/isar/ordinal.dart'; import '../../networking/http.dart'; import '../../notifications/show_flush_bar.dart'; +import '../../pages/ordinals/widgets/dialogs.dart'; +import '../../pages/send_view/confirm_transaction_view.dart'; import '../../pages/wallet_view/transaction_views/transaction_details_view.dart'; import '../../providers/db/main_db_provider.dart'; import '../../providers/global/wallets_provider.dart'; import '../../services/tor_service.dart'; +import '../desktop_home_view.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; @@ -22,12 +26,15 @@ import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; import '../../utilities/prefs.dart'; import '../../utilities/show_loading.dart'; -import '../../utilities/stack_file_system.dart'; import '../../utilities/text_styles.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_app_bar.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/desktop_scaffold.dart'; +import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/ordinal_image.dart'; import '../../widgets/rounded_white_container.dart'; class DesktopOrdinalDetailsView extends ConsumerStatefulWidget { @@ -73,9 +80,7 @@ class _DesktopOrdinalDetailsViewState final bytes = response.bodyBytes; - final dir = Platform.isAndroid - ? await StackFileSystem.wtfAndroidDocumentsPath() - : await getApplicationDocumentsDirectory(); + final dir = await getApplicationDocumentsDirectory(); final filePath = path.join( dir.path, @@ -144,14 +149,7 @@ class _DesktopOrdinalDetailsViewState borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), - child: Image.network( - widget - .ordinal - .content, // Use the preview URL as the image source - fit: BoxFit.cover, - filterQuality: - FilterQuality.none, // Set the filter mode to nearest - ), + child: OrdinalImage(url: widget.ordinal.content), ), ), const SizedBox(width: 16), @@ -178,33 +176,140 @@ class _DesktopOrdinalDetailsViewState ), ), const SizedBox(width: 16), - // PrimaryButton( - // width: 150, - // label: "Send", - // icon: SvgPicture.asset( - // Assets.svg.send, - // width: 18, - // height: 18, - // color: Theme.of(context) - // .extension()! - // .buttonTextPrimary, - // ), - // buttonHeight: ButtonHeight.l, - // iconSpacing: 8, - // onPressed: () async { - // final response = await showDialog( - // context: context, - // builder: (_) => - // const SendOrdinalUnfreezeDialog(), - // ); - // if (response == "unfreeze") { - // // TODO: unfreeze and go to send ord screen - // } - // }, - // ), - // const SizedBox( - // width: 16, - // ), + PrimaryButton( + width: 150, + label: "Send", + icon: SvgPicture.asset( + Assets.svg.send, + width: 18, + height: 18, + color: Theme.of( + context, + ).extension()!.buttonTextPrimary, + ), + buttonHeight: ButtonHeight.l, + iconSpacing: 8, + onPressed: () async { + final utxo = widget.ordinal.getUTXO( + ref.read(mainDBProvider), + ); + if (utxo == null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Could not find ordinal UTXO", + context: context, + ), + ); + return; + } + + if (utxo.isBlocked) { + final unfreezeResponse = + await showDialog( + context: context, + builder: (_) => + const SendOrdinalUnfreezeDialog(), + ); + if (unfreezeResponse != "unfreeze") return; + } + + if (!context.mounted) return; + + final address = await showDialog( + context: context, + builder: (_) => OrdinalRecipientAddressDialog( + inscriptionNumber: + widget.ordinal.inscriptionNumber, + ), + ); + if (address == null || address.isEmpty) return; + + final wallet = ref + .read(pWallets) + .getWallet(widget.walletId); + if (!wallet.cryptoCurrency.validateAddress( + address, + )) { + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid address", + context: context, + ), + ); + } + return; + } + + if (!context.mounted) return; + + final OrdinalsInterface? ordinalsWallet = + wallet is OrdinalsInterface ? wallet : null; + if (ordinalsWallet == null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "Wallet does not support ordinals", + context: context, + ), + ); + return; + } + + bool didError = false; + final txData = await showLoading( + whileFuture: ordinalsWallet + .prepareOrdinalSend( + ordinalUtxo: utxo, + recipientAddress: address, + ), + context: context, + rootNavigator: true, + message: "Preparing transaction...", + onException: (e) { + didError = true; + String msg = e.toString(); + while (msg.isNotEmpty && + msg.startsWith("Exception:")) { + msg = msg.substring(10).trim(); + } + if (context.mounted) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: msg, + context: context, + ); + } + }, + ); + + if (didError || + txData == null || + !context.mounted) { + return; + } + + await showDialog( + context: context, + builder: (context) => DesktopDialog( + maxHeight: + MediaQuery.of(context).size.height - 64, + maxWidth: 580, + child: ConfirmTransactionView( + walletId: widget.walletId, + txData: txData, + routeOnSuccessName: + DesktopHomeView.routeName, + onSuccess: () {}, + ), + ), + ); + }, + ), + const SizedBox(width: 16), SecondaryButton( width: 150, label: "Download", diff --git a/lib/pages_desktop_specific/password/forgotten_passphrase_restore_from_swb.dart b/lib/pages_desktop_specific/password/forgotten_passphrase_restore_from_swb.dart index 161459e439..f4f4de39e8 100644 --- a/lib/pages_desktop_specific/password/forgotten_passphrase_restore_from_swb.dart +++ b/lib/pages_desktop_specific/password/forgotten_passphrase_restore_from_swb.dart @@ -15,7 +15,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:tuple/tuple.dart'; import '../../app_config.dart'; import '../../db/hive/db.dart'; @@ -96,29 +95,26 @@ class _ForgottenPassphraseRestoreFromSWBState child: Text( "Decrypting ${AppConfig.prefix} backup file", style: STextStyles.pageTitleH2(context).copyWith( - color: - Theme.of(context).extension()!.textWhite, + color: Theme.of( + context, + ).extension()!.textWhite, ), ), ), ), - const SizedBox( - height: 64, - ), - const Center( - child: LoadingIndicator( - width: 100, - ), - ), + const SizedBox(height: 64), + const Center(child: LoadingIndicator(width: 100)), ], ), ), ), ); + final content = await File(fileToRestore).readAsString(); + final String? jsonString = await compute( - SWB.decryptStackWalletWithPassphrase, - Tuple2(fileToRestore, passphrase), + SWB.decryptStackWalletStringWithPassphrase, + (encryptedText: content, passphrase: passphrase), debugLabel: "${AppConfig.appName} decryption compute", ); @@ -161,9 +157,7 @@ class _ForgottenPassphraseRestoreFromSWBState ), ], ), - const SizedBox( - height: 44, - ), + const SizedBox(height: 44), Flexible( child: StackRestoreProgressView( jsonString: jsonString, @@ -220,8 +214,9 @@ class _ForgottenPassphraseRestoreFromSWBState ref.refresh(storageCryptoHandlerProvider); await DB.instance.init(); if (mounted) { - Navigator.of(context) - .popUntil(ModalRoute.withName(CreatePasswordView.routeName)); + Navigator.of( + context, + ).popUntil(ModalRoute.withName(CreatePasswordView.routeName)); Navigator.of(context).pop(); } }, @@ -241,34 +236,29 @@ class _ForgottenPassphraseRestoreFromSWBState "Restore from backup", style: STextStyles.desktopH1(context), ), - const SizedBox( - height: 32, - ), + const SizedBox(height: 32), Text( "Use your ${AppConfig.prefix} backup file to restore your wallets, address book, and wallet preferences.", textAlign: TextAlign.center, style: STextStyles.desktopTextSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), ), - const SizedBox( - height: 40, - ), + const SizedBox(height: 40), GestureDetector( onTap: () async { try { await stackFileSystem.prepareStorage(); if (mounted) { - await stackFileSystem.openFile(context); - } + final filePath = await stackFileSystem.openFile(); - if (mounted) { - setState(() { - fileLocationController.text = - stackFileSystem.filePath ?? ""; - }); + if (mounted) { + setState(() { + fileLocationController.text = filePath ?? ""; + }); + } } } catch (e, s) { Logging.instance.e("$e\n$s", error: e, stackTrace: s); @@ -290,20 +280,16 @@ class _ForgottenPassphraseRestoreFromSWBState child: UnconstrainedBox( child: Row( children: [ - const SizedBox( - width: 24, - ), + const SizedBox(width: 24), SvgPicture.asset( Assets.svg.folder, - color: Theme.of(context) - .extension()! - .textDark3, + color: Theme.of( + context, + ).extension()!.textDark3, width: 24, height: 24, ), - const SizedBox( - width: 12, - ), + const SizedBox(width: 12), ], ), ), @@ -321,16 +307,14 @@ class _ForgottenPassphraseRestoreFromSWBState setState(() { _enableButton = passwordController.text.isNotEmpty && - fileLocationController.text.isNotEmpty; + fileLocationController.text.isNotEmpty; }); }, ), ), ), ), - const SizedBox( - height: 16, - ), + const SizedBox(height: 16), ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -339,67 +323,63 @@ class _ForgottenPassphraseRestoreFromSWBState key: const Key("restoreFromFilePasswordFieldKey"), focusNode: passwordFocusNode, controller: passwordController, - style: STextStyles.desktopTextMedium(context).copyWith( - height: 2, - ), + style: STextStyles.desktopTextMedium( + context, + ).copyWith(height: 2), obscureText: hidePassword, enableSuggestions: false, autocorrect: false, - decoration: standardInputDecoration( - "Enter passphrase", - passwordFocusNode, - context, - ).copyWith( - suffixIcon: UnconstrainedBox( - child: SizedBox( - height: 70, - child: Row( - children: [ - const SizedBox( - width: 24, - ), - GestureDetector( - key: const Key( - "restoreFromFilePasswordFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: SvgPicture.asset( - hidePassword - ? Assets.svg.eye - : Assets.svg.eyeSlash, - color: Theme.of(context) - .extension()! - .textDark3, - width: 24, - height: 24, + decoration: + standardInputDecoration( + "Enter passphrase", + passwordFocusNode, + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: SizedBox( + height: 70, + child: Row( + children: [ + const SizedBox(width: 24), + GestureDetector( + key: const Key( + "restoreFromFilePasswordFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of( + context, + ).extension()!.textDark3, + width: 24, + height: 24, + ), + ), ), - ), - ), - const SizedBox( - width: 12, + const SizedBox(width: 12), + ], ), - ], + ), ), ), - ), - ), onChanged: (newValue) { setState(() { - _enableButton = passwordController.text.isNotEmpty && + _enableButton = + passwordController.text.isNotEmpty && fileLocationController.text.isNotEmpty; }); }, ), ), - const SizedBox( - height: 24, - ), + const SizedBox(height: 24), PrimaryButton( label: "Restore", enabled: _enableButton, @@ -407,9 +387,7 @@ class _ForgottenPassphraseRestoreFromSWBState restore(); }, ), - const SizedBox( - height: kDesktopAppBarHeight, - ), + const SizedBox(height: kDesktopAppBarHeight), ], ), ), diff --git a/lib/pages_desktop_specific/services/cakepay/desktop_gift_cards_view.dart b/lib/pages_desktop_specific/services/cakepay/desktop_gift_cards_view.dart new file mode 100644 index 0000000000..150e6f63b7 --- /dev/null +++ b/lib/pages_desktop_specific/services/cakepay/desktop_gift_cards_view.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../app_config.dart'; +import '../../../pages/cakepay/cakepay_orders_view.dart'; +import '../../../pages/cakepay/cakepay_vendors_view.dart'; +import '../../../services/event_bus/events/global/tor_connection_status_changed_event.dart'; +import '../../../services/tor_service.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart'; +import '../../../widgets/icon_widgets/credit_card_icon.dart'; +import '../../../widgets/rounded_white_container.dart'; +import '../../../widgets/tor_subscription.dart'; + +class DesktopGiftCardsView extends ConsumerStatefulWidget { + const DesktopGiftCardsView({super.key}); + + static const String routeName = "/desktopGiftCardsView"; + + @override + ConsumerState createState() => + _DesktopGiftCardsViewState(); +} + +class _DesktopGiftCardsViewState extends ConsumerState { + late bool _torEnabled; + + @override + void initState() { + _torEnabled = AppConfig.hasFeature(AppFeature.tor) + ? ref.read(pTorService).status != TorConnectionStatus.disconnected + : false; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return TorSubscription( + onTorStatusChanged: (status) { + setState(() { + _torEnabled = status != TorConnectionStatus.disconnected; + }); + }, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(right: 30), + child: RoundedWhiteContainer( + radiusMultiplier: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.all(8.0), + child: CreditCardIcon(width: 48, height: 48), + ), + Padding( + padding: const EdgeInsets.all(10), + child: RichText( + textAlign: TextAlign.start, + text: TextSpan( + children: [ + TextSpan( + text: "CakePay", + style: STextStyles.desktopTextSmall(context), + ), + TextSpan( + text: + "\n\nPurchase gift cards with cryptocurrency.", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + ], + ), + ), + ), + if (_torEnabled) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text( + "CakePay is not available while Tor is enabled", + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + child: Row( + children: [ + PrimaryButton( + width: 220, + buttonHeight: ButtonHeight.m, + label: "Browse Gift Cards", + enabled: !_torEnabled, + onPressed: () { + showDialog( + context: context, + builder: (_) => const NestedNavigatorDialog( + initialRoute: CakePayVendorsView.routeName, + ), + ); + }, + ), + const SizedBox(width: 16), + SecondaryButton( + width: 200, + buttonHeight: ButtonHeight.m, + label: "My Orders", + enabled: !_torEnabled, + onPressed: () { + showDialog( + context: context, + builder: (_) => const NestedNavigatorDialog( + initialRoute: CakePayOrdersView.routeName, + ), + ); + }, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages_desktop_specific/services/desktop_services_view.dart b/lib/pages_desktop_specific/services/desktop_services_view.dart new file mode 100644 index 0000000000..7a24d94aeb --- /dev/null +++ b/lib/pages_desktop_specific/services/desktop_services_view.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../app_config.dart'; +import '../../route_generator.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/text_styles.dart'; +import '../../widgets/desktop/desktop_app_bar.dart'; +import '../../widgets/desktop/desktop_scaffold.dart'; +import '../settings/settings_menu_item.dart'; +import 'cakepay/desktop_gift_cards_view.dart'; +import 'shopin_bit/desktop_shopinbit_view.dart'; + +final _selectedServicesMenuItemStateProvider = StateProvider<_MenuItem?>( + (_) => _labels.firstOrNull, +); + +enum _MenuItem { + shopinBit("Services"), + cakePay("Gift Cards"); + + final String value; + const _MenuItem(this.value); +} + +final _labels = [ + if (AppConfig.hasFeature(.shopinBit)) _MenuItem.shopinBit, + if (AppConfig.hasFeature(.cakePay)) _MenuItem.cakePay, +]; + +class DesktopServicesView extends ConsumerStatefulWidget { + const DesktopServicesView({super.key}); + + static const String routeName = "/desktopServicesView"; + + @override + ConsumerState createState() => + _DesktopServicesViewState(); +} + +class _DesktopServicesViewState extends ConsumerState { + @override + Widget build(BuildContext context) { + final Map<_MenuItem, Widget> contentViews = { + if (AppConfig.hasFeature(.shopinBit)) + .shopinBit: const Navigator( + key: Key("servicesShopInBitDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: DesktopShopInBitView.routeName, + ), + if (AppConfig.hasFeature(.cakePay)) + .cakePay: const Navigator( + key: Key("servicesGiftCardsDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: DesktopGiftCardsView.routeName, + ), + }; + + return DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: true, + leading: Row( + children: [ + const SizedBox(width: 24, height: 24), + Text("Services", style: STextStyles.desktopH3(context)), + ], + ), + ), + body: Row( + children: [ + Padding( + padding: const EdgeInsets.all(15.0), + child: Align( + alignment: Alignment.topLeft, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 250, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ..._labels.map( + (label) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + SettingsMenuItem<_MenuItem?>( + icon: SvgPicture.asset( + Assets.svg.polygon, + width: 11, + height: 11, + color: + ref + .watch( + _selectedServicesMenuItemStateProvider + .state, + ) + .state == + label + ? Theme.of(context) + .extension()! + .accentColorBlue + : Colors.transparent, + ), + label: label.value, + value: label, + group: ref + .watch( + _selectedServicesMenuItemStateProvider + .state, + ) + .state, + onChanged: (newValue) => + ref + .read( + _selectedServicesMenuItemStateProvider + .state, + ) + .state = + newValue, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + Expanded( + child: + contentViews[ref + .watch(_selectedServicesMenuItemStateProvider.state) + .state]!, + ), + ], + ), + ); + } +} diff --git a/lib/pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dart b/lib/pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dart new file mode 100644 index 0000000000..310c8d60fb --- /dev/null +++ b/lib/pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dart @@ -0,0 +1,403 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../../app_config.dart'; +import '../../../notifications/show_flush_bar.dart'; +import '../../../pages/shopinbit/shopinbit_step_2.dart'; +import '../../../pages/shopinbit/shopinbit_tickets_view.dart'; +import '../../../providers/db/drift_provider.dart'; +import '../../../providers/desktop/current_desktop_menu_item.dart'; +import '../../../providers/global/shopin_bit_service_provider.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/assets.dart'; +import '../../../utilities/show_loading.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../widgets/desktop/desktop_dialog.dart'; +import '../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart'; +import '../../../widgets/dialogs/request_external_link_navigation_dialog.dart'; +import '../../../widgets/rounded_container.dart'; +import '../../../widgets/rounded_white_container.dart'; +import '../../desktop_menu.dart'; +import '../../settings/settings_menu.dart'; +import 'sub_widgets/desktop_shopin_bit_first_run.dart'; + +class DesktopShopInBitView extends ConsumerStatefulWidget { + const DesktopShopInBitView({super.key}); + + static const String routeName = "/desktopShopInBitView"; + + @override + ConsumerState createState() => + _DesktopServicesViewState(); +} + +class _DesktopServicesViewState extends ConsumerState { + Future _showShopDialog() async { + final dao = ref.read(pSharedDrift).shopInBitSettingsDao; + final settings = await dao.getCurrentSettings(); + bool isFirstRun = false; + + if (settings == null || !settings.setupComplete) { + // something went wrong + if (!mounted) return; + + // First-time user: show setup. + final completed = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const _ShopInBitDesktopSetupDialog(), + ); + if (completed != true) return; // user cancelled + isFirstRun = true; + } + + if (!mounted) return; + + if (isFirstRun) { + // First run: show service overview then go directly to Step2 + // (name was just entered in setup dialog, no need to show Step1 again). + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const NestedNavigatorDialog( + initialRoute: DesktopShopinBitFirstRun.routeName, + ), + ); + } else { + // Returning user: go directly to Step2 (skip service overview dialog + // and the redundant display-name prompt; name is already loaded from + // settings into model). + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const NestedNavigatorDialog( + initialRoute: ShopInBitStep2.routeName, + initialRouteArgs: true, + ), + ); + + // TODO: figure out and comment why this is needed + if (mounted) setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.only(right: 30), + child: RoundedWhiteContainer( + radiusMultiplier: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + decoration: BoxDecoration( + color: const Color(0xFFE0E3E3), + borderRadius: .circular(54), + ), + width: 54, + height: 54, + child: Center( + child: SizedBox( + width: 38, + height: 38, + child: SvgPicture.asset( + Assets.svg.sib, + colorFilter: const .mode(Colors.black, .srcIn), + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(10), + child: RichText( + textAlign: TextAlign.start, + text: TextSpan( + style: STextStyles.desktopTextExtraExtraSmall(context), + children: [ + TextSpan( + text: "ShopinBit", + style: STextStyles.desktopTextSmall(context), + ), + const TextSpan( + text: + "\n\n" + "Spend crypto privately in the real world.\n" + "A global concierge service, handled by real " + "humans, built around your privacy. Turn crypto" + " into flights, cars, electronics or almost " + "anything else, legally." + "\n\n" + "Minimum order value of 1,000 EUR. " + "A 10% service fee applies to all orders.\n\n" + "By using ShopinBit, you agree to their ", + ), + TextSpan( + text: "Terms & Conditions", + style: STextStyles.richLink( + context, + ).copyWith(fontSize: 14), + recognizer: TapGestureRecognizer() + ..onTap = () async { + const url = + "https://api.shopinbit.com/static/policy/terms.html"; + await showRequestExternalLinkAndMaybeLaunch( + context, + uri: Uri.parse(url), + ); + }, + ), + const TextSpan(text: " and "), + TextSpan( + text: "Privacy Policy", + style: STextStyles.richLink( + context, + ).copyWith(fontSize: 14), + recognizer: TapGestureRecognizer() + ..onTap = () async { + const url = + "https://api.shopinbit.com/static/policy/privacy.html"; + + await showRequestExternalLinkAndMaybeLaunch( + context, + uri: Uri.parse(url), + ); + }, + ), + const TextSpan(text: "."), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(10), + child: Row( + children: [ + PrimaryButton( + width: 224, + buttonHeight: ButtonHeight.m, + enabled: true, + label: "Shop with ShopinBit", + onPressed: _showShopDialog, + ), + const SizedBox(width: 16), + SecondaryButton( + width: 196, + buttonHeight: ButtonHeight.m, + label: "My requests", + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const NestedNavigatorDialog( + initialRoute: ShopInBitTicketsView.routeName, + ), + ); + if (mounted) setState(() {}); + }, + ), + const SizedBox(width: 16), + SecondaryButton( + width: 118, + buttonHeight: ButtonHeight.m, + label: "Settings", + onPressed: () { + // ShopInBit is the last settings menu item. + var idx = 8; + if (AppConfig.hasFeature(AppFeature.themeSelection)) { + idx++; + } + ref + .read( + selectedSettingsMenuItemStateProvider.state, + ) + .state = + idx; + ref.read(currentDesktopMenuItemProvider.state).state = + DesktopMenuItemId.settings; + }, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _ShopInBitDesktopSetupDialog extends ConsumerStatefulWidget { + const _ShopInBitDesktopSetupDialog(); + + @override + ConsumerState<_ShopInBitDesktopSetupDialog> createState() => + _ShopInBitDesktopSetupDialogState(); +} + +class _ShopInBitDesktopSetupDialogState + extends ConsumerState<_ShopInBitDesktopSetupDialog> { + late final Future _keyFuture; + String? _key; + + @override + void initState() { + super.initState(); + _keyFuture = ref.read(pShopinBitService).ensureCustomerKey(); + () async { + final key = await _keyFuture; + if (mounted) setState(() => _key = key); + }(); + } + + Future _completeSetup() async { + final dao = ref.read(pSharedDrift).shopInBitSettingsDao; + await showLoading( + context: context, + message: "Saving...", + whileFuture: () async { + final settings = await dao.getCurrentSettings(); + if (settings == null) { + throw Exception("Devs pls clean this up"); + } + + await dao.setSetupComplete(settings.customerKey, true); + }(), + ); + + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(true); + } + } + + @override + Widget build(BuildContext context) { + final maxDialogHeight = MediaQuery.sizeOf(context).height - 64; + return DesktopDialog( + maxWidth: 580, + maxHeight: maxDialogHeight, + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "ShopinBit Setup", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + const SizedBox(height: 16), + Text( + "Your Customer Key", + style: STextStyles.w600_20(context), + ), + const SizedBox(height: 8), + Text( + "This is your ShopinBit customer key: save it " + "somewhere safe, you'll need it to recover " + "your ShopinBit account on a new device.", + style: STextStyles.desktopTextExtraExtraSmall(context), + ), + const SizedBox(height: 12), + FutureBuilder( + future: _keyFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return RoundedContainer( + color: Theme.of( + context, + ).extension()!.warningBackground, + child: Text( + "Failed to generate key. Please try again.", + style: STextStyles.label700(context).copyWith( + color: Theme.of( + context, + ).extension()!.warningForeground, + fontSize: 14, + ), + ), + ); + } + final key = snapshot.data!; + return RoundedWhiteContainer( + borderColor: Theme.of( + context, + ).extension()!.textSubtitle6, + child: Row( + children: [ + const SizedBox(width: 10), + Expanded( + child: SelectableText( + key, + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy, size: 20), + onPressed: () { + Clipboard.setData(ClipboardData(text: key)); + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard!", + context: context, + ); + }, + ), + ], + ), + ); + }, + ), + const SizedBox(height: 40), + Row( + mainAxisAlignment: .end, + children: [ + PrimaryButton( + label: "Complete Setup", + enabled: _key != null, + onPressed: _key != null ? _completeSetup : null, + horizontalContentPadding: 20, + ), + ], + ), + const SizedBox(height: 32), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart b/lib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart new file mode 100644 index 0000000000..ba16219323 --- /dev/null +++ b/lib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import '../../../../pages/shopinbit/shopinbit_step_2.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../widgets/desktop/primary_button.dart'; +import '../../../../widgets/dialogs/s_dialog.dart'; + +class DesktopShopinBitFirstRun extends StatelessWidget { + const DesktopShopinBitFirstRun({super.key}); + + static const routeName = "/desktopShopinBitFirstRun"; + + @override + Widget build(BuildContext context) { + return SDialog( + child: SizedBox( + width: 500, + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("ShopinBit", style: STextStyles.desktopH2(context)), + const SizedBox(height: 24), + RichText( + text: TextSpan( + style: STextStyles.desktopTextSmall(context), + children: const [ + TextSpan( + text: + "Please note the following before proceeding:" + "\n\n \u2022 Minimum order amount: 1,000 EUR" + "\n \u2022 Service fee: 10% of the order total", + ), + ], + ), + ), + const SizedBox(height: 48), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Spacer(), + Expanded( + child: PrimaryButton( + buttonHeight: ButtonHeight.l, + label: "Continue", + onPressed: () => Navigator.of( + context, + ).pushReplacementNamed(ShopInBitStep2.routeName), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages_desktop_specific/settings/desktop_settings_view.dart b/lib/pages_desktop_specific/settings/desktop_settings_view.dart index d0747f7b63..f60952ec20 100644 --- a/lib/pages_desktop_specific/settings/desktop_settings_view.dart +++ b/lib/pages_desktop_specific/settings/desktop_settings_view.dart @@ -12,8 +12,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app_config.dart'; +import '../../pages/shopinbit/shopinbit_settings_view.dart'; import '../../route_generator.dart'; import '../../themes/stack_colors.dart'; +import '../../utilities/constants.dart'; import '../../utilities/text_styles.dart'; import '../../widgets/desktop/desktop_app_bar.dart'; import '../../widgets/desktop/desktop_scaffold.dart'; @@ -39,69 +41,68 @@ class DesktopSettingsView extends ConsumerStatefulWidget { } class _DesktopSettingsViewState extends ConsumerState { - final List contentViews = [ - const Navigator( - key: Key("settingsBackupRestoreDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: BackupRestoreSettings.routeName, - ), //b+r - const Navigator( - key: Key("settingsSecurityDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: SecuritySettings.routeName, - ), //security - const Navigator( - key: Key("settingsCurrencyDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: CurrencySettings.routeName, - ), //currency - const Navigator( - key: Key("settingsLanguageDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: LanguageOptionSettings.routeName, - ), - const Navigator( - key: Key("settingsTorDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: TorSettings.routeName, - ), //tor - const Navigator( - key: Key("settingsNodesDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: NodesSettings.routeName, - ), //nodes - const Navigator( - key: Key("settingsSyncingPreferencesDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: SyncingPreferencesSettings.routeName, - ), //syncing prefs - if (AppConfig.hasFeature(AppFeature.themeSelection)) - const Navigator( - key: Key("settingsAppearanceDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: AppearanceOptionSettings.routeName, - ), //appearance - const Navigator( - key: Key("settingsAdvancedDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: AdvancedSettings.routeName, - ), //advanced - ]; - @override Widget build(BuildContext context) { + final List contentViews = [ + const Navigator( + key: Key("settingsBackupRestoreDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: BackupRestoreSettings.routeName, + ), //b+r + const Navigator( + key: Key("settingsSecurityDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: SecuritySettings.routeName, + ), //security + const Navigator( + key: Key("settingsCurrencyDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: CurrencySettings.routeName, + ), //currency + const Navigator( + key: Key("settingsLanguageDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: LanguageOptionSettings.routeName, + ), + const Navigator( + key: Key("settingsTorDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: TorSettings.routeName, + ), //tor + const Navigator( + key: Key("settingsNodesDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: NodesSettings.routeName, + ), //nodes + const Navigator( + key: Key("settingsSyncingPreferencesDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: SyncingPreferencesSettings.routeName, + ), //syncing prefs + if (AppConfig.hasFeature(AppFeature.themeSelection)) + const Navigator( + key: Key("settingsAppearanceDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: AppearanceOptionSettings.routeName, + ), //appearance + const Navigator( + key: Key("settingsAdvancedDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: AdvancedSettings.routeName, + ), //advanced + if (Constants.enableExchange && AppConfig.hasFeature(.shopinBit)) + const Navigator( + key: Key("settingsShopInBitDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: ShopInBitSettingsView.routeName, + ), //shopinbit + ]; return DesktopScaffold( background: Theme.of(context).extension()!.background, appBar: const DesktopAppBar( isCompactHeight: true, leading: Row( - children: [ - SizedBox( - width: 24, - height: 24, - ), - DesktopSettingsTitle(), - ], + children: [SizedBox(width: 24, height: 24), DesktopSettingsTitle()], ), ), body: Row( @@ -110,14 +111,14 @@ class _DesktopSettingsViewState extends ConsumerState { padding: EdgeInsets.all(15.0), child: Align( alignment: Alignment.topLeft, - child: SingleChildScrollView( - child: SettingsMenu(), - ), + child: SingleChildScrollView(child: SettingsMenu()), ), ), Expanded( - child: contentViews[ - ref.watch(selectedSettingsMenuItemStateProvider.state).state], + child: + contentViews[ref + .watch(selectedSettingsMenuItemStateProvider.state) + .state], ), ], ), @@ -130,9 +131,6 @@ class DesktopSettingsTitle extends StatelessWidget { @override Widget build(BuildContext context) { - return Text( - "Settings", - style: STextStyles.desktopH3(context), - ); + return Text("Settings", style: STextStyles.desktopH3(context)); } } diff --git a/lib/pages_desktop_specific/settings/settings_menu.dart b/lib/pages_desktop_specific/settings/settings_menu.dart index 1ec12e5f65..619c97f9ec 100644 --- a/lib/pages_desktop_specific/settings/settings_menu.dart +++ b/lib/pages_desktop_specific/settings/settings_menu.dart @@ -15,36 +15,37 @@ import 'package:flutter_svg/svg.dart'; import '../../app_config.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; +import '../../utilities/constants.dart'; import 'settings_menu_item.dart'; final selectedSettingsMenuItemStateProvider = StateProvider((_) => 0); class SettingsMenu extends ConsumerStatefulWidget { - const SettingsMenu({ - super.key, - }); + const SettingsMenu({super.key}); @override ConsumerState createState() => _SettingsMenuState(); } class _SettingsMenuState extends ConsumerState { - final List labels = [ - "Backup and restore", - "Security", - "Currency", - "Language", - "Tor settings", - "Nodes", - "Syncing preferences", - if (AppConfig.hasFeature(AppFeature.themeSelection)) "Appearance", - "Advanced", - ]; - @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + final List labels = [ + "Backup and restore", + "Security", + "Currency", + "Language", + "Tor settings", + "Nodes", + "Syncing preferences", + if (AppConfig.hasFeature(AppFeature.themeSelection)) "Appearance", + "Advanced", + if (Constants.enableExchange && AppConfig.hasFeature(.shopinBit)) + "ShopinBit", + ]; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -57,25 +58,23 @@ class _SettingsMenuState extends ConsumerState { Column( mainAxisSize: MainAxisSize.min, children: [ - if (i > 0) - const SizedBox( - height: 2, - ), + if (i > 0) const SizedBox(height: 2), SettingsMenuItem( icon: SvgPicture.asset( Assets.svg.polygon, width: 11, height: 11, - color: ref + color: + ref .watch( selectedSettingsMenuItemStateProvider .state, ) .state == i - ? Theme.of(context) - .extension()! - .accentColorBlue + ? Theme.of( + context, + ).extension()!.accentColorBlue : Colors.transparent, ), label: labels[i], @@ -83,9 +82,13 @@ class _SettingsMenuState extends ConsumerState { group: ref .watch(selectedSettingsMenuItemStateProvider.state) .state, - onChanged: (newValue) => ref - .read(selectedSettingsMenuItemStateProvider.state) - .state = newValue, + onChanged: (newValue) => + ref + .read( + selectedSettingsMenuItemStateProvider.state, + ) + .state = + newValue, ), ], ), diff --git a/lib/pages_desktop_specific/settings/settings_menu/backup_and_restore/create_auto_backup.dart b/lib/pages_desktop_specific/settings/settings_menu/backup_and_restore/create_auto_backup.dart index 3ffea65084..1a0fc67533 100644 --- a/lib/pages_desktop_specific/settings/settings_menu/backup_and_restore/create_auto_backup.dart +++ b/lib/pages_desktop_specific/settings/settings_menu/backup_and_restore/create_auto_backup.dart @@ -21,7 +21,6 @@ import 'package:stack_wallet_backup/stack_wallet_backup.dart'; import 'package:zxcvbn/zxcvbn.dart'; import '../../../../app_config.dart'; -import '../../../../notifications/show_flush_bar.dart'; import '../../../../pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart'; import '../../../../pages/settings_views/global_settings_view/stack_backup_views/helpers/swb_file_system.dart'; import '../../../../providers/global/prefs_provider.dart'; @@ -32,7 +31,9 @@ import '../../../../utilities/constants.dart'; import '../../../../utilities/enums/backup_frequency_type.dart'; import '../../../../utilities/flutter_secure_storage_interface.dart'; import '../../../../utilities/format.dart'; +import '../../../../utilities/fs.dart'; import '../../../../utilities/logger.dart'; +import '../../../../utilities/show_loading.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; @@ -44,9 +45,7 @@ import '../../../../widgets/stack_dialog.dart'; import '../../../../widgets/stack_text_field.dart'; class CreateAutoBackup extends ConsumerStatefulWidget { - const CreateAutoBackup({ - super.key, - }); + const CreateAutoBackup({super.key}); @override ConsumerState createState() => _CreateAutoBackup(); @@ -89,6 +88,141 @@ class _CreateAutoBackup extends ConsumerState { BackupFrequencyType.afterClosingAWallet, ]; + Future _enableAutoBackup() async { + final String pathToSave = fileLocationController.text; + final String passphrase = passphraseController.text; + final String repeatPassphrase = passphraseRepeatController.text; + + if (validateFail(context, pathToSave, passphrase, repeatPassphrase)) return; + + if (mounted) { + final now = DateTime.now(); + Exception? ex; + final savedPath = await showLoading( + whileFuture: () async { + String adkString; + int adkVersion; + try { + final adk = await compute(generateAdk, passphrase); + adkString = Format.uint8listToString(adk.item2); + adkVersion = adk.item1; + } on Exception catch (e, s) { + final String err = getErrorMessageFromSWBException(e); + Logging.instance.e(err, error: e, stackTrace: s); + rethrow; + } + + await secureStore.write(key: "auto_adk_string", value: adkString); + await secureStore.write( + key: "auto_adk_version_string", + value: adkVersion.toString(), + ); + + final fileToSavePath = createAutoBackupFilename(pathToSave, now); + + final backup = await SWB.createStackWalletJSON( + secureStorage: secureStore, + ); + + final encryptedDataString = await SWB.encryptStackWalletWithADK( + adkString, + jsonEncode(backup), + adkVersion, + ); + + await FS.writeStringToFile( + encryptedDataString, + pathToSave, + fileToSavePath.split("/").last, + ); + + return fileToSavePath; + }(), + context: context, + message: "Encrypting initial backup", + subMessage: "This shouldn't take long", + delay: const Duration(seconds: 1), + onException: (e) => ex = e, + ); + + if (mounted) { + // pop encryption progress dialog + Navigator.of(context).pop(); + + if (savedPath != null) { + ref.read(prefsChangeNotifierProvider).autoBackupLocation = pathToSave; + ref.read(prefsChangeNotifierProvider).lastAutoBackup = now; + + ref.read(prefsChangeNotifierProvider).isAutoBackupEnabled = true; + + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return DesktopDialog( + maxHeight: double.infinity, + maxWidth: 500, + child: Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "${AppConfig.prefix} Auto Backup enabled!", + style: STextStyles.desktopH3(context), + ), + const DesktopDialogCloseButton(), + ], + ), + const SizedBox(height: 40), + Row( + children: [ + const Spacer(), + Expanded( + child: PrimaryButton( + label: "Ok", + buttonHeight: ButtonHeight.l, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + if (mounted) { + passphraseController.text = ""; + passphraseRepeatController.text = ""; + + Navigator.of(context).pop(); + } + } else { + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => StackOkDialog( + title: "Failed to enable Auto Backup", + message: ex?.toString(), + ), + ); + } + } + } + } + @override void initState() { secureStore = ref.read(secureStoreProvider); @@ -101,7 +235,7 @@ class _CreateAutoBackup extends ConsumerState { passphraseFocusNode = FocusNode(); passphraseRepeatFocusNode = FocusNode(); - if (Platform.isAndroid || Platform.isIOS) { + if (Platform.isIOS) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { final dir = await stackFileSystem.prepareStorage(); if (mounted) { @@ -154,9 +288,7 @@ class _CreateAutoBackup extends ConsumerState { const DesktopDialogCloseButton(), ], ), - const SizedBox( - height: 30, - ), + const SizedBox(height: 30), Container( alignment: Alignment.centerLeft, padding: const EdgeInsets.only(left: 32), @@ -168,15 +300,13 @@ class _CreateAutoBackup extends ConsumerState { textAlign: TextAlign.left, ), ), - const SizedBox( - height: 10, - ), + const SizedBox(height: 10), Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (!Platform.isAndroid && !Platform.isIOS) + if (!Platform.isIOS) Consumer( builder: (context, ref, __) { return Container( @@ -184,21 +314,21 @@ class _CreateAutoBackup extends ConsumerState { child: TextField( autocorrect: false, enableSuggestions: false, - onTap: Platform.isAndroid || Platform.isIOS + onTap: Platform.isIOS ? null : () async { try { await stackFileSystem.prepareStorage(); - if (mounted) { - await stackFileSystem.pickDir(context); - } - - if (mounted) { - setState(() { - fileLocationController.text = - stackFileSystem.dirPath ?? ""; - }); + final filePath = await stackFileSystem + .pickDir(); + + if (mounted) { + setState(() { + fileLocationController.text = + filePath ?? ""; + }); + } } } catch (e, s) { Logging.instance.e( @@ -216,20 +346,16 @@ class _CreateAutoBackup extends ConsumerState { suffixIcon: UnconstrainedBox( child: Row( children: [ - const SizedBox( - width: 16, - ), + const SizedBox(width: 16), SvgPicture.asset( Assets.svg.folder, - color: Theme.of(context) - .extension()! - .textDark3, + color: Theme.of( + context, + ).extension()!.textDark3, width: 16, height: 16, ), - const SizedBox( - width: 12, - ), + const SizedBox(width: 12), ], ), ), @@ -249,21 +375,18 @@ class _CreateAutoBackup extends ConsumerState { ); }, ), - if (!Platform.isAndroid && !Platform.isIOS) - const SizedBox( - height: 24, - ), + if (!Platform.isIOS) const SizedBox(height: 24), if (isDesktop) Padding( padding: const EdgeInsets.only(bottom: 10.0), child: Text( "Create a passphrase", - style: - STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .textDark3, - ), + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), textAlign: TextAlign.left, ), ), @@ -279,46 +402,44 @@ class _CreateAutoBackup extends ConsumerState { obscureText: hidePassword, enableSuggestions: false, autocorrect: false, - decoration: standardInputDecoration( - "Create passphrase", - passphraseFocusNode, - context, - ).copyWith( - labelStyle: - isDesktop ? STextStyles.fieldLabel(context) : null, - suffixIcon: UnconstrainedBox( - child: Row( - children: [ - const SizedBox( - width: 16, - ), - GestureDetector( - key: const Key( - "createBackupPasswordFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: SvgPicture.asset( - hidePassword - ? Assets.svg.eye - : Assets.svg.eyeSlash, - color: Theme.of(context) - .extension()! - .textDark3, - width: 16, - height: 16, - ), - ), - const SizedBox( - width: 12, + decoration: + standardInputDecoration( + "Create passphrase", + passphraseFocusNode, + context, + ).copyWith( + labelStyle: isDesktop + ? STextStyles.fieldLabel(context) + : null, + suffixIcon: UnconstrainedBox( + child: Row( + children: [ + const SizedBox(width: 16), + GestureDetector( + key: const Key( + "createBackupPasswordFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of( + context, + ).extension()!.textDark3, + width: 16, + height: 16, + ), + ), + const SizedBox(width: 12), + ], ), - ], + ), ), - ), - ), onChanged: (newValue) { if (newValue.isEmpty) { setState(() { @@ -386,26 +507,25 @@ class _CreateAutoBackup extends ConsumerState { width: 512, height: 5, fillColor: passwordStrength < 0.51 - ? Theme.of(context) - .extension()! - .accentColorRed + ? Theme.of( + context, + ).extension()!.accentColorRed : passwordStrength < 1 - ? Theme.of(context) - .extension()! - .accentColorYellow - : Theme.of(context) - .extension()! - .accentColorGreen, - backgroundColor: Theme.of(context) - .extension()! - .buttonBackSecondary, - percent: - passwordStrength < 0.25 ? 0.03 : passwordStrength, + ? Theme.of( + context, + ).extension()!.accentColorYellow + : Theme.of( + context, + ).extension()!.accentColorGreen, + backgroundColor: Theme.of( + context, + ).extension()!.buttonBackSecondary, + percent: passwordStrength < 0.25 + ? 0.03 + : passwordStrength, ), ), - const SizedBox( - height: 16, - ), + const SizedBox(height: 16), ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -418,45 +538,42 @@ class _CreateAutoBackup extends ConsumerState { obscureText: hidePassword, enableSuggestions: false, autocorrect: false, - decoration: standardInputDecoration( - "Confirm passphrase", - passphraseRepeatFocusNode, - context, - ).copyWith( - labelStyle: STextStyles.fieldLabel(context), - suffixIcon: UnconstrainedBox( - child: Row( - children: [ - const SizedBox( - width: 16, - ), - GestureDetector( - key: const Key( - "createBackupPasswordFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: SvgPicture.asset( - hidePassword - ? Assets.svg.eye - : Assets.svg.eyeSlash, - color: Theme.of(context) - .extension()! - .textDark3, - width: 16, - height: 16, - ), - ), - const SizedBox( - width: 12, + decoration: + standardInputDecoration( + "Confirm passphrase", + passphraseRepeatFocusNode, + context, + ).copyWith( + labelStyle: STextStyles.fieldLabel(context), + suffixIcon: UnconstrainedBox( + child: Row( + children: [ + const SizedBox(width: 16), + GestureDetector( + key: const Key( + "createBackupPasswordFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of( + context, + ).extension()!.textDark3, + width: 16, + height: 16, + ), + ), + const SizedBox(width: 12), + ], ), - ], + ), ), - ), - ), onChanged: (newValue) { setState(() {}); // TODO: ? check if passwords match? @@ -466,9 +583,7 @@ class _CreateAutoBackup extends ConsumerState { ], ), ), - const SizedBox( - height: 24, - ), + const SizedBox(height: 24), Container( alignment: Alignment.centerLeft, padding: const EdgeInsets.only(left: 32), @@ -480,47 +595,39 @@ class _CreateAutoBackup extends ConsumerState { textAlign: TextAlign.left, ), ), - const SizedBox( - height: 10, - ), + const SizedBox(height: 10), Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - ), + padding: const EdgeInsets.only(left: 32, right: 32), child: isDesktop ? DropdownButtonHideUnderline( child: DropdownButton2( isExpanded: true, value: _currentDropDownValue, items: [ - ..._dropDownItems.map( - (e) { - String message = ""; - switch (e) { - case BackupFrequencyType.everyTenMinutes: - message = "Every 10 minutes"; - break; - case BackupFrequencyType.everyAppStart: - message = "Every app startup"; - break; - case BackupFrequencyType.afterClosingAWallet: - message = - "After closing a cryptocurrency wallet"; - break; - } - - return DropdownMenuItem( - value: e, - child: Text( - message, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ), + ..._dropDownItems.map((e) { + String message = ""; + switch (e) { + case BackupFrequencyType.everyTenMinutes: + message = "Every 10 minutes"; + break; + case BackupFrequencyType.everyAppStart: + message = "Every app startup"; + break; + case BackupFrequencyType.afterClosingAWallet: + message = "After closing a cryptocurrency wallet"; + break; + } + + return DropdownMenuItem( + value: e, + child: Text( + message, + style: STextStyles.desktopTextExtraExtraSmall( + context, ), - ); - }, - ), + ), + ); + }), ], onChanged: (value) { if (value is BackupFrequencyType) { @@ -529,8 +636,9 @@ class _CreateAutoBackup extends ConsumerState { .backupFrequencyType != value) { ref - .read(prefsChangeNotifierProvider) - .backupFrequencyType = value; + .read(prefsChangeNotifierProvider) + .backupFrequencyType = + value; } setState(() { _currentDropDownValue = value; @@ -542,18 +650,18 @@ class _CreateAutoBackup extends ConsumerState { Assets.svg.chevronDown, width: 10, height: 5, - color: Theme.of(context) - .extension()! - .textDark3, + color: Theme.of( + context, + ).extension()!.textDark3, ), ), dropdownStyleData: DropdownStyleData( offset: const Offset(0, -10), elevation: 0, decoration: BoxDecoration( - color: Theme.of(context) - .extension()! - .textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -581,292 +689,13 @@ class _CreateAutoBackup extends ConsumerState { onPressed: Navigator.of(context).pop, ), ), - const SizedBox( - width: 16, - ), + const SizedBox(width: 16), Expanded( child: PrimaryButton( buttonHeight: ButtonHeight.l, label: "Enable Auto Backup", enabled: shouldEnableCreate, - onPressed: !shouldEnableCreate - ? null - : () async { - final String pathToSave = - fileLocationController.text; - final String passphrase = passphraseController.text; - final String repeatPassphrase = - passphraseRepeatController.text; - - if (pathToSave.isEmpty) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Directory not chosen", - context: context, - ), - ); - return; - } - if (!(await Directory(pathToSave).exists())) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Directory does not exist", - context: context, - ), - ); - return; - } - if (passphrase.isEmpty) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "A passphrase is required", - context: context, - ), - ); - return; - } - if (passphrase != repeatPassphrase) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Passphrase does not match", - context: context, - ), - ); - return; - } - - unawaited( - showDialog( - context: context, - barrierDismissible: false, - builder: (_) { - if (Util.isDesktop) { - return DesktopDialog( - maxHeight: double.infinity, - maxWidth: 450, - child: Padding( - padding: const EdgeInsets.all( - 32, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - "Encrypting initial backup", - style: STextStyles.desktopH3( - context, - ), - ), - const SizedBox( - height: 40, - ), - Text( - "This shouldn't take long", - style: STextStyles - .desktopTextExtraExtraSmall( - context, - ), - ), - ], - ), - ), - ); - } else { - return const StackDialog( - title: "Encrypting initial backup", - message: "This shouldn't take long", - ); - } - }, - ), - ); - - // make sure the dialog is able to be displayed for at least some time - final fut = Future.delayed( - const Duration(milliseconds: 300), - ); - - String adkString; - int adkVersion; - try { - final adk = - await compute(generateAdk, passphrase); - adkString = Format.uint8listToString(adk.item2); - adkVersion = adk.item1; - } on Exception catch (e, s) { - final String err = - getErrorMessageFromSWBException(e); - Logging.instance.e( - err, - error: e, - stackTrace: s, - ); - // pop encryption progress dialog - Navigator.of(context).pop(); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: err, - context: context, - ), - ); - return; - } catch (e, s) { - Logging.instance.e( - "", - error: e, - stackTrace: s, - ); - // pop encryption progress dialog - Navigator.of(context).pop(); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "$e", - context: context, - ), - ); - return; - } - - await secureStore.write( - key: "auto_adk_string", - value: adkString, - ); - await secureStore.write( - key: "auto_adk_version_string", - value: adkVersion.toString(), - ); - - final DateTime now = DateTime.now(); - final String fileToSave = - createAutoBackupFilename(pathToSave, now); - - final backup = await SWB.createStackWalletJSON( - secureStorage: secureStore, - ); - - final bool result = - await SWB.encryptStackWalletWithADK( - fileToSave, - adkString, - jsonEncode(backup), - adkVersion, - ); - - // this future should already be complete unless there was an error encrypting - await Future.wait([fut]); - - if (mounted) { - // pop encryption progress dialog - Navigator.of(context).pop(); - - if (result) { - ref - .read(prefsChangeNotifierProvider) - .autoBackupLocation = pathToSave; - ref - .read(prefsChangeNotifierProvider) - .lastAutoBackup = now; - - ref - .read(prefsChangeNotifierProvider) - .isAutoBackupEnabled = true; - - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) { - if (Platform.isAndroid) { - return StackOkDialog( - title: - "${AppConfig.prefix} Auto Backup enabled and saved to:", - message: fileToSave, - ); - } else if (Util.isDesktop) { - return DesktopDialog( - maxHeight: double.infinity, - maxWidth: 500, - child: Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Text( - "${AppConfig.prefix} Auto Backup enabled!", - style: - STextStyles.desktopH3( - context, - ), - ), - const DesktopDialogCloseButton(), - ], - ), - const SizedBox( - height: 40, - ), - Row( - children: [ - const Spacer(), - Expanded( - child: PrimaryButton( - label: "Ok", - buttonHeight: - ButtonHeight.l, - onPressed: () { - Navigator.of(context) - .pop(); - }, - ), - ), - ], - ), - ], - ), - ), - ); - } else { - return const StackOkDialog( - title: - "${AppConfig.prefix} Auto Backup enabled!", - ); - } - }, - ); - if (mounted) { - passphraseController.text = ""; - passphraseRepeatController.text = ""; - - Navigator.of(context).pop(); - } - } else { - await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => const StackOkDialog( - title: "Failed to enable Auto Backup", - ), - ); - } - } - }, + onPressed: !shouldEnableCreate ? null : _enableAutoBackup, ), ), ], diff --git a/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/add_edit_epicbox_view.dart b/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/add_edit_epicbox_view.dart new file mode 100644 index 0000000000..07f504621d --- /dev/null +++ b/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/add_edit_epicbox_view.dart @@ -0,0 +1,482 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../../models/epicbox_server_model.dart'; +import '../../../../notifications/show_flush_bar.dart'; +import '../../../../providers/global/node_service_provider.dart'; +import '../../../../utilities/constants.dart'; +import '../../../../utilities/test_epicbox_server_connection.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../../widgets/desktop/delete_button.dart'; +import '../../../../widgets/desktop/desktop_dialog.dart'; +import '../../../../widgets/desktop/primary_button.dart'; +import '../../../../widgets/desktop/secondary_button.dart'; +import '../../../../widgets/icon_widgets/x_icon.dart'; +import '../../../../widgets/stack_text_field.dart'; +import '../../../../widgets/textfield_icon_button.dart'; + +enum AddEditEpicBoxViewType { add, edit } + +class AddEditEpicBoxView extends ConsumerStatefulWidget { + const AddEditEpicBoxView({ + super.key, + required this.viewType, + this.epicBoxId, + required this.onSave, + }) : assert( + (viewType == .edit && epicBoxId != null) || + viewType == .add && epicBoxId == null, + ); + + final AddEditEpicBoxViewType viewType; + final String? epicBoxId; + final VoidCallback onSave; + + @override + ConsumerState createState() => _AddEditEpicBoxViewState(); +} + +class _AddEditEpicBoxViewState extends ConsumerState { + late final TextEditingController _nameController; + late final TextEditingController _hostController; + late final TextEditingController _portController; + + final _nameFocusNode = FocusNode(); + final _hostFocusNode = FocusNode(); + final _portFocusNode = FocusNode(); + + bool _useSSL = true; + int? port; + + bool get canSave { + return _nameController.text.isNotEmpty && canTestConnection; + } + + bool get canTestConnection { + return _hostController.text.isNotEmpty && + port != null && + port! >= 0 && + port! <= 65535; + } + + Future _testConnection() async { + final data = EpicBoxFormData() + ..name = _nameController.text + ..host = _hostController.text + ..port = port ?? 443 + ..useSSL = _useSSL; + + final result = await testEpicBoxServerConnection(data); + if (!mounted) return; + + if (result != null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Connection successful", + context: context, + ), + ); + } else { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Could not connect to server", + context: context, + ), + ); + } + } + + Future _attemptSave() async { + final data = EpicBoxFormData() + ..name = _nameController.text + ..host = _hostController.text + ..port = port ?? 443 + ..useSSL = _useSSL; + + final canConnect = await testEpicBoxServerConnection(data) != null; + + bool shouldSave = canConnect; + + if (!canConnect && mounted) { + await showDialog( + context: context, + useSafeArea: true, + barrierDismissible: true, + builder: (_) => DesktopDialog( + maxWidth: 440, + maxHeight: 300, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 32), + child: Row( + children: [ + const SizedBox(width: 32), + Text( + "Server currently unreachable", + style: STextStyles.desktopH3(context), + ), + ], + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + top: 16, + bottom: 32, + ), + child: Column( + children: [ + const Spacer(), + Text( + "Would you like to save this server anyways?", + style: STextStyles.desktopTextMedium(context), + ), + const Spacer(flex: 2), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: ButtonHeight.l, + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(false), + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Save", + buttonHeight: ButtonHeight.l, + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(true), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ).then((value) { + if (value is bool && value) { + shouldSave = true; + } + }); + } + + if (!shouldSave) return; + + final epicBox = EpicBoxServerModel( + id: widget.epicBoxId ?? const Uuid().v1(), + host: _hostController.text, + port: port ?? 443, + name: _nameController.text, + useSSL: _useSSL, + enabled: true, + isFailover: true, + isDown: false, + ); + + await ref.read(nodeServiceChangeNotifierProvider).addEpicBox(epicBox, true); + widget.onSave(); + + if (mounted) { + Navigator.of(context).pop(); + } + } + + late final bool canDelete; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(); + _hostController = TextEditingController(); + _portController = TextEditingController(); + + switch (widget.viewType) { + case .add: + _portController.text = "443"; + port = 443; + canDelete = false; + break; + + case .edit: + final epicBox = ref + .read(nodeServiceChangeNotifierProvider) + .getEpicBoxById(id: widget.epicBoxId!)!; + + _nameController.text = epicBox.name; + _hostController.text = epicBox.host; + _portController.text = (epicBox.port ?? 443).toString(); + _useSSL = epicBox.useSSL ?? true; + port = epicBox.port ?? 443; + canDelete = !epicBox.isDefault; + break; + } + } + + @override + void dispose() { + _nameController.dispose(); + _hostController.dispose(); + _portController.dispose(); + _nameFocusNode.dispose(); + _hostFocusNode.dispose(); + _portFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const SizedBox(width: 8), + const AppBarBackButton(iconSize: 24, size: 40), + Text( + widget.viewType == AddEditEpicBoxViewType.add + ? "Add Epic Box server" + : "Edit Epic Box server", + style: STextStyles.desktopH3(context), + ), + ], + ), + ], + ), + Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + top: 16, + bottom: 32, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: false, + enableSuggestions: false, + controller: _nameController, + focusNode: _nameFocusNode, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Server name", + _nameFocusNode, + context, + ).copyWith( + suffixIcon: _nameController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: TextFieldIconButton( + child: const XIcon(), + onTap: () { + _nameController.clear(); + setState(() {}); + }, + ), + ), + ) + : null, + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: false, + enableSuggestions: false, + controller: _hostController, + focusNode: _hostFocusNode, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Host", + _hostFocusNode, + context, + ).copyWith( + suffixIcon: _hostController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: TextFieldIconButton( + child: const XIcon(), + onTap: () { + _hostController.clear(); + setState(() {}); + }, + ), + ), + ) + : null, + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: false, + enableSuggestions: false, + controller: _portController, + focusNode: _portFocusNode, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + keyboardType: TextInputType.number, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Port", + _portFocusNode, + context, + ).copyWith( + suffixIcon: _portController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: TextFieldIconButton( + child: const XIcon(), + onTap: () { + _portController.clear(); + setState(() {}); + }, + ), + ), + ) + : null, + ), + onChanged: (value) { + port = int.tryParse(value); + setState(() {}); + }, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + GestureDetector( + onTap: () { + setState(() { + _useSSL = !_useSSL; + }); + }, + child: Container( + color: Colors.transparent, + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: Checkbox( + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + value: _useSSL, + onChanged: (newValue) { + setState(() { + _useSSL = newValue!; + }); + }, + ), + ), + const SizedBox(width: 12), + Text( + "Use SSL", + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 22), + if (canDelete) + SizedBox( + height: 56, + child: Row( + children: [ + Expanded( + child: DeleteButton( + label: "Delete node", + desktopMed: true, + onPressed: () { + Navigator.of(context).pop(); + ref + .read(nodeServiceChangeNotifierProvider) + .deleteEpicBox(widget.epicBoxId!, true); + }, + ), + ), + const SizedBox(width: 16), + const Spacer(), + ], + ), + ), + if (canDelete) const SizedBox(height: 45), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Test connection", + enabled: canTestConnection, + buttonHeight: ButtonHeight.l, + onPressed: canTestConnection ? _testConnection : null, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Save", + enabled: canSave, + buttonHeight: ButtonHeight.l, + onPressed: canSave ? _attemptSave : null, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/desktop_manage_epicbox_dialog.dart b/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/desktop_manage_epicbox_dialog.dart new file mode 100644 index 0000000000..5dab7e2d55 --- /dev/null +++ b/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/desktop_manage_epicbox_dialog.dart @@ -0,0 +1,210 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../notifications/show_flush_bar.dart'; +import '../../../../providers/providers.dart'; +import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/assets.dart'; +import '../../../../utilities/default_epicboxes.dart'; +import '../../../../utilities/test_epicbox_server_connection.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; +import '../../../../widgets/custom_buttons/blue_text_button.dart'; +import '../../../../widgets/desktop/desktop_dialog.dart'; +import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../../widgets/epicbox_card.dart'; +import 'add_edit_epicbox_view.dart'; + +class DesktopManageEpicBoxDialog extends ConsumerStatefulWidget { + const DesktopManageEpicBoxDialog({super.key, required this.walletId}); + + final String walletId; + + @override + ConsumerState createState() => + _DesktopManageEpicBoxDialogState(); +} + +class _DesktopManageEpicBoxDialogState + extends ConsumerState { + Future _onConnect(String epicBoxId) async { + final epicBox = + ref + .read(nodeServiceChangeNotifierProvider) + .getEpicBoxById(id: epicBoxId) ?? + DefaultEpicBoxes.all.firstWhere((e) => e.id == epicBoxId); + + final data = EpicBoxFormData() + ..host = epicBox.host + ..port = epicBox.port ?? 443 + ..useSSL = epicBox.useSSL; + + final canConnect = await testEpicBoxServerConnection(data) != null; + + if (!canConnect && mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + iconAsset: Assets.svg.circleAlert, + message: "Could not connect to server", + context: context, + ), + ); + return; + } + + await ref + .read(nodeServiceChangeNotifierProvider) + .setPrimaryEpicBox(epicBox: epicBox, shouldNotifyListeners: true); + + // update wallet's epicbox config + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as EpiccashWallet; + await wallet.updateEpicboxConfig(epicBox.host, epicBox.port ?? 443); + + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Connected to ${epicBox.name}", + context: context, + ), + ); + } + } + + void _onEdit(String epicBoxId) { + showDialog( + context: context, + builder: (_) => AddEditEpicBoxView( + viewType: AddEditEpicBoxViewType.edit, + epicBoxId: epicBoxId, + onSave: () {}, + ), + ); + } + + void _onAdd() { + showDialog( + context: context, + builder: (_) => AddEditEpicBoxView( + viewType: AddEditEpicBoxViewType.add, + onSave: () {}, + ), + ); + } + + @override + Widget build(BuildContext context) { + final epicBoxes = ref.watch( + nodeServiceChangeNotifierProvider.select((value) => value.getEpicBoxes()), + ); + final primaryEpicBox = ref.watch( + nodeServiceChangeNotifierProvider.select( + (value) => value.getPrimaryEpicBox(), + ), + ); + + return DesktopDialog( + maxHeight: double.infinity, + maxWidth: 580, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Epic Box", style: STextStyles.desktopH3(context)), + const DesktopDialogCloseButton(), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, top: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Servers", + style: STextStyles.desktopTextExtraExtraSmall(context), + ), + CustomTextButton(text: "Add new", onTap: _onAdd), + ], + ), + ), + const SizedBox(height: 12), + Flexible( + child: Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text( + "Default servers", + style: STextStyles.smallMed12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), + ), + ), + ...DefaultEpicBoxes.all.map( + (epicBox) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: EpicBoxCard( + key: Key("${epicBox.id}_card_key"), + epicBoxId: epicBox.id, + onConnect: () => _onConnect(epicBox.id), + onEdit: () {}, // do nothing for defaults + testOnInit: primaryEpicBox?.id == epicBox.id, + ), + ), + ), + if (epicBoxes.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text( + "Custom servers", + style: STextStyles.smallMed12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), + ), + ), + ...epicBoxes.map( + (epicBox) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: EpicBoxCard( + key: Key("${epicBox.id}_card_key"), + epicBoxId: epicBox.id, + onConnect: () => _onConnect(epicBox.id), + onEdit: () => _onEdit(epicBox.id), + testOnInit: primaryEpicBox?.id == epicBox.id, + ), + ), + ), + ], + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages_desktop_specific/settings/settings_menu/nodes_settings.dart b/lib/pages_desktop_specific/settings/settings_menu/nodes_settings.dart index bf6914869f..308f4bc991 100644 --- a/lib/pages_desktop_specific/settings/settings_menu/nodes_settings.dart +++ b/lib/pages_desktop_specific/settings/settings_menu/nodes_settings.dart @@ -68,9 +68,7 @@ class _NodesSettings extends ConsumerState { @override void initState() { _coins = _coins.toList(); - _coins.removeWhere( - (e) => e is Firo && e.network.isTestNet, - ); + _coins.removeWhere((e) => e is Firo && e.network.isTestNet); searchNodeController = TextEditingController(); searchNodeFocusNode = FocusNode(); @@ -99,11 +97,7 @@ class _NodesSettings extends ConsumerState { List coins = showTestNet ? _coins - : _coins - .where( - (e) => e.network == CryptoCurrencyNetwork.main, - ) - .toList(); + : _coins.where((e) => e.network == CryptoCurrencyNetwork.main).toList(); coins = _search(filter, coins); @@ -131,23 +125,17 @@ class _NodesSettings extends ConsumerState { width: 48, height: 48, ), - const SizedBox( - height: 16, - ), + const SizedBox(height: 16), Text( "Nodes", style: STextStyles.desktopTextSmall(context), ), - const SizedBox( - height: 16, - ), + const SizedBox(height: 16), Text( "Select a coin to see nodes", style: STextStyles.desktopTextExtraExtraSmall(context), ), - const SizedBox( - height: 20, - ), + const SizedBox(height: 20), ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -161,56 +149,58 @@ class _NodesSettings extends ConsumerState { setState(() => filter = newString); }, style: STextStyles.field(context), - decoration: standardInputDecoration( - "Search", - searchNodeFocusNode, - context, - ).copyWith( - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 16, - height: 16, - ), - ), - suffixIcon: searchNodeController.text.isNotEmpty - ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - searchNodeController.text = ""; - filter = ""; - }); - }, + decoration: + standardInputDecoration( + "Search", + searchNodeFocusNode, + context, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 16, + height: 16, + ), + ), + suffixIcon: searchNodeController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only( + right: 0, + ), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + searchNodeController.text = + ""; + filter = ""; + }); + }, + ), + ], ), - ], - ), - ), - ) - : null, - ), + ), + ) + : null, + ), ), ), ], ), - const SizedBox( - height: 20, - ), + const SizedBox(height: 20), Flexible( child: RoundedWhiteContainer( padding: const EdgeInsets.all(0), - borderColor: Theme.of(context) - .extension()! - .background, + borderColor: Theme.of( + context, + ).extension()!.background, child: ListView.separated( controller: nodeScrollController, physics: const AlwaysScrollableScrollPhysics(), @@ -221,8 +211,9 @@ class _NodesSettings extends ConsumerState { final coin = coins[index]; final count = ref .watch( - nodeServiceChangeNotifierProvider - .select((value) => value.getNodesFor(coin)), + nodeServiceChangeNotifierProvider.select( + (value) => value.getNodesFor(coin), + ), ) .length; @@ -261,10 +252,9 @@ class _NodesSettings extends ConsumerState { ); }, child: Padding( - padding: const EdgeInsets.all( - 12.0, - ), + padding: const EdgeInsets.all(12.0), child: Row( + mainAxisAlignment: .spaceBetween, children: [ Row( children: [ @@ -275,9 +265,7 @@ class _NodesSettings extends ConsumerState { width: 24, height: 24, ), - const SizedBox( - width: 12, - ), + const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -298,12 +286,7 @@ class _NodesSettings extends ConsumerState { ), ], ), - Expanded( - child: SvgPicture.asset( - Assets.svg.chevronRight, - alignment: Alignment.centerRight, - ), - ), + SvgPicture.asset(Assets.svg.chevronRight), ], ), ), @@ -312,9 +295,9 @@ class _NodesSettings extends ConsumerState { }, separatorBuilder: (context, index) => Container( height: 1, - color: Theme.of(context) - .extension()! - .background, + color: Theme.of( + context, + ).extension()!.background, ), itemCount: coins.length, ), diff --git a/lib/providers/churning/churning_service_provider.dart b/lib/providers/churning/churning_service_provider.dart index 642d1103f3..2da0fa5b53 100644 --- a/lib/providers/churning/churning_service_provider.dart +++ b/lib/providers/churning/churning_service_provider.dart @@ -1,12 +1,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../services/churning_service.dart'; -import '../../wallets/wallet/intermediate/lib_monero_wallet.dart'; +import '../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../global/wallets_provider.dart'; final pChurningService = ChangeNotifierProvider.family( (ref, walletId) { final wallet = ref.watch(pWallets.select((s) => s.getWallet(walletId))); - return ChurningService(wallet: wallet as LibMoneroWallet); + return ChurningService(wallet: wallet as CryptonoteWallet); }, ); diff --git a/lib/providers/db/drift_provider.dart b/lib/providers/db/drift_provider.dart index 658dd5bc7e..efbf436498 100644 --- a/lib/providers/db/drift_provider.dart +++ b/lib/providers/db/drift_provider.dart @@ -10,8 +10,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../db/drift/database.dart'; +import '../../db/drift/database.dart' show WalletDatabase, Drift; +import '../../db/drift/shared_db/shared_database.dart' show SharedDrift; final pDrift = Provider.family( (ref, walletId) => Drift.get(walletId), ); + +final pSharedDrift = Provider((_) => SharedDrift.get()); diff --git a/lib/providers/global/cakepay_orders_provider.dart b/lib/providers/global/cakepay_orders_provider.dart new file mode 100644 index 0000000000..6f68348ccf --- /dev/null +++ b/lib/providers/global/cakepay_orders_provider.dart @@ -0,0 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../services/cakepay/cakepay_orders_service.dart'; + +final pCakePayOrdersService = ChangeNotifierProvider( + (ref) => CakePayOrdersService(), +); diff --git a/lib/providers/global/shopin_bit_service_provider.dart b/lib/providers/global/shopin_bit_service_provider.dart new file mode 100644 index 0000000000..bce681b41a --- /dev/null +++ b/lib/providers/global/shopin_bit_service_provider.dart @@ -0,0 +1,92 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../db/drift/shared_db/shared_database.dart'; +import '../../db/drift/shared_db/tables/notifications.dart'; +import '../../external_api_keys.dart'; +import '../../services/shopinbit/shopinbit_api.dart'; +import '../../services/shopinbit/shopinbit_service.dart'; +import '../db/drift_provider.dart'; +import 'notifications_provider.dart'; + +final pShopinBitService = Provider( + (ref) => ShopInBitService( + client: ShopInBitClient( + accessKey: kShopInBitAccessKey, + partnerSecret: kShopInBitPartnerSecret, + sandbox: false, + ), + db: ref.watch(pSharedDrift), + ), +); + +/// The active customer key's settings row (or null if none yet). +final pShopInBitSettings = StreamProvider.autoDispose( + (ref) => ref.watch(pSharedDrift).shopInBitSettingsDao.watchCurrentSettings(), +); + +/// All tickets for the active customer key, newest first. Watches the key so +/// a key created after startup or switched at runtime re-scopes the list. +final pShopInBitTickets = StreamProvider.autoDispose>(( + ref, +) { + final customerKey = ref.watch(_pShopInBitCustomerKey); + if (customerKey == null) { + return Stream.value(const []); + } + return ref + .watch(pSharedDrift) + .shopInBitTicketsDao + .watchByCustomerKey(customerKey); +}); + +final pShopInBitTicket = StreamProvider.autoDispose + .family( + (ref, apiTicketId) => + ref.watch(pSharedDrift).shopInBitTicketsDao.watchByApiId(apiTicketId), + ); + +final _pShopInBitCustomerKey = Provider.autoDispose( + (ref) => + ref.watch(pShopInBitSettings.select((s) => s.asData?.value?.customerKey)), +); + +/// ShopinBit notifications for the active customer key, newest first (feed). +final pShopInBitNotifications = + StreamProvider.autoDispose>((ref) { + final customerKey = ref.watch(_pShopInBitCustomerKey); + if (customerKey == null) { + return Stream.value(const []); + } + return ref + .watch(pSharedDrift) + .appNotificationsDao + .watchByScope(AppNotificationType.shopinbit, customerKey); + }); + +/// Unread ShopinBit notification count for the active customer key (bell). +final pShopInBitNotificationUnreadCount = StreamProvider.autoDispose(( + ref, +) { + final customerKey = ref.watch(_pShopInBitCustomerKey); + if (customerKey == null) { + return Stream.value(0); + } + return ref + .watch(pSharedDrift) + .appNotificationsDao + .watchUnreadCount( + type: AppNotificationType.shopinbit, + scopeId: customerKey, + ); +}); + +/// True when the global notifications bell should light: any unread Hive +/// notification, or any unread ShopinBit notification for the active key. +final pAnyGlobalUnreadNotifications = Provider.autoDispose((ref) { + final hive = ref.watch( + notificationsProvider.select((value) => value.hasUnreadNotifications), + ); + final sib = + (ref.watch(pShopInBitNotificationUnreadCount).asData?.value ?? 0) > 0; + return hive || sib; +}); diff --git a/lib/providers/ui/notification_feed_provider.dart b/lib/providers/ui/notification_feed_provider.dart new file mode 100644 index 0000000000..ab49552b8d --- /dev/null +++ b/lib/providers/ui/notification_feed_provider.dart @@ -0,0 +1,28 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../db/drift/shared_db/shared_database.dart'; +import '../../notifications/notification_feed_entry.dart'; +import '../global/notifications_provider.dart'; +import '../global/shopin_bit_service_provider.dart'; + +/// The merged notification feed, newest first, shared by the mobile and +/// desktop notifications views. Pass a walletId to scope the list to that +/// wallet's Hive notifications (ShopinBit rows are account-level, not +/// per-wallet, so they only appear in the global feed); null is the global +/// feed. +final pNotificationFeed = Provider.autoDispose + .family, String?>((ref, walletId) { + final all = ref.watch( + notificationsProvider.select((value) => value.notifications), + ); + final hive = walletId == null + ? all + : all + .where((element) => element.walletId == walletId) + .toList(growable: false); + final sib = walletId == null + ? (ref.watch(pShopInBitNotifications).asData?.value ?? + const []) + : const []; + return mergeNotificationFeed(hive, sib); + }); diff --git a/lib/providers/ui/preview_tx_button_state_provider.dart b/lib/providers/ui/preview_tx_button_state_provider.dart index e800869f0a..285d4b8781 100644 --- a/lib/providers/ui/preview_tx_button_state_provider.dart +++ b/lib/providers/ui/preview_tx_button_state_provider.dart @@ -11,6 +11,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../utilities/amount/amount.dart'; +import '../../utilities/enums/epic_transaction_method.dart'; import '../../utilities/enums/mwc_transaction_method.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; @@ -22,48 +23,75 @@ final pValidSparkSendToAddress = StateProvider.autoDispose((_) => false); final pIsExchangeAddress = StateProvider((_) => false); +final pOpReturnData = StateProvider.autoDispose((_) => null); + // MWC Transaction Method Provider. final pSelectedMwcTransactionMethod = StateProvider( (_) => MwcTransactionMethod.slatepack, ); +// Epic Cash Transaction Method Provider. +final pSelectedEpicTransactionMethod = StateProvider( + (_) => EpicTransactionMethod.epicbox, +); + final pIsSlatepack = Provider.family((ref, walletId) { - if (ref.watch(pWalletCoin(walletId)) is Mimblewimblecoin) { + final coin = ref.watch(pWalletCoin(walletId)); + if (coin is Mimblewimblecoin) { return ref.watch(pSelectedMwcTransactionMethod) == MwcTransactionMethod.slatepack; } + if (coin is Epiccash) { + return ref.watch(pSelectedEpicTransactionMethod) == + EpicTransactionMethod.slatepack; + } return false; }); -final pPreviewTxButtonEnabled = Provider.autoDispose - .family((ref, coin) { - final amount = ref.watch(pSendAmount) ?? Amount.zero; +final pPreviewTxButtonEnabled = Provider.autoDispose.family( + (ref, coin) { + final amount = ref.watch(pSendAmount) ?? Amount.zero; + final opReturnData = ref.watch(pOpReturnData); + + if (coin is! Firo && opReturnData != null) { + return false; + } - // For MWC slatepack transactions, address validation is not required. - if (coin is Mimblewimblecoin) { - final selectedMethod = ref.watch(pSelectedMwcTransactionMethod); - if (selectedMethod == MwcTransactionMethod.slatepack) { - return amount > Amount.zero; - } + // For MWC slatepack transactions, address validation is not required. + if (coin is Mimblewimblecoin) { + final selectedMethod = ref.watch(pSelectedMwcTransactionMethod); + if (selectedMethod == MwcTransactionMethod.slatepack) { + return amount > Amount.zero; } + } - if (coin is Firo) { - final firoType = ref.watch(publicPrivateBalanceStateProvider); - switch (firoType) { - case BalanceType.private: - return (ref.watch(pValidSendToAddress) || - ref.watch(pValidSparkSendToAddress)) && - !ref.watch(pIsExchangeAddress) && - amount > Amount.zero; - - case BalanceType.public: - return ref.watch(pValidSendToAddress) && amount > Amount.zero; - } - } else { - return ref.watch(pValidSendToAddress) && amount > Amount.zero; + // For Epic Cash slatepack transactions, address validation is not required. + if (coin is Epiccash) { + final selectedMethod = ref.watch(pSelectedEpicTransactionMethod); + if (selectedMethod == EpicTransactionMethod.slatepack) { + return amount > Amount.zero; } - }); + } + + if (coin is Firo) { + final firoType = ref.watch(publicPrivateBalanceStateProvider); + switch (firoType) { + case BalanceType.private: + return (ref.watch(pValidSendToAddress) || + ref.watch(pValidSparkSendToAddress)) && + !ref.watch(pIsExchangeAddress) && + opReturnData == null && + amount > Amount.zero; + + case BalanceType.public: + return ref.watch(pValidSendToAddress) && amount > Amount.zero; + } + } else { + return ref.watch(pValidSendToAddress) && amount > Amount.zero; + } + }, +); final previewTokenTxButtonStateProvider = StateProvider.autoDispose((_) { return false; diff --git a/lib/route_generator.dart b/lib/route_generator.dart index b44550bafc..6197874811 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -15,8 +15,10 @@ import 'package:tuple/tuple.dart'; import 'app_config.dart'; import 'db/drift/database.dart'; +import 'db/drift/shared_db/shared_database.dart'; import 'models/add_wallet_list_entity/add_wallet_list_entity.dart'; import 'models/add_wallet_list_entity/sub_classes/eth_token_entity.dart'; +import 'models/add_wallet_list_entity/sub_classes/sol_token_entity.dart'; import 'models/buy/response_objects/quote.dart'; import 'models/exchange/incomplete_exchange.dart'; import 'models/exchange/response_objects/trade.dart'; @@ -28,6 +30,9 @@ import 'models/keys/key_data_interface.dart'; import 'models/keys/view_only_wallet_data.dart'; import 'models/paynym/paynym_account_lite.dart'; import 'models/send_view_auto_fill_data.dart'; +import 'models/shopinbit/shopinbit_enums.dart'; +import 'models/shopinbit/shopinbit_request_draft.dart'; +import 'pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dart'; import 'pages/add_wallet_views/add_token_view/add_custom_token_view.dart'; import 'pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart'; import 'pages/add_wallet_views/add_wallet_view/add_wallet_view.dart'; @@ -42,6 +47,7 @@ import 'pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_walle import 'pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart'; import 'pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart'; import 'pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart'; +import 'pages/add_wallet_views/select_wallet_for_sol_token_view.dart'; import 'pages/add_wallet_views/select_wallet_for_token_view.dart'; import 'pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart'; import 'pages/address_book_views/address_book_view.dart'; @@ -54,13 +60,20 @@ import 'pages/address_book_views/subviews/edit_contact_name_emoji_view.dart'; import 'pages/buy_view/buy_in_wallet_view.dart'; import 'pages/buy_view/buy_quote_preview.dart'; import 'pages/buy_view/buy_view.dart'; +import 'pages/cakepay/cakepay_card_detail_view.dart'; +import 'pages/cakepay/cakepay_confirm_send_view.dart'; +import 'pages/cakepay/cakepay_order_view.dart'; +import 'pages/cakepay/cakepay_orders_view.dart'; +import 'pages/cakepay/cakepay_send_from_view.dart'; +import 'pages/cakepay/cakepay_vendors_view.dart'; import 'pages/cashfusion/cashfusion_view.dart'; import 'pages/cashfusion/fusion_progress_view.dart'; import 'pages/churning/churning_progress_view.dart'; import 'pages/churning/churning_view.dart'; import 'pages/coin_control/coin_control_view.dart'; import 'pages/coin_control/utxo_details_view.dart'; -import 'pages/exchange_view/choose_from_stack_view.dart'; +import 'pages/epic_finalize_view/epic_finalize_view.dart'; +import 'pages/exchange_view/choose_address_from_stack_view.dart'; import 'pages/exchange_view/edit_trade_note_view.dart'; import 'pages/exchange_view/exchange_step_views/step_1_view.dart'; import 'pages/exchange_view/exchange_step_views/step_2_view.dart'; @@ -74,6 +87,9 @@ import 'pages/generic/single_field_edit_view.dart'; import 'pages/home_view/home_view.dart'; import 'pages/intro_view.dart'; import 'pages/manage_favorites_view/manage_favorites_view.dart'; +import 'pages/masternodes/create_masternode_view.dart'; +import 'pages/masternodes/masternode_details_view.dart'; +import 'pages/masternodes/masternodes_home_view.dart'; import 'pages/monkey/monkey_view.dart'; import 'pages/namecoin_names/buy_domain_view.dart'; import 'pages/namecoin_names/confirm_name_transaction_view.dart'; @@ -93,10 +109,12 @@ import 'pages/receive_view/addresses/edit_address_label_view.dart'; import 'pages/receive_view/addresses/wallet_addresses_view.dart'; import 'pages/receive_view/generate_receiving_uri_qr_code_view.dart'; import 'pages/receive_view/receive_view.dart'; +import 'pages/receive_view/sol_token_receive_view.dart'; import 'pages/salvium_stake/salvium_create_stake_view.dart'; import 'pages/send_view/confirm_transaction_view.dart'; import 'pages/send_view/frost_ms/frost_send_view.dart'; import 'pages/send_view/send_view.dart'; +import 'pages/send_view/sol_token_send_view.dart'; import 'pages/send_view/token_send_view.dart'; import 'pages/settings_views/global_settings_view/about_view.dart'; import 'pages/settings_views/global_settings_view/advanced_views/advanced_settings_view.dart'; @@ -135,6 +153,8 @@ import 'pages/settings_views/global_settings_view/syncing_preferences_views/sync import 'pages/settings_views/global_settings_view/syncing_preferences_views/syncing_preferences_view.dart'; import 'pages/settings_views/global_settings_view/syncing_preferences_views/wallet_syncing_options_view.dart'; import 'pages/settings_views/global_settings_view/tor_settings/tor_settings_view.dart'; +import 'pages/settings_views/wallet_settings_view/epicbox_settings/add_edit_epicbox_mobile_view.dart'; +import 'pages/settings_views/wallet_settings_view/epicbox_settings/manage_epicbox_view.dart'; import 'pages/settings_views/wallet_settings_view/frost_ms/frost_ms_options_view.dart'; import 'pages/settings_views/wallet_settings_view/frost_ms/frost_participants_view.dart'; import 'pages/settings_views/wallet_settings_view/frost_ms/initiate_resharing/complete_reshare_config_view.dart'; @@ -150,8 +170,25 @@ import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_setting import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/rbf_settings_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/rename_wallet_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_info.dart'; +import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/xpub_view.dart'; +import 'pages/shopinbit/shopinbit_car_fee_view.dart'; +import 'pages/shopinbit/shopinbit_car_research_payment_view.dart'; +import 'pages/shopinbit/shopinbit_offer_view.dart'; +import 'pages/shopinbit/shopinbit_order_created.dart'; +import 'pages/shopinbit/shopinbit_payment_view.dart'; +import 'pages/shopinbit/shopinbit_send_from_view.dart'; +import 'pages/shopinbit/shopinbit_settings_view.dart'; +import 'pages/shopinbit/shopinbit_setup_view.dart'; +import 'pages/shopinbit/shopinbit_shipping_view.dart'; +import 'pages/shopinbit/shopinbit_step_2.dart'; +import 'pages/shopinbit/shopinbit_step_3.dart'; +import 'pages/shopinbit/shopinbit_step_4.dart'; +import 'pages/shopinbit/shopinbit_ticket_detail.dart'; +import 'pages/shopinbit/shopinbit_tickets_view.dart'; +import 'pages/signing/signing_view.dart'; +import 'pages/signing/sub_widgets/address_list.dart'; import 'pages/spark_names/buy_spark_name_view.dart'; import 'pages/spark_names/confirm_spark_name_transaction_view.dart'; import 'pages/spark_names/spark_names_home_view.dart'; @@ -159,6 +196,8 @@ import 'pages/spark_names/sub_widgets/spark_name_details.dart'; import 'pages/special/firo_rescan_recovery_error_dialog.dart'; import 'pages/stack_privacy_calls.dart'; import 'pages/token_view/my_tokens_view.dart'; +import 'pages/token_view/sol_token_view.dart'; +import 'pages/token_view/solana_token_contract_details_view.dart'; import 'pages/token_view/token_contract_details_view.dart'; import 'pages/token_view/token_view.dart'; import 'pages/wallet_view/transaction_views/all_transactions_view.dart'; @@ -185,6 +224,7 @@ import 'pages_desktop_specific/desktop_exchange/desktop_exchange_view.dart'; import 'pages_desktop_specific/desktop_home_view.dart'; import 'pages_desktop_specific/mweb_utxos_view.dart'; import 'pages_desktop_specific/my_stack_view/my_stack_view.dart'; +import 'pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart'; import 'pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart'; import 'pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; import 'pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart'; @@ -200,6 +240,9 @@ import 'pages_desktop_specific/password/create_password_view.dart'; import 'pages_desktop_specific/password/delete_password_warning_view.dart'; import 'pages_desktop_specific/password/forgot_password_desktop_view.dart'; import 'pages_desktop_specific/password/forgotten_passphrase_restore_from_swb.dart'; +import 'pages_desktop_specific/services/cakepay/desktop_gift_cards_view.dart'; +import 'pages_desktop_specific/services/desktop_services_view.dart'; +import 'pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dart'; import 'pages_desktop_specific/settings/desktop_settings_view.dart'; import 'pages_desktop_specific/settings/settings_menu/advanced_settings/advanced_settings.dart'; import 'pages_desktop_specific/settings/settings_menu/appearance_settings/appearance_settings.dart'; @@ -213,13 +256,18 @@ import 'pages_desktop_specific/settings/settings_menu/security_settings.dart'; import 'pages_desktop_specific/settings/settings_menu/syncing_preferences_settings.dart'; import 'pages_desktop_specific/settings/settings_menu/tor_settings/tor_settings.dart'; import 'pages_desktop_specific/spark_coins/spark_coins_view.dart'; +import 'services/cakepay/src/models/card.dart'; +import 'services/cakepay/src/models/order.dart'; import 'services/event_bus/events/global/node_connection_status_changed_event.dart'; import 'services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import 'services/shopinbit/src/models/car_research.dart'; +import 'services/shopinbit/src/models/payment.dart'; import 'utilities/amount/amount.dart'; import 'utilities/enums/add_wallet_type_enum.dart'; import 'wallets/crypto_currency/crypto_currency.dart'; import 'wallets/crypto_currency/intermediate/frost_currency.dart'; import 'wallets/models/tx_data.dart'; +import 'wallets/wallet/impl/firo_wallet.dart'; import 'wallets/wallet/wallet.dart'; import 'wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; import 'widgets/choose_coin_view.dart'; @@ -364,6 +412,16 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case DesktopSolTokenView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => DesktopSolTokenView(walletId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SelectWalletForTokenView.routeName: if (args is EthTokenEntity) { return getRoute( @@ -374,6 +432,16 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SelectWalletForSolTokenView.routeName: + if (args is SolTokenEntity) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SelectWalletForSolTokenView(entity: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case AddCustomTokenView.routeName: return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, @@ -381,6 +449,14 @@ class RouteGenerator { settings: RouteSettings(name: settings.name), ); + case AddCustomSolanaTokenView.routeName: + final walletId = args is String ? args : null; + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => AddCustomSolanaTokenView(walletId: walletId), + settings: RouteSettings(name: settings.name), + ); + case WalletsOverview.routeName: if (args is CryptoCurrency) { return getRoute( @@ -404,6 +480,19 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SolanaTokenContractDetailsView.routeName: + if (args is Tuple2) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SolanaTokenContractDetailsView( + tokenMint: args.item1, + walletId: args.item2, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SingleFieldEditView.routeName: if (args is Tuple2) { return getRoute( @@ -427,6 +516,26 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SigningView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SigningView(walletId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case CompactAddressListView.routeName: + if (args is String) { + return getRoute
( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => CompactAddressListView(walletId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case CreateNewFrostMsWalletView.routeName: if (args is ({String walletName, FrostCurrency frostCurrency})) { return getRoute( @@ -822,6 +931,41 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case MasternodesHomeView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => MasternodesHomeView(walletId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case CreateMasternodeView.routeName: + if (args is Map) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => CreateMasternodeView( + firoWalletId: args['walletId'] as String, + collateralTxid: args['collateralTxid'] as String, + collateralVout: args['collateralVout'] as int, + collateralAddress: args['collateralAddress'] as String, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case MasternodeDetailsView.routeName: + if (args is MasternodeInfo) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => MasternodeDetailsView(node: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case BuySparkNameView.routeName: if (args is ({String walletId, String name})) { return getRoute( @@ -920,6 +1064,207 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case ShopInBitSetupView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const ShopInBitSetupView(), + settings: RouteSettings(name: settings.name), + ); + + case CakePayVendorsView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const CakePayVendorsView(), + settings: RouteSettings(name: settings.name), + ); + + case CakePayCardDetailView.routeName: + if (args is CakePayCard) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => CakePayCardDetailView(card: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case CakePayOrderView.routeName: + if (args is CakePayOrder) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => CakePayOrderView(order: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case CakePayOrdersView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const CakePayOrdersView(), + settings: RouteSettings(name: settings.name), + ); + + case CakePaySendFromView.routeName: + if (args is Map) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => CakePaySendFromView( + address: args['address'] as String, + orderId: args['orderId'] as String, + coin: args['coin'] as CryptoCurrency?, + amount: args['amount'] as Amount?, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case CakePayConfirmSendView.routeName: + return _routeError("${settings.name} should be pushed directly"); + + case ShopInBitStep2.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const ShopInBitStep2(), + settings: RouteSettings(name: settings.name), + ); + + case ShopInBitStep3.routeName: + if (args is ({ShopInBitCategory category, String customerKey})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitStep3( + category: args.category, + customerKey: args.customerKey, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitStep4.routeName: + if (args is ShopInBitCategory) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitStep4(category: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitOrderCreated.routeName: + if (args is int) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitOrderCreated(apiTicketId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitTicketsView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const ShopInBitTicketsView(), + settings: RouteSettings(name: settings.name), + ); + + case ShopInBitSettingsView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const ShopInBitSettingsView(), + settings: RouteSettings(name: settings.name), + ); + + case ShopInBitTicketDetail.routeName: + if (args is int) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitTicketDetail(apiTicketId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitOfferView.routeName: + if (args is int) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitOfferView(apiTicketId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitShippingView.routeName: + if (args + is ({ + ShopInBitTicket ticket, + List> countries, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitShippingView( + ticket: args.ticket, + countries: args.countries, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitCarFeeView.routeName: + if (args is ShopinbitRequestDraft) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitCarFeeView(draft: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitCarResearchPaymentView.routeName: + if (args is ({CarResearchInvoice invoice, String customerKey})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitCarResearchPaymentView( + invoice: args.invoice, + customerKey: args.customerKey, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitPaymentView.routeName: + if (args is ({int apiTicketId, PaymentInfo paymentInfo})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitPaymentView( + apiTicketId: args.apiTicketId, + paymentInfo: args.paymentInfo, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitSendFromView.routeName: + if (args is Tuple4) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitSendFromView( + coin: args.item1, + amount: args.item2, + address: args.item3, + apiTicketId: args.item4, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case GlobalSettingsView.routeName: return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, @@ -1331,6 +1676,35 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case ManageEpicboxView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ManageEpicboxView(walletId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case AddEditEpicboxMobileView.routeName: + if (args + is ({ + AddEditEpicboxMobileViewType viewType, + String? epicBoxId, + String routeOnSuccessOrDelete, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => AddEditEpicboxMobileView( + viewType: args.viewType, + epicBoxId: args.epicBoxId, + routeOnSuccessOrDelete: args.routeOnSuccessOrDelete, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case WalletBackupView.routeName: if (args is ({String walletId, List mnemonic})) { return getRoute( @@ -1694,6 +2068,16 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case EpicFinalizeView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => EpicFinalizeView(walletId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case WalletAddressesView.routeName: if (args is String) { return getRoute( @@ -1767,6 +2151,28 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SolTokenSendView.routeName: + if (args is (String, String)) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => + SolTokenSendView(walletId: args.$1, tokenMint: args.$2), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case SolTokenReceiveView.routeName: + if (args is (String, String)) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => + SolTokenReceiveView(walletId: args.$1, tokenMint: args.$2), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case ConfirmTransactionView.routeName: if (args is (TxData, String, VoidCallback)) { return getRoute( @@ -1965,11 +2371,11 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); - case ChooseFromStackView.routeName: + case ChooseAddressFromStackView.routeName: if (args is CryptoCurrency) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => ChooseFromStackView(coin: args), + builder: (_) => ChooseAddressFromStackView(coin: args), settings: RouteSettings(name: settings.name), ); } @@ -2162,6 +2568,27 @@ class RouteGenerator { settings: RouteSettings(name: settings.name), ); + case DesktopServicesView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const DesktopServicesView(), + settings: RouteSettings(name: settings.name), + ); + + case DesktopShopInBitView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const DesktopShopInBitView(), + settings: RouteSettings(name: settings.name), + ); + + case DesktopGiftCardsView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const DesktopGiftCardsView(), + settings: RouteSettings(name: settings.name), + ); + case MyStackView.routeName: return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, @@ -2506,14 +2933,44 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SolTokenView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SolTokenView(walletId: args), + settings: RouteSettings(name: settings.name), + ); + } else if (args is ({String walletId, bool popPrevious})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SolTokenView( + walletId: args.walletId, + popPrevious: args.popPrevious, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + // == End of desktop specific routes ===================================== + case SparkViewKeyView.routeName: + if (args is (String, String)) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => + SparkViewKeyView(walletId: args.$1, sparkViewKeyHex: args.$2), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + default: return _routeError(""); } } - static Route getRoute({ + static Route getRoute({ bool shouldUseMaterialRoute = useMaterialPageRoute, required Widget Function(BuildContext) builder, String? title, @@ -2522,14 +2979,14 @@ class RouteGenerator { bool fullscreenDialog = false, }) { if (shouldUseMaterialRoute) { - return MaterialPageRoute( + return MaterialPageRoute( builder: builder, settings: settings, maintainState: maintainState, fullscreenDialog: fullscreenDialog, ); } else { - return CupertinoPageRoute( + return CupertinoPageRoute( builder: builder, settings: settings, title: title, @@ -2539,7 +2996,7 @@ class RouteGenerator { } } - static Route createSlideTransitionRoute(Widget viewToInsert) { + static Route createSlideTransitionRoute(Widget viewToInsert) { return PageRouteBuilder( pageBuilder: (context, animation, secondaryAnimation) => viewToInsert, transitionsBuilder: (context, animation, secondaryAnimation, child) { @@ -2557,7 +3014,7 @@ class RouteGenerator { ); } - static Route _routeError(String message) { + static Route _routeError(String message) { // Replace with robust ErrorView page final Widget errorView = Scaffold( appBar: AppBar( @@ -2571,7 +3028,7 @@ class RouteGenerator { ), ); - return getRoute( + return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, builder: (_) => errorView, ); diff --git a/lib/services/auto_swb_service.dart b/lib/services/auto_swb_service.dart index 24f58d0f78..419e7a0804 100644 --- a/lib/services/auto_swb_service.dart +++ b/lib/services/auto_swb_service.dart @@ -17,14 +17,11 @@ import 'package:tuple/tuple.dart'; import '../pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart'; import '../utilities/flutter_secure_storage_interface.dart'; +import '../utilities/fs.dart'; import '../utilities/logger.dart'; import '../utilities/prefs.dart'; -enum AutoSWBStatus { - idle, - backingUp, - error, -} +enum AutoSWBStatus { idle, backingUp, error } class AutoSWBService extends ChangeNotifier { Timer? _timer; @@ -74,27 +71,32 @@ class AutoSWBService extends ChangeNotifier { ); final jsonString = jsonEncode(json); - final adkString = - await secureStorageInterface.read(key: "auto_adk_string"); + final adkString = await secureStorageInterface.read( + key: "auto_adk_string", + ); - final adkVersionString = - await secureStorageInterface.read(key: "auto_adk_version_string"); + final adkVersionString = await secureStorageInterface.read( + key: "auto_adk_version_string", + ); final int adkVersion = int.parse(adkVersionString!); final DateTime now = DateTime.now(); - final String fileToSave = - createAutoBackupFilename(autoBackupDirectoryPath, now); + final String fileToSave = createAutoBackupFilename( + autoBackupDirectoryPath, + now, + ); - final result = await SWB.encryptStackWalletWithADK( - fileToSave, + final content = await SWB.encryptStackWalletWithADK( adkString!, jsonString, adkVersion, ); - if (!result) { - throw Exception("stack auto backup service failed to create a backup"); - } + await FS.writeStringToFile( + content, + autoBackupDirectoryPath, + fileToSave.split("/").last, + ); Prefs.instance.lastAutoBackup = now; @@ -124,6 +126,13 @@ class AutoSWBService extends ChangeNotifier { /// Trim the number of auto backup files based on age void trimBackups(String dirPath, int numberToKeep) { + if (Platform.isAndroid && dirPath.startsWith("content://")) { + Logging.instance.w( + "Android SAF lib doesn't provide a deletion API. Cannot trim/rotate out old backups", + ); + return; + } + final dir = Directory(dirPath); final List> files = []; diff --git a/lib/services/cakepay/cakepay_api.dart b/lib/services/cakepay/cakepay_api.dart new file mode 100644 index 0000000000..c3af35a833 --- /dev/null +++ b/lib/services/cakepay/cakepay_api.dart @@ -0,0 +1,10 @@ +export 'src/client.dart'; +export 'src/api_response.dart'; +export 'src/api_exception.dart'; +export 'src/endpoints.dart'; +export 'src/models/vendor.dart'; +export 'src/models/card.dart'; +export 'src/models/country.dart'; +export 'src/models/order.dart'; +export 'src/models/order_item.dart'; +export 'src/models/category.dart'; diff --git a/lib/services/cakepay/cakepay_orders_service.dart b/lib/services/cakepay/cakepay_orders_service.dart new file mode 100644 index 0000000000..fd9083bd46 --- /dev/null +++ b/lib/services/cakepay/cakepay_orders_service.dart @@ -0,0 +1,169 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'cakepay_service.dart'; +import 'src/models/order.dart'; + +/// Holds an in-memory cache of CakePay orders, refreshes them in the +/// background, and notifies listeners only when something actually changed. +/// +/// Modelled on `PriceService` — see `lib/services/price_service.dart`. +class CakePayOrdersService extends ChangeNotifier { + static const Duration defaultPollInterval = Duration(seconds: 15); + + final Map _orders = {}; + final Map> _inFlight = {}; + final Map _polls = {}; + Completer? _refreshAllCompleter; + + /// Current cached value for [orderId], or null if not yet fetched. + CakePayOrder? get(String orderId) => _orders[orderId]; + + /// Snapshot of all cached orders, sorted by `createdAt` descending. + List get all { + final list = _orders.values.toList(); + list.sort((a, b) { + final ac = a.createdAt; + final bc = b.createdAt; + if (ac == null && bc == null) return 0; + if (ac == null) return 1; + if (bc == null) return -1; + return bc.compareTo(ac); + }); + return list; + } + + bool isRefreshing(String orderId) => _inFlight.containsKey(orderId); + bool get isRefreshingAll => _refreshAllCompleter != null; + + /// returns existing future if already in flight + Future refreshOne(String orderId) async { + final Completer? pending = _inFlight[orderId]; + if (pending != null) return pending.future; + + final Completer completer = Completer(); + _inFlight[orderId] = completer; + notifyListeners(); + + unawaited(() async { + try { + final resp = await CakePayService.instance.client.getOrder(orderId); + if (!resp.hasError && resp.value != null) { + _putIfChanged(resp.value!); + } + completer.complete(); + } catch (e, s) { + completer.completeError(e, s); + } finally { + _inFlight.remove(orderId); + notifyListeners(); + } + }()); + + return completer.future; + } + + /// Fetch every locally-tracked order in parallel. Returns the existing + /// future if a refresh-all is already in flight, so awaiters can be sure a + /// refresh has actually occurred. + Future refreshAll() async { + final Completer? pending = _refreshAllCompleter; + if (pending != null) return pending.future; + + final Completer completer = Completer(); + _refreshAllCompleter = completer; + notifyListeners(); + + unawaited(() async { + try { + final ids = await CakePayService.instance.getOrderIds(); + await Future.wait(ids.map(refreshOne)); + completer.complete(); + } catch (e, s) { + completer.completeError(e, s); + } finally { + _refreshAllCompleter = null; + notifyListeners(); + } + }()); + + return completer.future; + } + + /// Start (or join) a refcounted poll for [orderId]. The first call kicks off + /// an immediate refresh and creates the timer; subsequent calls just bump + /// the refcount. Each call must be paired with [stopPolling]. + void startPolling(String orderId, {Duration interval = defaultPollInterval}) { + final existing = _polls[orderId]; + if (existing != null) { + existing.refs += 1; + return; + } + final poll = _Poll(refs: 1, timer: null); + _polls[orderId] = poll; + // Immediate fetch. + unawaited(refreshOne(orderId)); + poll.timer = Timer.periodic(interval, (_) { + final cached = _orders[orderId]; + if (cached != null && _isTerminal(cached.status)) { + _cancel(orderId); + return; + } + unawaited(refreshOne(orderId)); + }); + } + + void stopPolling(String orderId) { + final poll = _polls[orderId]; + if (poll == null) return; + poll.refs -= 1; + if (poll.refs <= 0) { + _cancel(orderId); + } + } + + void _cancel(String orderId) { + _polls.remove(orderId)?.timer?.cancel(); + } + + void _putIfChanged(CakePayOrder order) { + final existing = _orders[order.orderId]; + if (existing == null || !_equals(existing, order)) { + _orders[order.orderId] = order; + } + } + + static bool _isTerminal(CakePayOrderStatus s) => + s == CakePayOrderStatus.complete || + s == CakePayOrderStatus.expired || + s == CakePayOrderStatus.failed || + s == CakePayOrderStatus.refunded; + + static bool _equals(CakePayOrder a, CakePayOrder b) { + return a.orderId == b.orderId && + a.status == b.status && + a.amountUsd == b.amountUsd && + a.expirationTime == b.expirationTime && + a.invoiceTime == b.invoiceTime && + a.commission == b.commission && + a.markupPercent == b.markupPercent && + a.createdAt == b.createdAt && + a.externalOrderId == b.externalOrderId; + } + + @override + void dispose() { + for (final p in _polls.values) { + p.timer?.cancel(); + } + _polls.clear(); + super.dispose(); + } +} + +class _Poll { + _Poll({required this.refs, required this.timer}); + int refs; + Timer? timer; +} diff --git a/lib/services/cakepay/cakepay_service.dart b/lib/services/cakepay/cakepay_service.dart new file mode 100644 index 0000000000..ed1bfd0f64 --- /dev/null +++ b/lib/services/cakepay/cakepay_service.dart @@ -0,0 +1,80 @@ +import 'package:drift/drift.dart'; +import 'package:mutex/mutex.dart'; + +import '../../db/drift/shared_db/shared_database.dart'; +import '../../external_api_keys.dart'; +import 'src/client.dart'; + +class CakePayService { + static final instance = CakePayService._(); + CakePayService._(); + + CakePayClient? _client; + + CakePayClient get client { + return _client ??= CakePayClient(apiToken: kCakePayApiToken); + } + + // TODO clean this up some day + // simple in memory cache + DateTime? _countryNamesUpdated; + List _countryNames = []; + final _countryNamesMutex = Mutex(); + Future> getCountryNames({bool refreshCache = false}) async { + return _countryNamesMutex.protect(() async { + final isFresh = + _countryNamesUpdated != null && + _countryNamesUpdated! + .add(const Duration(hours: 12)) + .isAfter(DateTime.now()); + + if (!refreshCache && isFresh && _countryNames.isNotEmpty) { + return _countryNames; + } + + final response = await client.getAllCountries(); + + if (response.hasError || response.value == null) { + throw response.exception ?? Exception("Failed to fetch countries"); + } + + _countryNames = + response.value! + .where((e) => e.available) + .map((e) => e.name) + .toSet() + .toList() + ..sort(); + + _countryNamesUpdated = DateTime.now(); + + return _countryNames; + }); + } + + Future addOrderId(String orderId) async { + final db = SharedDrift.get(); + + await db.transaction(() async { + await db + .into(db.cakepayOrders) + .insert( + CakepayOrdersCompanion.insert(orderId: orderId), + mode: .insertOrIgnore, + ); + }); + } + + /// Return locally-tracked order IDs (most recent first). + Future> getOrderIds() async { + final db = SharedDrift.get(); + + final rows = + await (db.select(db.cakepayOrders)..orderBy([ + (t) => OrderingTerm(expression: t.rowId, mode: OrderingMode.desc), + ])) + .get(); + + return rows.map((row) => row.orderId).toList(); + } +} diff --git a/lib/services/cakepay/src/api_exception.dart b/lib/services/cakepay/src/api_exception.dart new file mode 100644 index 0000000000..6e35192572 --- /dev/null +++ b/lib/services/cakepay/src/api_exception.dart @@ -0,0 +1,24 @@ +class ApiException implements Exception { + final String message; + final int? statusCode; + final String? responseBody; + + ApiException(this.message, {this.statusCode, this.responseBody}); + + factory ApiException.fromResponse(int statusCode, String body) { + return ApiException( + 'HTTP $statusCode', + statusCode: statusCode, + responseBody: body, + ); + } + + factory ApiException.network(Object error) { + return ApiException('Network error: $error'); + } + + @override + String toString() => + 'ApiException: $message' + '${statusCode != null ? ' (status: $statusCode)' : ''}'; +} diff --git a/lib/services/cakepay/src/api_response.dart b/lib/services/cakepay/src/api_response.dart new file mode 100644 index 0000000000..27fd26d3e4 --- /dev/null +++ b/lib/services/cakepay/src/api_response.dart @@ -0,0 +1,19 @@ +import 'api_exception.dart'; + +class ApiResponse { + final T? value; + final ApiException? exception; + + ApiResponse({this.value, this.exception}); + + bool get hasError => exception != null; + + T get valueOrThrow { + if (exception != null) throw exception!; + if (value == null) throw ApiException('Response has no value'); + return value as T; + } + + @override + String toString() => '{error: $exception, value: $value}'; +} diff --git a/lib/services/cakepay/src/client.dart b/lib/services/cakepay/src/client.dart new file mode 100644 index 0000000000..7d401bcf62 --- /dev/null +++ b/lib/services/cakepay/src/client.dart @@ -0,0 +1,537 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../../../app_config.dart'; +import '../../../networking/http.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/prefs.dart'; +import '../../tor_service.dart'; +import 'api_exception.dart'; +import 'api_response.dart'; +import 'endpoints.dart'; +import 'models/card.dart'; +import 'models/country.dart'; +import 'models/order.dart'; +import 'models/vendor.dart'; + +const _kTag = "CakePayClient"; + +class CakePayClient { + final String baseUrl; + final String apiToken; + final HTTP _httpClient; + + CakePayClient({ + this.baseUrl = Endpoints.base, + required this.apiToken, + HTTP? httpClient, + }) : _httpClient = httpClient ?? const HTTP(); + + late final _authHeaders = { + 'Authorization': 'Bearer $apiToken', + 'Content-Type': 'application/json', + }; + + ({InternetAddress host, int port})? get _proxyInfo => + !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null; + + // -- Marketplace -- + + Future vendors, int? nextPage})>> + getVendors({ + String? country, + String? countryCode, + String? search, + int? page, + int? pageSize, + bool? all, + bool? giftCards, + bool? prepaidCards, + bool? onDemand, + bool? custom, + }) async { + final query = {}; + if (country != null) query['country'] = country; + if (countryCode != null) query['country_code'] = countryCode; + if (search != null) query['search'] = search; + if (page != null) query['page'] = page.toString(); + if (pageSize != null) query['page_size'] = pageSize.toString(); + if (all != null) query['all'] = all.toString(); + if (giftCards != null) query['gift_cards'] = giftCards.toString(); + if (prepaidCards != null) query['prepaid_cards'] = prepaidCards.toString(); + if (onDemand != null) query['on_demand'] = onDemand.toString(); + if (custom != null) query['custom'] = custom.toString(); + + return _requestRaw( + 'GET', + '/marketplace/vendors/', + query: query, + parse: (body) { + final dynamic decoded = jsonDecode(body); + + final List rawList = switch (decoded) { + final List list => list, + {"results": final List results} => results, + _ => const [], + }; + + final List vendors = rawList + .whereType>() + .map(CakePayVendor.fromJson) + .toList(); + + final int? nextPage = + (page != null && pageSize != null && vendors.length >= pageSize) + ? page + 1 + : null; + + return (vendors: vendors, nextPage: nextPage); + }, + ); + } + + Future> getCard(int id) async { + return _request( + 'GET', + '/marketplace/cards/$id/', + parse: CakePayCard.fromJson, + ); + } + + Future>> searchCards({ + String? query, + String? category, + String? country, + double? minPrice, + double? maxPrice, + bool? availableOnly, + int? page, + }) async { + final params = {}; + if (query != null) params['query'] = query; + if (category != null) params['category'] = category; + if (country != null) params['country'] = country; + if (minPrice != null) params['min_price'] = minPrice.toString(); + if (maxPrice != null) params['max_price'] = maxPrice.toString(); + if (availableOnly != null) { + params['available_only'] = availableOnly.toString(); + } + if (page != null) params['page'] = page.toString(); + + return _requestRaw( + 'GET', + '/marketplace/cards/search/', + query: params, + parse: (body) { + final decoded = jsonDecode(body); + if (decoded is List) { + return decoded + .whereType>() + .map(CakePayCard.fromJson) + .toList(); + } + if (decoded is Map) { + final results = decoded['results']; + if (results is List) { + return results + .whereType>() + .map(CakePayCard.fromJson) + .toList(); + } + } + return []; + }, + ); + } + + Future>> getFeaturedCards({int? page}) async { + final query = {}; + if (page != null) query['page'] = page.toString(); + + return _requestRaw( + 'GET', + '/marketplace/cards/featured/', + query: query, + parse: (body) { + final decoded = jsonDecode(body); + if (decoded is List) { + return decoded + .whereType>() + .map(CakePayCard.fromJson) + .toList(); + } + if (decoded is Map) { + final results = decoded['results']; + if (results is List) { + return results + .whereType>() + .map(CakePayCard.fromJson) + .toList(); + } + } + return []; + }, + ); + } + + /// Fetches all countries by following pagination til last page. + Future>> getAllCountries() async { + try { + final allCountries = []; + int page = 1; + + while (true) { + final response = await _send( + 'GET', + '/marketplace/countries/', + query: {'page': page.toString()}, + overrideHeaders: {}, // Auth here leads to 403. Why? Who knows? + ); + + if (response.code < 200 || response.code >= 300) { + Logging.instance.w( + "$_kTag GET /marketplace/countries/ HTTP:${response.code} " + "body: ${response.body}", + ); + return ApiResponse( + exception: ApiException.fromResponse(response.code, response.body), + ); + } + + final decoded = jsonDecode(response.body); + + // This never gets hit according to docs + // Handle non-paginated response (plain list). + // if (decoded is List) { + // return ApiResponse( + // value: decoded + // .whereType>() + // .map(CakePayCountry.fromJson) + // .toList(), + // ); + // } + + if (decoded is Map) { + final results = decoded['results']; + if (results is List) { + allCountries.addAll( + results.whereType>().map( + CakePayCountry.fromJson, + ), + ); + } + + // If there is no next page we're done. + if (decoded['next'] == null) break; + } else { + break; + } + + page++; + } + + return ApiResponse(value: allCountries); + } on ApiException catch (e) { + Logging.instance.e("$_kTag getAllCountries threw: ", error: e); + return ApiResponse(exception: e); + } catch (e, s) { + Logging.instance.e( + "$_kTag getAllCountries threw: ", + error: e, + stackTrace: s, + ); + return ApiResponse(exception: ApiException.network(e)); + } + } + + /// List cards from the marketplace with optional pagination. + Future>> getCards({ + int? page, + int? pageSize, + }) async { + final query = {}; + if (page != null) query['page'] = page.toString(); + if (pageSize != null) query['page_size'] = pageSize.toString(); + + return _requestRaw( + 'GET', + '/marketplace/cards/', + query: query, + parse: (body) { + final decoded = jsonDecode(body); + if (decoded is List) { + return decoded + .whereType>() + .map(CakePayCard.fromJson) + .toList(); + } + if (decoded is Map) { + final results = decoded['results']; + if (results is List) { + return results + .whereType>() + .map(CakePayCard.fromJson) + .toList(); + } + } + return []; + }, + ); + } + + /// Fetches the list of marketplace providers. + /// + /// Endpoint: GET `/marketplace/providers/` + Future>>> getProviders() async { + return _requestRaw( + 'GET', + '/marketplace/providers/', + parse: (body) { + final decoded = jsonDecode(body); + if (decoded is List) { + return decoded.whereType>().toList(); + } + if (decoded is Map) { + final results = decoded['results']; + if (results is List) { + return results.whereType>().toList(); + } + } + return []; + }, + ); + } + + /// Fetches marketplace statistics. + /// + /// Endpoint: GET `/marketplace/stats/` + Future>> getStats() async { + return _request('GET', '/marketplace/stats/', parse: (json) => json); + } + + Future>> getBannedCountries() async { + return _requestRaw( + 'GET', + '/core/banned_countries/', + parse: (body) { + final decoded = jsonDecode(body); + if (decoded is List) { + return decoded.whereType().toList(); + } + return []; + }, + ); + } + + // -- Orders -- + + /// Create an order via the seller API. + /// + /// Posts to `/orders/seller/create/`. The response wraps the order object + /// in `{"message": "...", "order": {...}}`, so we extract `json['order']` + /// before parsing. + Future> createOrder({ + required int cardId, + required String price, + int? quantity, + String? userEmail, + bool? sendEmail, + String? externalOrderId, + String? markupPercent, + bool? confirmsNoVpn, + bool? confirmsVoidedRefund, + bool? confirmsTermsAgreed, + }) async { + final body = {'card_id': cardId, 'price': price}; + if (quantity != null) body['quantity'] = quantity; + if (userEmail != null) body['user_email'] = userEmail; + if (sendEmail != null) body['send_email'] = sendEmail; + if (externalOrderId != null) body['external_order_id'] = externalOrderId; + if (markupPercent != null) body['markup_percent'] = markupPercent; + if (confirmsNoVpn != null) body['confirms_no_vpn'] = confirmsNoVpn; + if (confirmsVoidedRefund != null) { + body['confirms_voided_refund'] = confirmsVoidedRefund; + } + if (confirmsTermsAgreed != null) { + body['confirms_terms_agreed'] = confirmsTermsAgreed; + } + + return _requestRaw( + 'POST', + '/orders/seller/create/', + body: body, + parse: (responseBody) { + final decoded = jsonDecode(responseBody); + if (decoded is Map) { + final orderData = decoded['order']; + if (orderData is Map) { + return CakePayOrder.fromJson(orderData); + } + return CakePayOrder.fromJson(decoded); + } + return CakePayOrder.fromJson({}); + }, + ); + } + + /// Fetch a single order via the seller API. + Future> getOrder(String orderId) async { + return _request( + 'GET', + '/orders/seller/order/$orderId/', + parse: CakePayOrder.fromJson, + ); + } + + /// Fetch the current user's orders. + /// + /// **Note:** This endpoint requires Knox user authentication (email OTP + /// flow), not the seller API key. It will fail when called with only the + /// seller bearer token. + Future>> getMyOrders({ + int? page, + List? orderIds, + }) async { + final query = {}; + if (page != null) query['page'] = page.toString(); + if (orderIds != null && orderIds.isNotEmpty) { + query['order_ids'] = orderIds.join(','); + } + + return _requestRaw( + 'GET', + '/orders/my_orders/', + query: query, + parse: (body) { + final decoded = jsonDecode(body); + if (decoded is List) { + return decoded + .whereType>() + .map(CakePayOrder.fromJson) + .toList(); + } + if (decoded is Map) { + final results = decoded['results']; + if (results is List) { + return results + .whereType>() + .map(CakePayOrder.fromJson) + .toList(); + } + } + return []; + }, + ); + } + + // -- Internal -- + + Future _send( + String method, + String path, { + Map? body, + Map? query, + Map? overrideHeaders, + }) async { + var uri = Uri.parse('$baseUrl$path'); + if (query != null && query.isNotEmpty) { + uri = uri.replace(queryParameters: query); + } + final headers = overrideHeaders ?? _authHeaders; + final proxy = _proxyInfo; + + Logging.instance.t("$_kTag $method $uri"); + + switch (method) { + case 'GET': + return _httpClient.get(url: uri, headers: headers, proxyInfo: proxy); + case 'POST': + return _httpClient.post( + url: uri, + headers: headers, + body: body != null ? jsonEncode(body) : null, + proxyInfo: proxy, + ); + default: + throw ApiException('Unsupported method: $method'); + } + } + + Future> _request( + String method, + String path, { + Map? body, + Map? query, + required T Function(Map) parse, + }) async { + try { + final response = await _send(method, path, body: body, query: query); + + if (response.code >= 200 && response.code < 300) { + Logging.instance.t("$_kTag $method $path HTTP:${response.code}"); + if (response.body.isEmpty) { + return ApiResponse(value: parse({})); + } + final json = jsonDecode(response.body) as Map; + return ApiResponse(value: parse(json)); + } else { + Logging.instance.w( + "$_kTag $method $path HTTP:${response.code} " + "body: ${response.body}", + ); + return ApiResponse( + exception: ApiException.fromResponse(response.code, response.body), + ); + } + } on ApiException catch (e) { + Logging.instance.e("$_kTag _request($method $path) threw: ", error: e); + return ApiResponse(exception: e); + } catch (e, s) { + Logging.instance.e( + "$_kTag _request($method $path) threw: ", + error: e, + stackTrace: s, + ); + return ApiResponse(exception: ApiException.network(e)); + } + } + + Future> _requestRaw( + String method, + String path, { + Map? body, + Map? query, + required T Function(String) parse, + }) async { + try { + final response = await _send(method, path, body: body, query: query); + + if (response.code >= 200 && response.code < 300) { + Logging.instance.t("$_kTag $method $path HTTP:${response.code}"); + return ApiResponse(value: parse(response.body)); + } else { + Logging.instance.w( + "$_kTag $method $path HTTP:${response.code} " + "body: ${response.body}", + ); + return ApiResponse( + exception: ApiException.fromResponse(response.code, response.body), + ); + } + } on ApiException catch (e) { + Logging.instance.e("$_kTag _requestRaw($method $path) threw: ", error: e); + return ApiResponse(exception: e); + } catch (e, s) { + Logging.instance.e( + "$_kTag _requestRaw($method $path) threw: ", + error: e, + stackTrace: s, + ); + return ApiResponse(exception: ApiException.network(e)); + } + } +} diff --git a/lib/services/cakepay/src/endpoints.dart b/lib/services/cakepay/src/endpoints.dart new file mode 100644 index 0000000000..340128c09a --- /dev/null +++ b/lib/services/cakepay/src/endpoints.dart @@ -0,0 +1,3 @@ +class Endpoints { + static const base = 'https://api-prod.cakepay.com/api'; +} diff --git a/lib/services/cakepay/src/models/card.dart b/lib/services/cakepay/src/models/card.dart new file mode 100644 index 0000000000..83d2eb3bc1 --- /dev/null +++ b/lib/services/cakepay/src/models/card.dart @@ -0,0 +1,123 @@ +import "package:decimal/decimal.dart"; + +class CakePayCard { + final int id; + final String name; + final String? type; + final String? description; + final String? termsAndConditions; + final String? howToUse; + final String? expiryAndValidity; + final String? cardImageUrl; + final String? country; + final String? currencyCode; + final List denominations; + final Decimal? minValue; + final Decimal? maxValue; + final Decimal? minValueUsd; + final Decimal? maxValueUsd; + final bool available; + final String? lastUpdated; + + CakePayCard({ + required this.id, + required this.name, + this.type, + this.description, + this.termsAndConditions, + this.howToUse, + this.expiryAndValidity, + this.cardImageUrl, + this.country, + this.currencyCode, + required this.denominations, + this.minValue, + this.maxValue, + this.minValueUsd, + this.maxValueUsd, + required this.available, + this.lastUpdated, + }); + + factory CakePayCard.fromJson(Map json) { + final dynamic rawDenoms = + json["denominations"] ?? json["denominations_list"]; + final List denominations = []; + if (rawDenoms is List) { + for (final dynamic d in rawDenoms) { + final Decimal? parsed = _toDecimal(d is Map ? d["value"] : d); + if (parsed != null) denominations.add(parsed); + } + } + + return CakePayCard( + id: json["id"] as int? ?? 0, + name: (json["name"] ?? "") as String, + type: json["type"] as String?, + description: json["description"] as String?, + termsAndConditions: json["terms_and_conditions"] as String?, + howToUse: json["how_to_use"] as String?, + expiryAndValidity: json["expiry_and_validity"] as String?, + cardImageUrl: json["card_image_url"] as String?, + country: json["country"] is Map + ? (json["country"] as Map)["name"] as String? + : json["country"] as String?, + currencyCode: json["currency_code"] as String?, + denominations: denominations, + minValue: _toDecimal(json["min_value"]), + maxValue: _toDecimal(json["max_value"]), + minValueUsd: _toDecimal(json["min_value_usd"]), + maxValueUsd: _toDecimal(json["max_value_usd"]), + available: json["available"] as bool? ?? true, + lastUpdated: json["last_updated"] as String?, + ); + } + + Map toMap() { + return { + "id": id, + "name": name, + "type": type, + "description": description, + "terms_and_conditions": termsAndConditions, + "how_to_use": howToUse, + "expiry_and_validity": expiryAndValidity, + "card_image_url": cardImageUrl, + "country": country, + "currency_code": currencyCode, + "denominations": denominations.map((Decimal d) => d.toString()).toList(), + "min_value": minValue?.toString(), + "max_value": maxValue?.toString(), + "min_value_usd": minValueUsd?.toString(), + "max_value_usd": maxValueUsd?.toString(), + "available": available, + "last_updated": lastUpdated, + }; + } + + bool get isFixedDenomination => denominations.isNotEmpty; + bool get isRangeDenomination => + denominations.isEmpty && minValue != null && maxValue != null; + + String get denominationRange { + if (isFixedDenomination) { + return denominations.map((Decimal d) => d.toStringAsFixed(0)).join(", "); + } + if (isRangeDenomination) { + return "${minValue!.toStringAsFixed(0)} - ${maxValue!.toStringAsFixed(0)}"; + } + return ""; + } + + @override + String toString() => toMap().toString(); +} + +Decimal? _toDecimal(dynamic v) { + if (v == null) return null; + if (v is Decimal) return v; + if (v is int) return Decimal.fromInt(v); + if (v is double) return Decimal.parse(v.toString()); + if (v is String) return Decimal.tryParse(v); + return null; +} diff --git a/lib/services/cakepay/src/models/category.dart b/lib/services/cakepay/src/models/category.dart new file mode 100644 index 0000000000..d097d03197 --- /dev/null +++ b/lib/services/cakepay/src/models/category.dart @@ -0,0 +1,31 @@ +class CakePayCategory { + final int id; + final String name; + final String? emoji; + final String? slug; + final bool isActive; + final int sortOrder; + + CakePayCategory({ + required this.id, + required this.name, + this.emoji, + this.slug, + required this.isActive, + required this.sortOrder, + }); + + factory CakePayCategory.fromJson(Map json) { + return CakePayCategory( + id: json['id'] as int? ?? 0, + name: (json['name'] ?? '') as String, + emoji: json['emoji'] as String?, + slug: json['slug'] as String?, + isActive: json['is_active'] as bool? ?? true, + sortOrder: json['sort_order'] as int? ?? 0, + ); + } + + @override + String toString() => 'CakePayCategory($id, $name)'; +} diff --git a/lib/services/cakepay/src/models/country.dart b/lib/services/cakepay/src/models/country.dart new file mode 100644 index 0000000000..36a65960ae --- /dev/null +++ b/lib/services/cakepay/src/models/country.dart @@ -0,0 +1,28 @@ +class CakePayCountry { + final String name; + final String countryCode; + final String currencyCode; + final String? image; + final bool available; + + CakePayCountry({ + required this.name, + required this.countryCode, + required this.currencyCode, + this.image, + required this.available, + }); + + factory CakePayCountry.fromJson(Map json) { + return CakePayCountry( + name: (json['name'] ?? '') as String, + countryCode: (json['country_code'] ?? '') as String, + currencyCode: (json['currency_code'] ?? '') as String, + image: json['image'] as String?, + available: json['available'] as bool? ?? true, + ); + } + + @override + String toString() => 'CakePayCountry($countryCode, $name)'; +} diff --git a/lib/services/cakepay/src/models/order.dart b/lib/services/cakepay/src/models/order.dart new file mode 100644 index 0000000000..35fab83e54 --- /dev/null +++ b/lib/services/cakepay/src/models/order.dart @@ -0,0 +1,220 @@ +import 'dart:ui'; + +import '../../../../themes/stack_colors.dart'; +import 'order_item.dart'; + +enum CakePayOrderStatus { + new_('new'), + expiredButStillPending('expired_but_still_pending'), + expired('expired'), + failed('failed'), + paid('paid'), + paidPartial('paid_partial'), + pendingPurchase('pending_purchase'), + purchaseProcessing('purchase_processing'), + purchased('purchased'), + pendingEmail('pending_email'), + complete('complete'), + pendingRefund('pending_refund'), + refunded('refunded'); + + final String value; + const CakePayOrderStatus(this.value); + + static CakePayOrderStatus fromString(String s) { + return CakePayOrderStatus.values.firstWhere( + (e) => e.value == s, + orElse: () => CakePayOrderStatus.new_, + ); + } + + String get label => switch (this) { + CakePayOrderStatus.new_ => "New", + CakePayOrderStatus.expiredButStillPending => "Expired (pending)", + CakePayOrderStatus.expired => "Expired", + CakePayOrderStatus.failed => "Failed", + CakePayOrderStatus.paid => "Paid", + CakePayOrderStatus.paidPartial => "Partially paid", + CakePayOrderStatus.pendingPurchase => "Pending purchase", + CakePayOrderStatus.purchaseProcessing => "Processing", + CakePayOrderStatus.purchased => "Purchased", + CakePayOrderStatus.pendingEmail => "Pending email", + CakePayOrderStatus.complete => "Complete", + CakePayOrderStatus.pendingRefund => "Pending refund", + CakePayOrderStatus.refunded => "Refunded", + }; + + Color color(StackColors themeColors) { + return switch (this) { + CakePayOrderStatus.complete || + CakePayOrderStatus.purchased => themeColors.accentColorGreen, + CakePayOrderStatus.new_ || + CakePayOrderStatus.paid || + CakePayOrderStatus.paidPartial => themeColors.accentColorBlue, + CakePayOrderStatus.pendingPurchase || + CakePayOrderStatus.purchaseProcessing || + CakePayOrderStatus.pendingEmail || + CakePayOrderStatus.expiredButStillPending => + themeColors.accentColorYellow, + CakePayOrderStatus.expired || + CakePayOrderStatus.failed || + CakePayOrderStatus.pendingRefund || + CakePayOrderStatus.refunded => themeColors.textSubtitle1, + }; + } +} + +/// A single crypto payment option within [CakePayOrder.paymentOptions]. +/// +/// The API returns `payment_data` as a map whose keys are crypto tickers +/// (e.g. `"BTC"`, `"XMR"`) each mapping to an object with `amount_from` +/// and `address`. +class CakePayPaymentOption { + final String ticker; + final double amountFrom; + final String address; + + CakePayPaymentOption({ + required this.ticker, + required this.amountFrom, + required this.address, + }); + + @override + String toString() => 'CakePayPaymentOption($ticker, $amountFrom, $address)'; +} + +class CakePayOrder { + final String orderId; + final CakePayOrderStatus status; + final String? amountUsd; + final List? cards; + + /// Raw `payment_data` map preserved for backward compatibility. + /// + /// Prefer [paymentOptions] for structured access to crypto payment + /// methods. + final Map? paymentData; + + /// Structured crypto payment options parsed from `payment_data`. + /// + /// Keys are crypto tickers (e.g. `"BTC"`, `"XMR"`, `"BTC_LN"`). + final Map? paymentOptions; + + /// Unix-millis timestamp when the payment window expires. + final int? expirationTime; + + /// Unix-millis timestamp when the invoice was created. + final int? invoiceTime; + + final String? commission; + final double? markupPercent; + final String? createdAt; + final String? externalOrderId; + + CakePayOrder({ + required this.orderId, + required this.status, + this.amountUsd, + this.cards, + this.paymentData, + this.paymentOptions, + this.expirationTime, + this.invoiceTime, + this.commission, + this.markupPercent, + this.createdAt, + this.externalOrderId, + }); + + factory CakePayOrder.fromJson(Map json) { + final rawCards = json['cards']; + List? cards; + if (rawCards is List) { + cards = rawCards + .whereType>() + .map(CakePayOrderItem.fromJson) + .toList(); + } + + // ---- payment_data parsing ---- + final rawPayment = json['payment_data']; + Map? paymentData; + Map? paymentOptions; + int? expirationTime; + int? invoiceTime; + + if (rawPayment is Map) { + paymentData = rawPayment; + + // Extract top-level timing fields. + expirationTime = rawPayment['expiration_time'] as int?; + invoiceTime = rawPayment['invoice_time'] as int?; + + // Each remaining key whose value is a Map is a crypto payment option. + paymentOptions = {}; + for (final entry in rawPayment.entries) { + final v = entry.value; + if (v is Map) { + final amountFrom = _toDouble(v['amount_from']); + final address = v['address']?.toString(); + if (amountFrom != null && address != null) { + paymentOptions[entry.key] = CakePayPaymentOption( + ticker: entry.key, + amountFrom: amountFrom, + address: address, + ); + } + } + } + if (paymentOptions.isEmpty) { + paymentOptions = null; + } + } + + return CakePayOrder( + orderId: (json['order_id'] ?? json['id'])?.toString() ?? '', + status: CakePayOrderStatus.fromString( + (json['status'] ?? 'new') as String, + ), + amountUsd: json['amount_usd']?.toString(), + cards: cards, + paymentData: paymentData, + paymentOptions: paymentOptions, + expirationTime: expirationTime, + invoiceTime: invoiceTime, + commission: json['commission']?.toString(), + markupPercent: _toDouble(json['markup_percent']), + createdAt: json['created_at'] as String?, + externalOrderId: json['external_order_id'] as String?, + ); + } + + CakePayOrder copyWith({CakePayOrderStatus? status}) { + return CakePayOrder( + orderId: orderId, + status: status ?? this.status, + amountUsd: amountUsd, + cards: cards, + paymentData: paymentData, + paymentOptions: paymentOptions, + expirationTime: expirationTime, + invoiceTime: invoiceTime, + commission: commission, + markupPercent: markupPercent, + createdAt: createdAt, + externalOrderId: externalOrderId, + ); + } + + @override + String toString() => 'CakePayOrder($orderId, ${status.value})'; +} + +double? _toDouble(dynamic v) { + if (v == null) return null; + if (v is double) return v; + if (v is int) return v.toDouble(); + if (v is String) return double.tryParse(v); + return null; +} diff --git a/lib/services/cakepay/src/models/order_item.dart b/lib/services/cakepay/src/models/order_item.dart new file mode 100644 index 0000000000..b15386e019 --- /dev/null +++ b/lib/services/cakepay/src/models/order_item.dart @@ -0,0 +1,59 @@ +class CakePayOrderItem { + final int? cardId; + final String? name; + + /// The price string as returned by the API. + /// + /// May be a bare number (`"20.00"`) or include the currency + /// (`"20.00 EUR"`). Use [priceValue] when you need only the numeric + /// portion and [currencyCode] for the currency. + final String? price; + + /// The numeric portion of [price] (e.g. `"20.00"`). + final String? priceValue; + + /// Price expressed in USD, as returned by the API (e.g. `"$24.12"`). + final String? priceUsd; + + final int? quantity; + final String? currencyCode; + final String? cardImageUrl; + + CakePayOrderItem({ + this.cardId, + this.name, + this.price, + this.priceValue, + this.priceUsd, + this.quantity, + this.currencyCode, + this.cardImageUrl, + }); + + factory CakePayOrderItem.fromJson(Map json) { + final rawPrice = json['price']?.toString(); + + // The API may return price as "20.00 EUR" (with currency) or just + // "20.00". Extract the leading numeric portion so the UI can display + // it without duplicating the currency code. + String? priceValue; + if (rawPrice != null) { + final match = RegExp(r'^[\d.]+').firstMatch(rawPrice); + priceValue = match?.group(0) ?? rawPrice; + } + + return CakePayOrderItem( + cardId: json['card_id'] as int?, + name: json['name'] as String?, + price: rawPrice, + priceValue: priceValue, + priceUsd: json['price_usd']?.toString(), + quantity: json['quantity'] as int?, + currencyCode: json['currency_code'] as String?, + cardImageUrl: json['card_image_url'] as String?, + ); + } + + @override + String toString() => 'CakePayOrderItem($cardId, $name)'; +} diff --git a/lib/services/cakepay/src/models/vendor.dart b/lib/services/cakepay/src/models/vendor.dart new file mode 100644 index 0000000000..33f80035ae --- /dev/null +++ b/lib/services/cakepay/src/models/vendor.dart @@ -0,0 +1,43 @@ +import 'card.dart'; + +class CakePayVendor { + final int id; + final String name; + final bool available; + final String? cakeWarnings; + final String? country; + final List cards; + + CakePayVendor({ + required this.id, + required this.name, + required this.available, + this.cakeWarnings, + this.country, + required this.cards, + }); + + factory CakePayVendor.fromJson(Map json) { + final rawCards = json['cards']; + final cards = []; + if (rawCards is List) { + for (final c in rawCards) { + if (c is Map) { + cards.add(CakePayCard.fromJson(c)); + } + } + } + + return CakePayVendor( + id: json['id'] as int? ?? 0, + name: (json['name'] ?? '') as String, + available: json['available'] as bool? ?? true, + cakeWarnings: json['cake_warnings'] as String?, + country: json['country'] as String?, + cards: cards, + ); + } + + @override + String toString() => 'CakePayVendor($id, $name)'; +} diff --git a/lib/services/churning_service.dart b/lib/services/churning_service.dart index b1449fd688..d0699871f5 100644 --- a/lib/services/churning_service.dart +++ b/lib/services/churning_service.dart @@ -5,8 +5,9 @@ import 'package:flutter/cupertino.dart'; import 'package:mutex/mutex.dart'; import '../utilities/logger.dart'; -import '../wallets/wallet/intermediate/lib_monero_wallet.dart'; -import '../wl_gen/interfaces/cs_monero_interface.dart'; +import '../wallets/wallet/intermediate/cryptonote_wallet.dart'; +import '../wl_gen/interfaces/cs_monero_interface.dart' + show CsRecipient, CsOutput; enum ChurnStatus { waiting, running, failed, success } @@ -16,7 +17,7 @@ class ChurningService extends ChangeNotifier { ChurningService({required this.wallet}); - final LibMoneroWallet wallet; + final CryptonoteWallet wallet; String get walletId => wallet.walletId; int rounds = 1; // default @@ -31,9 +32,9 @@ class ChurningService extends ChangeNotifier { bool done = false; Object? lastSeenError; - bool _canChurn() { + Future _canChurn() async { if (wallet.wallet != null && - csMonero.getUnlockedBalance(wallet.wallet!, accountIndex: kAccount)! > + await wallet.internalGetUnlockedBalance(accountIndex: kAccount) > BigInt.zero) { return true; } else { @@ -50,7 +51,7 @@ class ChurningService extends ChangeNotifier { final outputs = wallet.wallet == null ? [] - : await csMonero.getOutputs(wallet.wallet!, refresh: true); + : await wallet.internalGetOutputs(refresh: true); final required = wallet.cryptoCurrency.minConfirms; int lowestNumberOfConfirms = required; @@ -120,7 +121,7 @@ class ChurningService extends ChangeNotifier { bool complete() => !continuous && roundsCompleted >= roundsToDo; while (!complete() && _running) { - if (_canChurn()) { + if (await _canChurn()) { waitingForUnlockedBalance = ChurnStatus.success; makingChurnTransaction = ChurnStatus.running; notifyListeners(); @@ -185,27 +186,25 @@ class ChurningService extends ChangeNotifier { } Future _churnTxSimple() async { - final address = csMonero.getAddress( - wallet.wallet!, + final address = await wallet.internalGetAddress( accountIndex: kAccount, addressIndex: 0, ); final height = await wallet.chainHeight; - final pending = await csMonero.createTx( - wallet.wallet!, + final pending = await wallet.internalCreateTx( output: CsRecipient( address, BigInt.zero, // Doesn't matter if `sweep` is true ), - priority: csMonero.getTxPriorityNormal(), + priority: wallet.getTxPriorityNormal(), accountIndex: kAccount, sweep: true, minConfirms: wallet.cryptoCurrency.minConfirms, currentHeight: height, ); - await csMonero.commitTx(wallet.wallet!, pending); + await wallet.internalCommitTx(pending); } } diff --git a/lib/services/ethereum/ethereum_api.dart b/lib/services/ethereum/ethereum_api.dart index efffc5105b..cf00ce3312 100644 --- a/lib/services/ethereum/ethereum_api.dart +++ b/lib/services/ethereum/ethereum_api.dart @@ -256,7 +256,7 @@ abstract class EthereumAPI { throw response.exception!; } - return EthFeeObject( + final fees = EthFeeObject( suggestBaseFee: response.value!.suggestBaseFee.shift(9).toBigInt(), numberOfBlocksFast: response.value!.numberOfBlocksFast, numberOfBlocksAverage: response.value!.numberOfBlocksAverage, @@ -265,6 +265,8 @@ abstract class EthereumAPI { medium: response.value!.average.shift(9).toBigInt(), slow: response.value!.low.shift(9).toBigInt(), ); + Logging.instance.t(fees); + return fees; } static Future _addContractInfoToServer(String contractAddress) async { diff --git a/lib/services/exchange/cyphergoat/cyphergoat_api.dart b/lib/services/exchange/cyphergoat/cyphergoat_api.dart new file mode 100644 index 0000000000..d12e99182e --- /dev/null +++ b/lib/services/exchange/cyphergoat/cyphergoat_api.dart @@ -0,0 +1,215 @@ +import 'dart:convert'; + +import 'package:decimal/decimal.dart'; + +import '../../../app_config.dart'; +import '../../../exceptions/exchange/exchange_exception.dart'; +import '../../../external_api_keys.dart'; +import '../../../networking/http.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/prefs.dart'; +import '../../tor_service.dart'; +import '../exchange_response.dart'; +import 'response_objects/cg_estimate.dart'; +import 'response_objects/cg_transaction.dart'; + +const kCypherGoatSource = "stackwallet"; + +abstract class CypherGoatAPI { + static const String authority = "api.cyphergoat.com"; + + static const HTTP _client = HTTP(); + + static Uri _buildUri({required String path, Map? params}) { + return Uri.https(authority, path, params); + } + + static Future _makeGetRequest(Uri uri) async { + int code = -1; + try { + final headers = { + "Content-Type": "application/json", + "Accept": "application/json", + }; + if (kCypherGoatApiKey.isNotEmpty) { + headers["Authorization"] = "Bearer $kCypherGoatApiKey"; + } + + final response = await _client.get( + url: uri, + headers: headers, + proxyInfo: !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ); + + code = response.code; + + final json = jsonDecode(response.body); + + if (code != 200) { + final errMsg = (json is Map ? json["error"].toString() : null); + throw Exception(errMsg ?? "HTTP $code: ${response.body}"); + } + + return json; + } catch (e, s) { + Logging.instance.e( + "CypherGoatAPI GET $uri HTTP:$code threw:", + error: e, + stackTrace: s, + ); + rethrow; + } + } + + /// GET /estimate + /// Returns all exchange provider estimates for the given pair and amount. + static Future> + getEstimate({ + required String coin1, + required String network1, + required String coin2, + required String network2, + required String amount, + }) async { + final params = { + "coin1": coin1.toLowerCase(), + "network1": network1.toLowerCase(), + "coin2": coin2.toLowerCase(), + "network2": network2.toLowerCase(), + "amount": amount, + "best": "false", + }; + + final uri = _buildUri(path: "/estimate", params: params); + + try { + final json = await _makeGetRequest(uri); + final map = Map.from(json as Map); + + final ratesMap = map["rates"] as Map?; + if (ratesMap == null) { + throw Exception("Missing 'rates' in estimate response"); + } + + final rates = CgEstimatesResponse.fromMap( + Map.from(ratesMap), + ); + final min = map["min"] != null + ? Decimal.parse(map["min"].toString()) + : rates.min; + + return ExchangeResponse(value: (rates: rates, min: min)); + } catch (e, s) { + Logging.instance.e( + "CypherGoatAPI.getEstimate() exception:", + error: e, + stackTrace: s, + ); + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + /// GET /swap + /// Creates a swap with the specified exchange partner. + static Future> createSwap({ + required String coin1, + required String network1, + required String coin2, + required String network2, + required String amount, + required String partner, + required String address, + String? estimateId, + }) async { + final params = { + "coin1": coin1.toLowerCase(), + "network1": network1.toLowerCase(), + "coin2": coin2.toLowerCase(), + "network2": network2.toLowerCase(), + "amount": amount, + "partner": partner, + "address": address, + "source": kCypherGoatSource, + }; + + if (kCypherGoatAffiliate.isNotEmpty) { + params["affiliate"] = kCypherGoatAffiliate; + } + if (estimateId != null && estimateId.isNotEmpty) { + params["estimateid"] = estimateId; + } + + final uri = _buildUri(path: "/swap", params: params); + + try { + final json = await _makeGetRequest(uri); + final map = Map.from(json as Map); + + final txMap = map["transaction"] as Map?; + if (txMap == null) { + throw Exception("Missing 'transaction' in swap response"); + } + + return ExchangeResponse( + value: CgTransaction.fromMap(Map.from(txMap)), + ); + } catch (e, s) { + Logging.instance.e( + "CypherGoatAPI.createSwap() exception:", + error: e, + stackTrace: s, + ); + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + /// GET /transaction + /// Fetches transaction details by CGID. + static Future> getTransaction({ + required String cgid, + }) async { + final params = {"id": cgid}; + + final uri = _buildUri(path: "/transaction", params: params); + + try { + final json = await _makeGetRequest(uri); + final map = Map.from(json as Map); + + final txMap = map["transaction"] as Map?; + if (txMap == null) { + throw Exception("Missing 'transaction' in response"); + } + + return ExchangeResponse( + value: CgTransaction.fromMap(Map.from(txMap)), + ); + } catch (e, s) { + Logging.instance.e( + "CypherGoatAPI.getTransaction($cgid) exception:", + error: e, + stackTrace: s, + ); + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } +} diff --git a/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart b/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart new file mode 100644 index 0000000000..0189908834 --- /dev/null +++ b/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart @@ -0,0 +1,546 @@ +import 'package:decimal/decimal.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../app_config.dart'; +import '../../../exceptions/exchange/exchange_exception.dart'; +import '../../../models/exchange/response_objects/estimate.dart'; +import '../../../models/exchange/response_objects/range.dart'; +import '../../../models/exchange/response_objects/trade.dart'; +import '../../../models/isar/exchange_cache/currency.dart'; +import '../../../models/isar/exchange_cache/pair.dart'; +import '../exchange.dart'; +import '../exchange_response.dart'; +import 'cyphergoat_api.dart'; + +class _CgCoin { + final String ticker; + final String name; + final String network; + final double? min; + + const _CgCoin({ + required this.ticker, + required this.name, + required this.network, + this.min, + }); +} + +// Static coin list derived from CypherGoat's coins.json. +const List<_CgCoin> _kCgCoins = [ + _CgCoin(ticker: 'btc', name: 'Bitcoin', network: 'btc', min: 4.449e-05), + _CgCoin( + ticker: 'btc', + name: 'Bitcoin (Lightning)', + network: 'lightning', + min: 4.449e-05, + ), + _CgCoin(ticker: 'eth', name: 'Ethereum', network: 'eth', min: 0.001114), + _CgCoin(ticker: 'xmr', name: 'Monero', network: 'xmr', min: 0.01886), + _CgCoin(ticker: 'ltc', name: 'Litecoin', network: 'ltc', min: 0.04444), + _CgCoin(ticker: 'bch', name: 'Bitcoin Cash', network: 'bch'), + _CgCoin(ticker: 'doge', name: 'Dogecoin', network: 'doge', min: 22.59), + _CgCoin(ticker: 'bnb', name: 'Binance Coin', network: 'bnb', min: 0.005711), + _CgCoin(ticker: 'sol', name: 'Solana', network: 'sol', min: 0.0238), + _CgCoin(ticker: 'xtz', name: 'Tezos', network: 'xtz', min: 6.336), + _CgCoin(ticker: 'ada', name: 'Cardano', network: 'ada', min: 5.868), + _CgCoin(ticker: 'xrp', name: 'Ripple', network: 'xrp', min: 1.678), + _CgCoin(ticker: 'trx', name: 'Tron', network: 'trx', min: 14.58), + _CgCoin(ticker: 'link', name: 'Chainlink', network: 'link', min: 0.2014), + _CgCoin(ticker: 'usdc', name: 'USDC (Ethereum)', network: 'usdc'), + _CgCoin(ticker: 'xno', name: 'Nano', network: 'xno', min: 5.393), + _CgCoin(ticker: 'usdc', name: 'USDC (Polygon)', network: 'poly'), + _CgCoin(ticker: 'usdc', name: 'USDC (Solana)', network: 'sol'), + _CgCoin(ticker: 'usdc', name: 'USDC (Algorand)', network: 'algo'), + _CgCoin(ticker: 'usdc', name: 'USDC (BSC)', network: 'bsc'), + _CgCoin(ticker: 'usdc', name: 'USDC (Optimism)', network: 'op'), + _CgCoin(ticker: 'usdc', name: 'USDC (Base)', network: 'base'), + _CgCoin(ticker: 'usdc', name: 'USDC (Tron)', network: 'tron'), + _CgCoin(ticker: 'usdt', name: 'Tether USD (Ethereum)', network: 'eth'), + _CgCoin(ticker: 'usdt', name: 'Tether USD (Tron)', network: 'tron'), + _CgCoin(ticker: 'usdt', name: 'Tether USD (Polygon)', network: 'poly'), + _CgCoin(ticker: 'usdt', name: 'Tether USD (BSC)', network: 'bsc'), + _CgCoin(ticker: 'usdt', name: 'Tether USD (Solana)', network: 'sol'), + _CgCoin(ticker: 'usdt', name: 'Tether USD (Algorand)', network: 'algo'), + _CgCoin(ticker: 'busd', name: 'Binance USD (BSC)', network: 'bsc'), + _CgCoin(ticker: 'busd', name: 'Binance USD (Ethereum)', network: 'eth'), + _CgCoin(ticker: 'dai', name: 'Dai (Ethereum)', network: 'eth'), + _CgCoin(ticker: 'dai', name: 'Dai (BSC)', network: 'bsc'), + _CgCoin(ticker: 'dai', name: 'Dai (Polygon)', network: 'poly'), + _CgCoin(ticker: 'dai', name: 'Dai (Optimism)', network: 'op'), + _CgCoin(ticker: 'tusd', name: 'True USD', network: 'tusd'), + _CgCoin(ticker: 'tusd', name: 'True USD (Tron)', network: 'tron'), + _CgCoin(ticker: 'shib', name: 'Shiba Inu', network: 'shib', min: 10000), + _CgCoin(ticker: 'dot', name: 'Polkadot', network: 'dot', min: 1.272), + _CgCoin(ticker: 'etc', name: 'Ethereum Classic', network: 'etc', min: 0.2318), + _CgCoin(ticker: 'zec', name: 'Zcash', network: 'zec', min: 0.2), + _CgCoin(ticker: 'hive', name: 'Hive', network: 'hive', min: 24.06), + _CgCoin(ticker: 'bdx', name: 'Beldex', network: 'bdx', min: 65.93), + _CgCoin(ticker: 'wow', name: 'Wownero', network: 'wow', min: 163.8), + _CgCoin(ticker: 'ban', name: 'Banano', network: 'banano', min: 2614.0), + _CgCoin(ticker: 'arrr', name: 'Pirate Chain', network: 'arrr', min: 4.8), + _CgCoin( + ticker: 'arrrbsc', + name: 'Pirate Chain (BSC)', + network: 'arrrbsc', + min: 4.8, + ), + _CgCoin(ticker: 'dcr', name: 'Decred', network: 'dcr', min: 0.3045), + _CgCoin(ticker: 'aave', name: 'Aave', network: 'aave', min: 0.01574), + _CgCoin(ticker: 'avax', name: 'Avalanche', network: 'avax', min: 0.4263), + _CgCoin( + ticker: 'bat', + name: 'Basic Attention Token', + network: 'bat', + min: 32.09, + ), + _CgCoin(ticker: 'link', name: 'Chainlink (BSC)', network: 'bsc', min: 0.2014), + _CgCoin(ticker: 'gusd', name: 'Gemini Dollar', network: 'gusd'), + _CgCoin(ticker: 'paxg', name: 'Paxos Gold', network: 'paxg', min: 0.002), + _CgCoin(ticker: 'hbar', name: 'Hedera', network: 'hbar', min: 12), + _CgCoin(ticker: 'ark', name: 'Ark', network: 'ark', min: 10.96), + _CgCoin(ticker: 'firo', name: 'Firo', network: 'firo', min: 14.24), + _CgCoin( + ticker: 'wbtc', + name: 'Wrapped Bitcoin', + network: 'wbtc', + min: 4.444e-05, + ), + _CgCoin(ticker: '1inch', name: '1inch', network: '1inch', min: 19.87), + _CgCoin(ticker: 'dash', name: 'Dash', network: 'dash', min: 0.2152), + _CgCoin(ticker: 'zano', name: 'Zano', network: 'zano', min: 0.3358), + _CgCoin(ticker: 'tel', name: 'Telcoin', network: 'tel', min: 1001.0), + _CgCoin(ticker: 'leo', name: 'Leo Token', network: 'leo', min: 2), + _CgCoin(ticker: 'fusd', name: 'Freedom Dollar', network: 'fusd', min: 25), + _CgCoin(ticker: 'apt', name: 'Aptos', network: 'apt', min: 1.134), + _CgCoin(ticker: 'sui', name: 'Sui', network: 'sui', min: 1.449), + _CgCoin(ticker: 'nvdax', name: 'NVIDIA xStock', network: 'nvdax', min: 0.8), + _CgCoin(ticker: 'spyx', name: 'SP500 xStock', network: 'spyx', min: 0.3), + _CgCoin(ticker: 'tslax', name: 'TSLA xStock', network: 'tslax', min: 0.4), + _CgCoin(ticker: 'qqqx', name: 'Nasdaq xStock', network: 'qqqx', min: 0.3), + _CgCoin(ticker: 'crclx', name: 'Circle xStock', network: 'crclx', min: 1.3), + _CgCoin( + ticker: 'mstrx', + name: 'MicroStrategy xStock', + network: 'mstrx', + min: 0.4, + ), + _CgCoin(ticker: 'aaplx', name: 'Apple xStock', network: 'aaplx', min: 0.6), + _CgCoin(ticker: 'coinx', name: 'Coinbase xStock', network: 'coinx', min: 0.5), + _CgCoin( + ticker: 'googlx', + name: 'Alphabet xStock', + network: 'googlx', + min: 0.7, + ), + _CgCoin(ticker: 'amznx', name: 'Amazon xStock', network: 'amznx', min: 0.6), + _CgCoin(ticker: 'metax', name: 'Meta xStock', network: 'metax', min: 0.2), + _CgCoin( + ticker: 'hoodx', + name: 'Robinhood xStock', + network: 'hoodx', + min: 1.3, + ), + _CgCoin(ticker: 'gmex', name: 'Gamestop xStock', network: 'gmex', min: 5), +]; + +class CypherGoatExchange extends Exchange { + CypherGoatExchange._(); + + static CypherGoatExchange? _instance; + static CypherGoatExchange get instance => + _instance ??= CypherGoatExchange._(); + + static const exchangeName = "CypherGoat"; + + @override + String get name => exchangeName; + + @override + bool get supportsRefundAddress => false; + + @override + Future>> getAllCurrencies( + bool fixedRate, + ) async { + try { + if (fixedRate) { + return ExchangeResponse( + exception: ExchangeException( + "CypherGoat does not support fixed rate", + ExchangeExceptionType.generic, + ), + ); + } + + final currencies = _kCgCoins + .map( + (c) => Currency( + exchangeName: exchangeName, + ticker: c.ticker, + name: c.name, + network: c.network, + image: "", + isFiat: false, + rateType: SupportedRateType.estimated, + isStackCoin: AppConfig.isStackCoin(c.ticker), + tokenContract: null, + isAvailable: true, + ), + ) + .toList(); + + return ExchangeResponse(value: currencies); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> getRange( + String from, + String? fromNetwork, + String to, + String? toNetwork, + bool fixedRate, + ) async { + try { + if (fixedRate) { + return ExchangeResponse( + exception: ExchangeException( + "CypherGoat does not support fixed rate", + ExchangeExceptionType.generic, + ), + ); + } + + // Use the min from the static coin list as a quick offline fallback. + final coin = _kCgCoins.where( + (c) => + c.ticker.toLowerCase() == from.toLowerCase() && + (fromNetwork == null || + c.network.toLowerCase() == fromNetwork.toLowerCase()), + ); + + Decimal? min; + if (coin.isNotEmpty && coin.first.min != null) { + min = Decimal.parse(coin.first.min.toString()); + } + + // Fetch live min from the API. + final response = await CypherGoatAPI.getEstimate( + coin1: from, + network1: fromNetwork ?? from, + coin2: to, + network2: toNetwork ?? to, + amount: (min ?? Decimal.one).toString(), + ); + + if (response.value != null) { + final liveMin = response.value!.min; + if (liveMin > Decimal.zero) { + min = liveMin; + } + } + + return ExchangeResponse(value: Range(min: min, max: null)); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future>> getEstimates( + String from, + String? fromNetwork, + String to, + String? toNetwork, + Decimal amount, + bool fixedRate, + bool reversed, + ) async { + try { + if (fixedRate) { + return ExchangeResponse( + exception: ExchangeException( + "CypherGoat does not support fixed rate", + ExchangeExceptionType.generic, + ), + ); + } + if (reversed) { + return ExchangeResponse( + exception: ExchangeException( + "CypherGoat does not support reversed estimates", + ExchangeExceptionType.generic, + ), + ); + } + + final response = await CypherGoatAPI.getEstimate( + coin1: from, + network1: fromNetwork ?? from, + coin2: to, + network2: toNetwork ?? to, + amount: amount.toString(), + ); + + if (response.value == null) { + return ExchangeResponse(exception: response.exception); + } + + final data = response.value!; + final estimateIdStr = data.rates.estimateId.toString(); + + final List estimates = []; + for (final quote in response.value!.rates.results) { + final provider = quote.exchange.toLowerCase(); + if (provider != "changenow" && + provider != "letsexchange" && + provider != "exolix") { + estimates.add( + Estimate( + estimatedAmount: quote.amount, + fixedRate: false, + reversed: false, + exchangeProvider: quote.exchange, + rateId: estimateIdStr, + // exchangeProviderLogo: quote.providerLogo, + // kycRating: quote.kycRating, + ), + ); + } + } + + estimates.sort((a, b) => b.estimatedAmount.compareTo(a.estimatedAmount)); + + if (estimates.isEmpty) { + return ExchangeResponse( + exception: ExchangeException( + "No rates available for this pair", + ExchangeExceptionType.orderNotFound, + ), + ); + } + + return ExchangeResponse(value: estimates); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> createTrade({ + required String from, + required String to, + required String? fromNetwork, + required String? toNetwork, + required bool fixedRate, + required Decimal amount, + required String addressTo, + String? extraId, + required String addressRefund, + required String refundExtraId, + Estimate? estimate, + required bool reversed, + }) async { + try { + if (fixedRate) { + throw ExchangeException( + "CypherGoat does not support fixed rate", + ExchangeExceptionType.generic, + ); + } + if (reversed) { + throw ExchangeException( + "CypherGoat does not support reversed trades", + ExchangeExceptionType.generic, + ); + } + if (estimate == null) { + throw ExchangeException( + "An estimate is required to create a CypherGoat trade", + ExchangeExceptionType.generic, + ); + } + + final response = await CypherGoatAPI.createSwap( + coin1: from, + network1: fromNetwork ?? from, + coin2: to, + network2: toNetwork ?? to, + amount: amount.toString(), + partner: estimate.exchangeProvider, + address: addressTo, + estimateId: estimate.rateId, + ); + + if (response.value == null) { + return ExchangeResponse(exception: response.exception); + } + + final tx = response.value!; + + return ExchangeResponse( + value: Trade( + uuid: const Uuid().v1(), + tradeId: tx.cgid ?? tx.id, + rateType: "estimated", + direction: "direct", + timestamp: tx.createdAt, + updatedAt: tx.createdAt, + payInCurrency: tx.coin1.toUpperCase(), + payInAmount: tx.sendAmount.toString(), + payInAddress: tx.address, + payInNetwork: tx.network1, + payInExtraId: tx.memo ?? "", + payInTxid: "", + payOutCurrency: tx.coin2.toUpperCase(), + payOutAmount: tx.estimateAmount.toString(), + payOutAddress: tx.destinationAddress, + payOutNetwork: tx.network2, + payOutExtraId: "", + payOutTxid: "", + refundAddress: "", + refundExtraId: "", + status: tx.status, + exchangeName: exchangeName, + other: tx.track, + ), + ); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> getTrade(String tradeId) async { + try { + final response = await CypherGoatAPI.getTransaction(cgid: tradeId); + + if (response.value == null) { + return ExchangeResponse(exception: response.exception); + } + + final tx = response.value!; + + return ExchangeResponse( + value: Trade( + uuid: const Uuid().v1(), + tradeId: tx.cgid ?? tradeId, + rateType: "estimated", + direction: "direct", + timestamp: tx.createdAt, + updatedAt: DateTime.now(), + payInCurrency: tx.coin1.toUpperCase(), + payInAmount: tx.sendAmount.toString(), + payInAddress: tx.address, + payInNetwork: tx.network1, + payInExtraId: tx.memo ?? "", + payInTxid: "", + payOutCurrency: tx.coin2.toUpperCase(), + payOutAmount: tx.estimateAmount.toString(), + payOutAddress: tx.destinationAddress, + payOutNetwork: tx.network2, + payOutExtraId: "", + payOutTxid: "", + refundAddress: "", + refundExtraId: "", + status: tx.status, + exchangeName: exchangeName, + other: tx.track, + ), + ); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future>> getTrades() async { + throw UnimplementedError( + "CypherGoat does not provide a trade history endpoint", + ); + } + + @override + Future> updateTrade(Trade trade) async { + try { + final response = await CypherGoatAPI.getTransaction(cgid: trade.tradeId); + + if (response.value == null) { + return ExchangeResponse(exception: response.exception); + } + + final tx = response.value!; + + return ExchangeResponse( + value: Trade( + uuid: trade.uuid, + tradeId: trade.tradeId, + rateType: trade.rateType, + direction: trade.direction, + timestamp: trade.timestamp, + updatedAt: DateTime.now(), + payInCurrency: tx.coin1.toUpperCase(), + payInAmount: tx.sendAmount.toString(), + payInAddress: tx.address, + payInNetwork: trade.payInNetwork, + payInExtraId: tx.memo ?? trade.payInExtraId, + payInTxid: trade.payInTxid, + payOutCurrency: tx.coin2.toUpperCase(), + payOutAmount: tx.estimateAmount.toString(), + payOutAddress: tx.destinationAddress, + payOutNetwork: trade.payOutNetwork, + payOutExtraId: trade.payOutExtraId, + payOutTxid: trade.payOutTxid, + refundAddress: trade.refundAddress, + refundExtraId: trade.refundExtraId, + status: tx.status, + exchangeName: exchangeName, + other: tx.track, + ), + ); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } +} diff --git a/lib/services/exchange/cyphergoat/response_objects/cg_estimate.dart b/lib/services/exchange/cyphergoat/response_objects/cg_estimate.dart new file mode 100644 index 0000000000..74efacce31 --- /dev/null +++ b/lib/services/exchange/cyphergoat/response_objects/cg_estimate.dart @@ -0,0 +1,64 @@ +import 'package:decimal/decimal.dart'; + +import 'cg_parse_utils.dart'; + +class CgEstimateResult { + final String exchange; + final Decimal amount; + final int kycScore; + final bool? safeRouteOk; + final Decimal? safeRouteScore; + + CgEstimateResult({ + required this.exchange, + required this.amount, + required this.kycScore, + required this.safeRouteOk, + required this.safeRouteScore, + }); + + factory CgEstimateResult.fromMap(Map map) { + return CgEstimateResult( + exchange: requireCgString(map, "Exchange"), + amount: requireCgDecimal(map, "Amount"), + kycScore: requireCgInt(map, "KYCScore"), + safeRouteOk: map["SafeRouteOK"] as bool?, + safeRouteScore: Decimal.tryParse(map["SafeRouteScore"].toString()), + ); + } +} + +class CgEstimatesResponse { + final List results; + final Decimal min; + final Decimal tradeValueFiat; + final Decimal tradeValueBtc; + final int estimateId; + + CgEstimatesResponse({ + required this.results, + required this.min, + required this.tradeValueFiat, + required this.tradeValueBtc, + required this.estimateId, + }); + + factory CgEstimatesResponse.fromMap(Map map) { + final resultsRaw = map["Results"]; + if (resultsRaw is! List) { + throw CgResponseFormatException("Missing required field 'Results'"); + } + return CgEstimatesResponse( + results: resultsRaw + .map( + (e) => + CgEstimateResult.fromMap(Map.from(e as Map)), + ) + .toList(), + min: requireCgDecimal(map, "Min"), + tradeValueFiat: requireCgDecimal(map, "TradeValue_fiat"), + tradeValueBtc: requireCgDecimal(map, "TradeValue_btc"), + estimateId: requireCgInt(map, "EstimateId"), + ); + } +} diff --git a/lib/services/exchange/cyphergoat/response_objects/cg_parse_utils.dart b/lib/services/exchange/cyphergoat/response_objects/cg_parse_utils.dart new file mode 100644 index 0000000000..68f69c8f3b --- /dev/null +++ b/lib/services/exchange/cyphergoat/response_objects/cg_parse_utils.dart @@ -0,0 +1,49 @@ +import 'package:decimal/decimal.dart'; + +/// Thrown when a CypherGoat API response is missing a field the client +/// treats as mandatory, instead of silently substituting a default value. +class CgResponseFormatException implements Exception { + final String message; + CgResponseFormatException(this.message); + + @override + String toString() => "CgResponseFormatException: $message"; +} + +String requireCgString(Map map, String key) { + final v = map[key]; + if (v is! String || v.isEmpty) { + throw CgResponseFormatException("Missing or empty required field '$key'"); + } + return v; +} + +String? optionalCgString(Map map, String key) { + final v = map[key]; + if (v is String && v.isNotEmpty) return v; + return null; +} + +Decimal requireCgDecimal(Map map, String key) { + final v = map[key]; + if (v is! num && v is! String) { + throw CgResponseFormatException("Missing required numeric field '$key'"); + } + return Decimal.parse(v.toString()); +} + +int requireCgInt(Map map, String key) { + final v = map[key]; + if (v is! num) { + throw CgResponseFormatException("Missing required numeric field '$key'"); + } + return v.toInt(); +} + +bool requireCgBool(Map map, String key) { + final v = map[key]; + if (v is! bool) { + throw CgResponseFormatException("Missing required boolean field '$key'"); + } + return v; +} diff --git a/lib/services/exchange/cyphergoat/response_objects/cg_transaction.dart b/lib/services/exchange/cyphergoat/response_objects/cg_transaction.dart new file mode 100644 index 0000000000..2ca4bdee41 --- /dev/null +++ b/lib/services/exchange/cyphergoat/response_objects/cg_transaction.dart @@ -0,0 +1,93 @@ +import 'package:decimal/decimal.dart'; + +import 'cg_parse_utils.dart'; + +class CgTransaction { + final String coin1; + final String coin2; + final String network1; + final String network2; + final String address; + final Decimal estimateAmount; + final String provider; + final String id; + final Decimal sendAmount; + final String? track; + final String status; + final String? kyc; + final String? token; + final bool done; + final String? cgid; + final DateTime createdAt; + final String? affiliate; + final String? memo; + final String? source; + final String destinationAddress; + final bool payment; + final DateTime? completedAt; + final int estimateId; + + CgTransaction({ + required this.coin1, + required this.coin2, + required this.network1, + required this.network2, + required this.address, + required this.estimateAmount, + required this.provider, + required this.id, + required this.sendAmount, + required this.track, + required this.status, + required this.kyc, + required this.token, + required this.done, + required this.cgid, + required this.createdAt, + required this.affiliate, + required this.memo, + required this.source, + required this.destinationAddress, + required this.payment, + required this.completedAt, + required this.estimateId, + }); + + // Go's zero time ("0001-01-01T00:00:00Z") is returned when the field isn't + // set yet; treat it as now rather than storing year 1. + static DateTime _parseDate(String s) { + final dt = DateTime.tryParse(s); + if (dt == null || dt.year <= 1) return DateTime.now(); + return dt; + } + + factory CgTransaction.fromMap(Map map) { + return CgTransaction( + coin1: requireCgString(map, "Coin1"), + coin2: requireCgString(map, "Coin2"), + network1: requireCgString(map, "Network1"), + network2: requireCgString(map, "Network2"), + address: requireCgString(map, "Address"), + estimateAmount: requireCgDecimal(map, "EstimateAmount"), + provider: requireCgString(map, "Provider"), + id: requireCgString(map, "Id"), + sendAmount: requireCgDecimal(map, "SendAmount"), + track: optionalCgString(map, "Track"), + status: optionalCgString(map, "Status") ?? "waiting", + kyc: optionalCgString(map, "KYC"), + token: optionalCgString(map, "Token"), + done: requireCgBool(map, "Done"), + cgid: optionalCgString(map, "CGID"), + createdAt: _parseDate(requireCgString(map, "CreatedAt")), + affiliate: optionalCgString(map, "Affiliate"), + memo: optionalCgString(map, "Memo"), + source: optionalCgString(map, "Source"), + destinationAddress: requireCgString(map, "DestinationAddress"), + payment: requireCgBool(map, "Payment"), + completedAt: map["CompletedAt"] != null + ? DateTime.tryParse(map["CompletedAt"] as String) + : null, + estimateId: requireCgInt(map, "EstimateId"), + ); + } +} diff --git a/lib/services/exchange/exchange.dart b/lib/services/exchange/exchange.dart index 05ca33e871..7868b9e286 100644 --- a/lib/services/exchange/exchange.dart +++ b/lib/services/exchange/exchange.dart @@ -15,11 +15,14 @@ import '../../models/exchange/response_objects/range.dart'; import '../../models/exchange/response_objects/trade.dart'; import '../../models/isar/exchange_cache/currency.dart'; import 'change_now/change_now_exchange.dart'; +import 'cyphergoat/cyphergoat_exchange.dart'; import 'exchange_response.dart'; -import 'majestic_bank/majestic_bank_exchange.dart'; +import 'exolix/exolix_exchange.dart'; +import 'lets_exchange/lets_exchange_exchange.dart'; import 'nanswap/nanswap_exchange.dart'; import 'simpleswap/simpleswap_exchange.dart'; import 'trocador/trocador_exchange.dart'; +import 'wizard_swap/wizard_swap_exchange.dart'; abstract class Exchange { static Exchange get defaultExchange => ChangeNowExchange.instance; @@ -36,6 +39,14 @@ abstract class Exchange { return TrocadorExchange.instance; case NanswapExchange.exchangeName: return NanswapExchange.instance; + case WizardSwapExchange.exchangeName: + return WizardSwapExchange.instance; + case ExolixExchange.exchangeName: + return ExolixExchange.instance; + case LetsExchangeExchange.exchangeName: + return LetsExchangeExchange.instance; + case CypherGoatExchange.exchangeName: + return CypherGoatExchange.instance; default: final split = name.split(" "); if (split.length >= 2) { @@ -108,6 +119,7 @@ abstract class Exchange { static List get exchangesWithTorSupport => [ // MajesticBankExchange.instance, TrocadorExchange.instance, + ExolixExchange.instance, // Maybe?? NanswapExchange.instance, // Maybe?? ]; diff --git a/lib/services/exchange/exchange_data_loading_service.dart b/lib/services/exchange/exchange_data_loading_service.dart index 1bf16504d3..549074db06 100644 --- a/lib/services/exchange/exchange_data_loading_service.dart +++ b/lib/services/exchange/exchange_data_loading_service.dart @@ -25,8 +25,12 @@ import '../../utilities/logger.dart'; import '../../utilities/prefs.dart'; import '../../utilities/stack_file_system.dart'; import 'change_now/change_now_exchange.dart'; +import 'cyphergoat/cyphergoat_exchange.dart'; +import 'exolix/exolix_exchange.dart'; +import 'lets_exchange/lets_exchange_exchange.dart'; import 'nanswap/nanswap_exchange.dart'; import 'trocador/trocador_exchange.dart'; +import 'wizard_swap/wizard_swap_exchange.dart'; class ExchangeDataLoadingService { ExchangeDataLoadingService._(); @@ -124,45 +128,41 @@ class ExchangeDataLoadingService { final List currencies; if (contract != null) { - currencies = - await (await isar).currencies - .filter() - .tokenContractEqualTo(contract) - .and() - .group( - (q) => - rateType == ExchangeRateType.fixed - ? q - .rateTypeEqualTo(SupportedRateType.both) - .or() - .rateTypeEqualTo(SupportedRateType.fixed) - : q - .rateTypeEqualTo(SupportedRateType.both) - .or() - .rateTypeEqualTo(SupportedRateType.estimated), - ) - .findAll(); + currencies = await (await isar).currencies + .filter() + .tokenContractEqualTo(contract) + .and() + .group( + (q) => rateType == ExchangeRateType.fixed + ? q + .rateTypeEqualTo(SupportedRateType.both) + .or() + .rateTypeEqualTo(SupportedRateType.fixed) + : q + .rateTypeEqualTo(SupportedRateType.both) + .or() + .rateTypeEqualTo(SupportedRateType.estimated), + ) + .findAll(); } else { - currencies = - await (await isar).currencies - .filter() - .group( - (q) => - rateType == ExchangeRateType.fixed - ? q - .rateTypeEqualTo(SupportedRateType.both) - .or() - .rateTypeEqualTo(SupportedRateType.fixed) - : q - .rateTypeEqualTo(SupportedRateType.both) - .or() - .rateTypeEqualTo(SupportedRateType.estimated), - ) - .and() - .tickerEqualTo(ticker, caseSensitive: false) - .and() - .tokenContractIsNull() - .findAll(); + currencies = await (await isar).currencies + .filter() + .group( + (q) => rateType == ExchangeRateType.fixed + ? q + .rateTypeEqualTo(SupportedRateType.both) + .or() + .rateTypeEqualTo(SupportedRateType.fixed) + : q + .rateTypeEqualTo(SupportedRateType.both) + .or() + .rateTypeEqualTo(SupportedRateType.estimated), + ) + .and() + .tickerEqualTo(ticker, caseSensitive: false) + .and() + .tokenContractIsNull() + .findAll(); } currencies.retainWhere((e) => e.getFuzzyNet() == fuzzyNet); @@ -211,6 +211,10 @@ class ExchangeDataLoadingService { // loadMajesticBankCurrencies(), loadTrocadorCurrencies(), loadNanswapCurrencies(), + loadWizardSwapCurrencies(), + loadExolixCurrencies(), + loadLetsExchangeCurrencies(), + loadCypherGoatCurrencies(), ]; // If using Tor, don't load data for exchanges which don't support Tor. @@ -248,12 +252,11 @@ class ExchangeDataLoadingService { final responseCurrencies = await exchange.getAllCurrencies(false); if (responseCurrencies.value != null) { await (await isar).writeTxn(() async { - final idsToDelete = - await (await isar).currencies - .where() - .exchangeNameEqualTo(ChangeNowExchange.exchangeName) - .idProperty() - .findAll(); + final idsToDelete = await (await isar).currencies + .where() + .exchangeNameEqualTo(ChangeNowExchange.exchangeName) + .idProperty() + .findAll(); await (await isar).currencies.deleteAll(idsToDelete); await (await isar).currencies.putAll(responseCurrencies.value!); }); @@ -373,6 +376,28 @@ class ExchangeDataLoadingService { // } // } + Future loadCypherGoatCurrencies() async { + if (_isar == null) { + await initDB(); + } + final responseCurrencies = await CypherGoatExchange.instance + .getAllCurrencies(false); + + if (responseCurrencies.value != null) { + await (await isar).writeTxn(() async { + final idsToDelete = await (await isar).currencies + .where() + .exchangeNameEqualTo(CypherGoatExchange.exchangeName) + .idProperty() + .findAll(); + await (await isar).currencies.deleteAll(idsToDelete); + await (await isar).currencies.putAll(responseCurrencies.value!); + }); + } else { + Logging.instance.w("loadCypherGoatCurrencies: $responseCurrencies"); + } + } + // Future loadMajesticBankCurrencies() async { // if (_isar == null) { // await initDB(); @@ -405,12 +430,11 @@ class ExchangeDataLoadingService { if (responseCurrencies.value != null) { await (await isar).writeTxn(() async { - final idsToDelete = - await (await isar).currencies - .where() - .exchangeNameEqualTo(TrocadorExchange.exchangeName) - .idProperty() - .findAll(); + final idsToDelete = await (await isar).currencies + .where() + .exchangeNameEqualTo(TrocadorExchange.exchangeName) + .idProperty() + .findAll(); await (await isar).currencies.deleteAll(idsToDelete); await (await isar).currencies.putAll(responseCurrencies.value!); }); @@ -429,12 +453,11 @@ class ExchangeDataLoadingService { if (responseCurrencies.value != null) { await (await isar).writeTxn(() async { - final idsToDelete = - await (await isar).currencies - .where() - .exchangeNameEqualTo(NanswapExchange.exchangeName) - .idProperty() - .findAll(); + final idsToDelete = await (await isar).currencies + .where() + .exchangeNameEqualTo(NanswapExchange.exchangeName) + .idProperty() + .findAll(); await (await isar).currencies.deleteAll(idsToDelete); await (await isar).currencies.putAll(responseCurrencies.value!); }); @@ -443,6 +466,73 @@ class ExchangeDataLoadingService { } } + Future loadWizardSwapCurrencies() async { + if (_isar == null) { + await initDB(); + } + final responseCurrencies = await WizardSwapExchange.instance + .getAllCurrencies(false); + + if (responseCurrencies.value != null) { + await (await isar).writeTxn(() async { + final idsToDelete = await (await isar).currencies + .where() + .exchangeNameEqualTo(WizardSwapExchange.exchangeName) + .idProperty() + .findAll(); + await (await isar).currencies.deleteAll(idsToDelete); + await (await isar).currencies.putAll(responseCurrencies.value!); + }); + } else { + Logging.instance.w("loadWizardSwapCurrencies: $responseCurrencies"); + } + } + + Future loadExolixCurrencies() async { + if (_isar == null) { + await initDB(); + } + final responseCurrencies = await ExolixExchange.instance.getAllCurrencies( + false, + ); + + if (responseCurrencies.value != null) { + await (await isar).writeTxn(() async { + final idsToDelete = await (await isar).currencies + .where() + .exchangeNameEqualTo(ExolixExchange.exchangeName) + .idProperty() + .findAll(); + await (await isar).currencies.deleteAll(idsToDelete); + await (await isar).currencies.putAll(responseCurrencies.value!); + }); + } else { + Logging.instance.w("loadExolixCurrencies: $responseCurrencies"); + } + } + + Future loadLetsExchangeCurrencies() async { + if (_isar == null) { + await initDB(); + } + final responseCurrencies = await LetsExchangeExchange.instance + .getAllCurrencies(false); + + if (responseCurrencies.value != null) { + await (await isar).writeTxn(() async { + final idsToDelete = await (await isar).currencies + .where() + .exchangeNameEqualTo(LetsExchangeExchange.exchangeName) + .idProperty() + .findAll(); + await (await isar).currencies.deleteAll(idsToDelete); + await (await isar).currencies.putAll(responseCurrencies.value!); + }); + } else { + Logging.instance.w("loadLetsExchangeCurrencies: $responseCurrencies"); + } + } + // Future loadMajesticBankPairs() async { // final exchange = MajesticBankExchange.instance; // diff --git a/lib/services/exchange/exolix/api/dto/exolix_base_dto.dart b/lib/services/exchange/exolix/api/dto/exolix_base_dto.dart new file mode 100644 index 0000000000..0ae2df48b0 --- /dev/null +++ b/lib/services/exchange/exolix/api/dto/exolix_base_dto.dart @@ -0,0 +1,6 @@ +abstract class ExolixBaseDto { + Map toMap(); + + @override + String toString() => toMap().toString(); +} diff --git a/lib/services/exchange/exolix/api/dto/exolix_coin_info.dart b/lib/services/exchange/exolix/api/dto/exolix_coin_info.dart new file mode 100644 index 0000000000..eaa61be835 --- /dev/null +++ b/lib/services/exchange/exolix/api/dto/exolix_coin_info.dart @@ -0,0 +1,51 @@ +import 'exolix_base_dto.dart'; + +/// The "coinFrom" / "coinTo" sub-object inside a transaction. +class ExolixCoinInfo extends ExolixBaseDto { + final String coinCode; + final String coinName; + final String network; + final String networkName; + final String? networkShortName; + final String? icon; + final String? memoName; + final String? contract; + + ExolixCoinInfo({ + required this.coinCode, + required this.coinName, + required this.network, + required this.networkName, + required this.networkShortName, + required this.icon, + required this.memoName, + required this.contract, + }); + + factory ExolixCoinInfo.fromJson(Map json) { + return ExolixCoinInfo( + coinCode: json["coinCode"] as String? ?? "", + coinName: json["coinName"] as String? ?? "", + network: json["network"] as String? ?? "", + networkName: json["networkName"] as String? ?? "", + networkShortName: json["networkShortName"] as String?, + icon: json["icon"] as String?, + memoName: json["memoName"] as String?, + contract: json["contract"] as String?, + ); + } + + @override + Map toMap() { + return { + "coinCode": coinCode, + "coinName": coinName, + "network": network, + "networkName": networkName, + "networkShortName": networkShortName, + "icon": icon, + "memoName": memoName, + "contract": contract, + }; + } +} diff --git a/lib/services/exchange/exolix/api/dto/exolix_currency.dart b/lib/services/exchange/exolix/api/dto/exolix_currency.dart new file mode 100644 index 0000000000..57091ecf4d --- /dev/null +++ b/lib/services/exchange/exolix/api/dto/exolix_currency.dart @@ -0,0 +1,51 @@ +import 'exolix_base_dto.dart'; +import 'exolix_network.dart'; + +/// A currency entry. +class ExolixCurrency extends ExolixBaseDto { + final String code; + final String name; + final String? icon; + final String? notes; + + /// Only populated when the listing was requested with withNetworks=true. + final List networks; + + ExolixCurrency({ + required this.code, + required this.name, + required this.icon, + required this.notes, + required this.networks, + }); + + factory ExolixCurrency.fromJson(Map json) { + final dynamic rawNetworks = json["networks"]; + final List nets = (rawNetworks is List) + ? rawNetworks + .map( + (e) => + ExolixNetwork.fromJson(Map.from(e as Map)), + ) + .toList() + : []; + return ExolixCurrency( + code: json["code"] as String? ?? "", + name: json["name"] as String? ?? "", + icon: json["icon"] as String?, + notes: json["notes"] as String?, + networks: nets, + ); + } + + @override + Map toMap() { + return { + "code": code, + "name": name, + "icon": icon, + "notes": notes, + "networks": networks.map((n) => n.toMap()).toList(), + }; + } +} diff --git a/lib/services/exchange/exolix/api/dto/exolix_hash.dart b/lib/services/exchange/exolix/api/dto/exolix_hash.dart new file mode 100644 index 0000000000..147bf92e04 --- /dev/null +++ b/lib/services/exchange/exolix/api/dto/exolix_hash.dart @@ -0,0 +1,21 @@ +import 'exolix_base_dto.dart'; + +/// A transaction hash sub-object (hashIn / hashOut). +class ExolixHash extends ExolixBaseDto { + final String? hash; + final String? link; + + ExolixHash({required this.hash, required this.link}); + + factory ExolixHash.fromJson(Map json) { + return ExolixHash( + hash: json["hash"] as String?, + link: json["link"] as String?, + ); + } + + @override + Map toMap() { + return {"hash": hash, "link": link}; + } +} diff --git a/lib/services/exchange/exolix/api/dto/exolix_network.dart b/lib/services/exchange/exolix/api/dto/exolix_network.dart new file mode 100644 index 0000000000..4d4091cda6 --- /dev/null +++ b/lib/services/exchange/exolix/api/dto/exolix_network.dart @@ -0,0 +1,97 @@ +import 'exolix_base_dto.dart'; + +/// A network entry as returned in currency listings and the dedicated +/// networks endpoints. +class ExolixNetwork extends ExolixBaseDto { + final String network; + final String name; + final String? shortName; + final String? notes; + final String? addressRegex; + final bool isDefault; + final String? blockExplorer; + final bool memoNeeded; + final String? memoName; + final String? memoRegex; + final int precision; + final int? decimal; + final String? contract; + final String? icon; + + ExolixNetwork({ + required this.network, + required this.name, + required this.shortName, + required this.notes, + required this.addressRegex, + required this.isDefault, + required this.blockExplorer, + required this.memoNeeded, + required this.memoName, + required this.memoRegex, + required this.precision, + required this.decimal, + required this.contract, + required this.icon, + }); + + factory ExolixNetwork.fromJson(Map json) { + // The docs are inconsistent: one example uses "addresRegex" (typo), + // another uses "addressRegex". Accept both. + final dynamic addrRegex = json["addressRegex"] ?? json["addresRegex"]; + return ExolixNetwork( + network: json["network"] as String? ?? "", + name: json["name"] as String? ?? "", + shortName: json["shortName"] as String?, + notes: json["notes"] as String?, + addressRegex: addrRegex as String?, + isDefault: json["isDefault"] as bool? ?? false, + blockExplorer: json["blockExplorer"] as String?, + memoNeeded: json["memoNeeded"] as bool? ?? false, + memoName: json["memoName"] as String?, + memoRegex: json["memoRegex"] as String?, + precision: _parseInt(json["precision"]), + decimal: json["decimal"] == null ? null : _parseInt(json["decimal"]), + contract: json["contract"] as String?, + icon: json["icon"] as String?, + ); + } + + @override + Map toMap() { + return { + "network": network, + "name": name, + "shortName": shortName, + "notes": notes, + "addressRegex": addressRegex, + "isDefault": isDefault, + "blockExplorer": blockExplorer, + "memoNeeded": memoNeeded, + "memoName": memoName, + "memoRegex": memoRegex, + "precision": precision, + "decimal": decimal, + "contract": contract, + "icon": icon, + }; + } +} + +int _parseInt(dynamic value) { + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) { + final parsedInt = int.tryParse(value); + if (parsedInt != null) return parsedInt; + throw FormatException( + "Expected an integer value but got unparseable string", + value, + ); + } + throw FormatException( + "Expected an integer value (int, or numeric String) but got" + " ${value.runtimeType}", + "$value", + ); +} diff --git a/lib/services/exchange/exolix/api/dto/exolix_rate.dart b/lib/services/exchange/exolix/api/dto/exolix_rate.dart new file mode 100644 index 0000000000..fc1656d0ee --- /dev/null +++ b/lib/services/exchange/exolix/api/dto/exolix_rate.dart @@ -0,0 +1,53 @@ +import 'package:decimal/decimal.dart'; + +import '../helpers/parse_decimal.dart'; +import 'exolix_base_dto.dart'; + +/// Exchange rate quote. +/// +/// All numeric fields are [Decimal] to preserve precision for coin amounts +/// and exchange rates. +class ExolixRate extends ExolixBaseDto { + final Decimal fromAmount; + final Decimal toAmount; + final Decimal rate; + final String? message; + final Decimal minAmount; + final Decimal withdrawMin; + final Decimal maxAmount; + + ExolixRate({ + required this.fromAmount, + required this.toAmount, + required this.rate, + required this.message, + required this.minAmount, + required this.withdrawMin, + required this.maxAmount, + }); + + factory ExolixRate.fromJson(Map json) { + return ExolixRate( + fromAmount: parseDecimal(json["fromAmount"]), + toAmount: parseDecimal(json["toAmount"]), + rate: parseDecimal(json["rate"]), + message: json["message"] as String?, + minAmount: parseDecimal(json["minAmount"]), + withdrawMin: parseDecimal(json["withdrawMin"]), + maxAmount: parseDecimal(json["maxAmount"]), + ); + } + + @override + Map toMap() { + return { + "fromAmount": fromAmount.toString(), + "toAmount": toAmount.toString(), + "rate": rate.toString(), + "message": message, + "minAmount": minAmount.toString(), + "withdrawMin": withdrawMin.toString(), + "maxAmount": maxAmount.toString(), + }; + } +} diff --git a/lib/services/exchange/exolix/api/dto/exolix_transaction.dart b/lib/services/exchange/exolix/api/dto/exolix_transaction.dart new file mode 100644 index 0000000000..1b1e98231b --- /dev/null +++ b/lib/services/exchange/exolix/api/dto/exolix_transaction.dart @@ -0,0 +1,152 @@ +import 'package:decimal/decimal.dart'; + +import '../helpers/enums.dart'; +import '../helpers/parse_decimal.dart'; +import 'exolix_base_dto.dart'; +import 'exolix_coin_info.dart'; +import 'exolix_hash.dart'; + +/// A full transaction object. +/// +/// Coin amounts and the exchange rate are [Decimal] for precision. +class ExolixTransaction extends ExolixBaseDto { + final String id; + final Decimal amount; + final Decimal amountTo; + final ExolixCoinInfo coinFrom; + final ExolixCoinInfo coinTo; + final String? comment; + final DateTime? createdAt; + final String depositAddress; + final String? depositExtraId; + final String withdrawalAddress; + final String? withdrawalExtraId; + final ExolixHash hashIn; + final ExolixHash hashOut; + final Decimal rate; + final ExolixRateType rateType; + final String? refundAddress; + final String? refundExtraId; + final ExolixTransactionStatus status; + + /// "source" is documented for the listing endpoint but not for the single + /// fetch. Nullable so it round-trips safely either way. + final String? source; + + ExolixTransaction({ + required this.id, + required this.amount, + required this.amountTo, + required this.coinFrom, + required this.coinTo, + required this.comment, + required this.createdAt, + required this.depositAddress, + required this.depositExtraId, + required this.withdrawalAddress, + required this.withdrawalExtraId, + required this.hashIn, + required this.hashOut, + required this.rate, + required this.rateType, + required this.refundAddress, + required this.refundExtraId, + required this.status, + required this.source, + }); + + factory ExolixTransaction.fromJson(Map json) { + final dynamic coinFromRaw = json["coinFrom"]; + final dynamic coinToRaw = json["coinTo"]; + final dynamic hashInRaw = json["hashIn"]; + final dynamic hashOutRaw = json["hashOut"]; + + DateTime? parsedCreatedAt; + final dynamic createdAtRaw = json["createdAt"]; + if (createdAtRaw is String && createdAtRaw.isNotEmpty) { + parsedCreatedAt = DateTime.tryParse(createdAtRaw); + } + + ExolixRateType parsedRateType; + final dynamic rateTypeRaw = json["rateType"]; + if (rateTypeRaw == "float") { + parsedRateType = ExolixRateType.float; + } else { + // Default per docs is fixed. + parsedRateType = ExolixRateType.fixed; + } + + return ExolixTransaction( + id: json["id"] as String? ?? "", + amount: parseDecimal(json["amount"]), + amountTo: parseDecimal(json["amountTo"]), + coinFrom: (coinFromRaw is Map) + ? ExolixCoinInfo.fromJson(Map.from(coinFromRaw)) + : ExolixCoinInfo( + coinCode: "", + coinName: "", + network: "", + networkName: "", + networkShortName: null, + icon: null, + memoName: null, + contract: null, + ), + coinTo: (coinToRaw is Map) + ? ExolixCoinInfo.fromJson(Map.from(coinToRaw)) + : ExolixCoinInfo( + coinCode: "", + coinName: "", + network: "", + networkName: "", + networkShortName: null, + icon: null, + memoName: null, + contract: null, + ), + comment: json["comment"] as String?, + createdAt: parsedCreatedAt, + depositAddress: json["depositAddress"] as String? ?? "", + depositExtraId: json["depositExtraId"] as String?, + withdrawalAddress: json["withdrawalAddress"] as String? ?? "", + withdrawalExtraId: json["withdrawalExtraId"] as String?, + hashIn: (hashInRaw is Map) + ? ExolixHash.fromJson(Map.from(hashInRaw)) + : ExolixHash(hash: null, link: null), + hashOut: (hashOutRaw is Map) + ? ExolixHash.fromJson(Map.from(hashOutRaw)) + : ExolixHash(hash: null, link: null), + rate: parseDecimal(json["rate"]), + rateType: parsedRateType, + refundAddress: json["refundAddress"] as String?, + refundExtraId: json["refundExtraId"] as String?, + status: ExolixTransactionStatus.fromString(json["status"] as String?), + source: json["source"] as String?, + ); + } + + @override + Map toMap() { + return { + "id": id, + "amount": amount.toString(), + "amountTo": amountTo.toString(), + "coinFrom": coinFrom.toMap(), + "coinTo": coinTo.toMap(), + "comment": comment, + "createdAt": createdAt?.toIso8601String(), + "depositAddress": depositAddress, + "depositExtraId": depositExtraId, + "withdrawalAddress": withdrawalAddress, + "withdrawalExtraId": withdrawalExtraId, + "hashIn": hashIn.toMap(), + "hashOut": hashOut.toMap(), + "rate": rate.toString(), + "rateType": rateType.apiValue, + "refundAddress": refundAddress, + "refundExtraId": refundExtraId, + "status": status.name, + "source": source, + }; + } +} diff --git a/lib/services/exchange/exolix/api/exolix_api.dart b/lib/services/exchange/exolix/api/exolix_api.dart new file mode 100644 index 0000000000..19b4291b01 --- /dev/null +++ b/lib/services/exchange/exolix/api/exolix_api.dart @@ -0,0 +1,515 @@ +import "dart:convert"; +import "dart:io"; + +import "package:decimal/decimal.dart"; +import "package:flutter/material.dart"; + +import "../../../../app_config.dart"; +import "../../../../external_api_keys.dart"; +import "../../../../networking/http.dart"; +import "../../../../utilities/prefs.dart"; +import "../../../tor_service.dart"; +import "dto/exolix_currency.dart"; +import "dto/exolix_network.dart"; +import "dto/exolix_rate.dart"; +import "dto/exolix_transaction.dart"; +import "helpers/enums.dart"; +import "helpers/exolix_paginated_response.dart"; + +class ExolixApiException implements Exception { + final int? statusCode; + final String message; + final dynamic body; + + ExolixApiException({required this.message, this.statusCode, this.body}); + + @override + String toString() => + "ExolixApiException(" + "statusCode: $statusCode, " + "message: $message, " + "body: $body)"; +} + +class ExolixApi { + ExolixApi._(); + + static const String _baseUrl = "https://exolix.com/api/v2"; + + /// Override to inject a mock client in tests. + static HTTP _client = const HTTP(); + + // ignore: avoid_setters_without_getters + @visibleForTesting + static set client(HTTP client) { + _client = client; + } + + /// Resolves the API key to use for a request. If [override] is null OR an + /// empty/whitespace-only string, falls back to [kExolixApiKey]. + static String _resolveApiKey(String? override) { + if (override == null) return kExolixApiKey; + final trimmed = override.trim(); + if (trimmed.isEmpty) return kExolixApiKey; + return trimmed; + } + + /// Builds the standard headers. The Authorization header is only attached + /// when the resolved key is non-empty AND not the literal placeholder. + /// Many endpoints work unauthenticated, so we must not send a useless + /// header that could be rejected by the server. + static Map _buildHeaders(String? apiKey) { + final headers = { + "Accept": "application/json", + "Content-Type": "application/json", + }; + final key = _resolveApiKey(apiKey); + if (key.isNotEmpty && key != "YOUR_API_KEY_HERE") { + headers["Authorization"] = key; + } + return headers; + } + + /// Encodes a query parameter map, dropping null values. All values are + /// stringified because Uri requires String values. [Decimal] values are + /// rendered via their canonical [Decimal.toString()] for lossless transport. + static Map _encodeQuery(Map raw) { + final out = {}; + raw.forEach((key, value) { + if (value == null) return; + if (value is bool) { + out[key] = value ? "true" : "false"; + } else if (value is Decimal) { + out[key] = value.toString(); + } else { + out[key] = value.toString(); + } + }); + return out; + } + + /// Builds a URI for a path under the base URL with optional query params. + static Uri _buildUri(String path, [Map? query]) { + final fullPath = path.startsWith("/") ? path : "/$path"; + final base = Uri.parse("$_baseUrl$fullPath"); + if (query == null || query.isEmpty) { + return base; + } + final encoded = _encodeQuery(query); + if (encoded.isEmpty) { + return base; + } + return base.replace(queryParameters: encoded); + } + + /// Resolve the proxy info to use for a request based on app config + prefs. + /// Returns null when the Tor feature is disabled or when the user has not + /// opted in to Tor in prefs. + static ({InternetAddress host, int port})? _resolveProxyInfo() { + if (!AppConfig.hasFeature(AppFeature.tor)) { + return null; + } + if (Prefs.instance.useTor) { + return TorService.sharedInstance.getProxyInfo(); + } + return null; + } + + /// Encodes a request body, serializing [Decimal] values as raw JSON + /// numbers (not strings) so the wire format matches the API examples. + /// We do this by emitting the JSON manually for top-level fields, since + /// jsonEncode's `toEncodable` can only return objects, not raw tokens. + /// + /// The body is a flat Map in this API, which keeps the + /// implementation simple. If you ever nest Decimals deeper, extend this. + static String _encodeBody(Map body) { + final buffer = StringBuffer("{"); + var first = true; + body.forEach((key, value) { + if (!first) buffer.write(","); + first = false; + buffer.write(jsonEncode(key)); + buffer.write(":"); + if (value is Decimal) { + // Emit as a raw JSON number using Decimal's canonical string form. + // Decimal.toString() never produces exponent form for finite values + // and always yields a valid JSON number. + buffer.write(value.toString()); + } else { + buffer.write(jsonEncode(value)); + } + }); + buffer.write("}"); + return buffer.toString(); + } + + /// Parse a response body and check status. Throws [ExolixApiException] on + /// non-2xx. Returns the decoded body (Map or List), or the raw String body + /// if it wasn't JSON-parseable. + static dynamic _parseResponse( + int status, + String body, + String endpointForError, + ) { + dynamic decoded; + if (body.isNotEmpty) { + try { + decoded = jsonDecode(body); + } catch (_) { + decoded = body; + } + } + if (status < 200 || status >= 300) { + String message; + if (decoded is Map && decoded["message"] is String) { + message = decoded["message"] as String; + } else if (decoded is Map && decoded["error"] is String) { + message = decoded["error"] as String; + } else { + message = "Request failed with status $status for $endpointForError"; + } + throw ExolixApiException( + statusCode: status, + message: message, + body: decoded, + ); + } + return decoded; + } + + /// Issues a GET and returns the decoded body. Throws on non-2xx. + static Future _get(Uri uri, String? apiKey) async { + final response = await _client.get( + url: uri, + headers: _buildHeaders(apiKey), + proxyInfo: _resolveProxyInfo(), + ); + return _parseResponse(response.code, response.body, uri.path); + } + + /// Issues a POST and returns the decoded body. Throws on non-2xx. + static Future _post( + Uri uri, + String? apiKey, + Map jsonBody, + ) async { + final response = await _client.post( + url: uri, + headers: _buildHeaders(apiKey), + body: _encodeBody(jsonBody), + proxyInfo: _resolveProxyInfo(), + ); + return _parseResponse(response.code, response.body, uri.path); + } + + // -------------------------------------------------------- + // Currencies + // -------------------------------------------------------- + + /// GET /currencies + static Future> getCurrencies({ + int? page, + int? size, + String? search, + bool? withNetworks, + String? apiKey, + }) async { + final uri = _buildUri("/currencies", { + "page": page, + "size": size, + "search": search, + "withNetworks": withNetworks, + }); + final result = await _get(uri, apiKey); + if (result is! Map) { + throw ExolixApiException( + message: "Unexpected response shape for /currencies", + body: result, + ); + } + return ExolixPaginatedResponse.fromJson( + Map.from(result), + ExolixCurrency.fromJson, + ); + } + + /// GET /currencies/{code}/networks + static Future> getCurrencyNetworks({ + required String code, + String? apiKey, + }) async { + if (code.trim().isEmpty) { + throw ArgumentError.value(code, "code", "must not be empty"); + } + final uri = _buildUri("/currencies/${Uri.encodeComponent(code)}/networks"); + final result = await _get(uri, apiKey); + if (result is! List) { + throw ExolixApiException( + message: "Unexpected response shape for /currencies/$code/networks", + body: result, + ); + } + return result + .map((e) => ExolixNetwork.fromJson(Map.from(e as Map))) + .toList(); + } + + /// GET /currencies/networks + static Future> getAllNetworks({ + int? page, + int? size, + String? search, + String? apiKey, + }) async { + final uri = _buildUri("/currencies/networks", { + "page": page, + "size": size, + "search": search, + }); + final result = await _get(uri, apiKey); + if (result is! Map) { + throw ExolixApiException( + message: "Unexpected response shape for /currencies/networks", + body: result, + ); + } + return ExolixPaginatedResponse.fromJson( + Map.from(result), + ExolixNetwork.fromJson, + ); + } + + // -------------------------------------------------------- + // Rate + // -------------------------------------------------------- + + /// GET /rate + /// + /// You must supply EXACTLY ONE of [amount] or [withdrawalAmount]. Supplying + /// neither or both throws [ArgumentError]. Both are coin amounts and use + /// [Decimal] for precision. + static Future getRate({ + required String coinFrom, + required String coinTo, + String? networkFrom, + String? networkTo, + Decimal? amount, + Decimal? withdrawalAmount, + ExolixRateType rateType = ExolixRateType.fixed, + String? apiKey, + }) async { + if (coinFrom.trim().isEmpty) { + throw ArgumentError.value(coinFrom, "coinFrom", "must not be empty"); + } + if (coinTo.trim().isEmpty) { + throw ArgumentError.value(coinTo, "coinTo", "must not be empty"); + } + final hasAmount = amount != null; + final hasWithdraw = withdrawalAmount != null; + if (!hasAmount && !hasWithdraw) { + throw ArgumentError("Must supply either amount or withdrawalAmount."); + } + if (hasAmount && hasWithdraw) { + throw ArgumentError( + "Supply only one of amount or withdrawalAmount, not both.", + ); + } + if (amount != null && amount <= Decimal.zero) { + throw ArgumentError.value(amount, "amount", "must be positive"); + } + if (withdrawalAmount != null && withdrawalAmount <= Decimal.zero) { + throw ArgumentError.value( + withdrawalAmount, + "withdrawalAmount", + "must be positive", + ); + } + + final uri = _buildUri("/rate", { + "coinFrom": coinFrom, + "coinTo": coinTo, + "networkFrom": networkFrom, + "networkTo": networkTo, + "amount": amount, + "withdrawalAmount": withdrawalAmount, + "rateType": rateType.apiValue, + }); + final result = await _get(uri, apiKey); + if (result is! Map) { + throw ExolixApiException( + message: "Unexpected response shape for /rate", + body: result, + ); + } + return ExolixRate.fromJson(Map.from(result)); + } + + // -------------------------------------------------------- + // Transactions + // -------------------------------------------------------- + + /// GET /transactions + static Future> getTransactions({ + int? page, + int? size, + String? search, + String? sort, + String? order, + DateTime? dateFrom, + DateTime? dateTo, + String? statuses, + String? apiKey, + }) async { + if (order != null) { + final normalized = order.toLowerCase(); + if (normalized != "asc" && normalized != "desc") { + throw ArgumentError.value(order, "order", "must be 'asc' or 'desc'"); + } + } + final uri = _buildUri("/transactions", { + "page": page, + "size": size, + "search": search, + "sort": sort, + "order": order?.toLowerCase(), + "dateFrom": dateFrom?.toUtc().toIso8601String(), + "dateTo": dateTo?.toUtc().toIso8601String(), + "statuses": statuses, + }); + final result = await _get(uri, apiKey); + if (result is! Map) { + throw ExolixApiException( + message: "Unexpected response shape for /transactions", + body: result, + ); + } + return ExolixPaginatedResponse.fromJson( + Map.from(result), + ExolixTransaction.fromJson, + ); + } + + /// GET /transactions/{id} + static Future getTransaction({ + required String id, + String? apiKey, + }) async { + if (id.trim().isEmpty) { + throw ArgumentError.value(id, "id", "must not be empty"); + } + final uri = _buildUri("/transactions/${Uri.encodeComponent(id)}"); + final result = await _get(uri, apiKey); + if (result is! Map) { + throw ExolixApiException( + message: "Unexpected response shape for /transactions/$id", + body: result, + ); + } + return ExolixTransaction.fromJson(Map.from(result)); + } + + /// POST /transactions + /// + /// Exactly one of [amount] / [withdrawalAmount] must be supplied — both are + /// coin amounts and use [Decimal]. If [slippage] is supplied, + /// [refundAddress] is required (per the docs). [slippage] is a percentage, + /// not a money value, so it stays a [double]. + static Future createTransaction({ + required String coinFrom, + required String networkFrom, + required String coinTo, + required String networkTo, + required String withdrawalAddress, + Decimal? amount, + Decimal? withdrawalAmount, + String? withdrawalExtraId, + ExolixRateType rateType = ExolixRateType.fixed, + String? refundAddress, + String? refundExtraId, + double? slippage, + String? apiKey, + }) async { + if (coinFrom.trim().isEmpty) { + throw ArgumentError.value(coinFrom, "coinFrom", "must not be empty"); + } + if (networkFrom.trim().isEmpty) { + throw ArgumentError.value( + networkFrom, + "networkFrom", + "must not be empty", + ); + } + if (coinTo.trim().isEmpty) { + throw ArgumentError.value(coinTo, "coinTo", "must not be empty"); + } + if (networkTo.trim().isEmpty) { + throw ArgumentError.value(networkTo, "networkTo", "must not be empty"); + } + if (withdrawalAddress.trim().isEmpty) { + throw ArgumentError.value( + withdrawalAddress, + "withdrawalAddress", + "must not be empty", + ); + } + + final hasAmount = amount != null; + final hasWithdraw = withdrawalAmount != null; + if (!hasAmount && !hasWithdraw) { + throw ArgumentError("Must supply either amount or withdrawalAmount."); + } + if (hasAmount && hasWithdraw) { + throw ArgumentError( + "Supply only one of amount or withdrawalAmount, not both.", + ); + } + if (amount != null && amount <= Decimal.zero) { + throw ArgumentError.value(amount, "amount", "must be positive"); + } + if (withdrawalAmount != null && withdrawalAmount <= Decimal.zero) { + throw ArgumentError.value( + withdrawalAmount, + "withdrawalAmount", + "must be positive", + ); + } + + if (slippage != null) { + if (slippage < 0) { + throw ArgumentError.value(slippage, "slippage", "must be non-negative"); + } + if (refundAddress == null || refundAddress.trim().isEmpty) { + throw ArgumentError( + "refundAddress is required when slippage is provided.", + ); + } + } + + final body = { + "coinFrom": coinFrom, + "networkFrom": networkFrom, + "coinTo": coinTo, + "networkTo": networkTo, + "withdrawalAddress": withdrawalAddress, + "rateType": rateType.apiValue, + }; + if (amount != null) body["amount"] = amount; + if (withdrawalAmount != null) body["withdrawalAmount"] = withdrawalAmount; + if (withdrawalExtraId != null) { + body["withdrawalExtraId"] = withdrawalExtraId; + } + if (refundAddress != null) body["refundAddress"] = refundAddress; + if (refundExtraId != null) body["refundExtraId"] = refundExtraId; + if (slippage != null) body["slippage"] = slippage; + + final uri = _buildUri("/transactions"); + final result = await _post(uri, apiKey, body); + if (result is! Map) { + throw ExolixApiException( + message: "Unexpected response shape for POST /transactions", + body: result, + ); + } + return ExolixTransaction.fromJson(Map.from(result)); + } +} diff --git a/lib/services/exchange/exolix/api/helpers/enums.dart b/lib/services/exchange/exolix/api/helpers/enums.dart new file mode 100644 index 0000000000..90c6809fbc --- /dev/null +++ b/lib/services/exchange/exolix/api/helpers/enums.dart @@ -0,0 +1,37 @@ +/// The rate type for an exchange. +enum ExolixRateType { + fixed, + float; + + String get apiValue => switch (this) { + .fixed => "fixed", + .float => "float", + }; +} + +/// Transaction status returned by the API. +enum ExolixTransactionStatus { + wait, + confirmation, + confirmed, + exchanging, + sending, + success, + overdue, + refund, + refunded, + unknown; + + static ExolixTransactionStatus fromString(String? value) => switch (value) { + "wait" => .wait, + "confirmation" => .confirmation, + "confirmed" => .confirmed, + "exchanging" => .exchanging, + "sending" => .sending, + "success" => .success, + "overdue" => .overdue, + "refund" => .refund, + "refunded" => .refunded, + _ => .unknown, + }; +} diff --git a/lib/services/exchange/exolix/api/helpers/exolix_paginated_response.dart b/lib/services/exchange/exolix/api/helpers/exolix_paginated_response.dart new file mode 100644 index 0000000000..28a22ab4db --- /dev/null +++ b/lib/services/exchange/exolix/api/helpers/exolix_paginated_response.dart @@ -0,0 +1,37 @@ +import '../dto/exolix_base_dto.dart'; + +class ExolixPaginatedResponse { + final List data; + final int count; + + ExolixPaginatedResponse({required this.data, required this.count}); + + factory ExolixPaginatedResponse.fromJson( + Map json, + T Function(Map) itemFromJson, + ) { + final dynamic rawData = json["data"]; + final List items = (rawData is List) + ? rawData + .map((e) => itemFromJson(Map.from(e as Map))) + .toList() + : []; + return ExolixPaginatedResponse( + data: items, + count: int.parse(json["count"].toString()), + ); + } + + Map toMap() { + return { + "data": data.map((e) { + if (e is ExolixBaseDto) return e.toMap(); + return e.toString(); + }).toList(), + "count": count, + }; + } + + @override + String toString() => toMap().toString(); +} diff --git a/lib/services/exchange/exolix/api/helpers/parse_decimal.dart b/lib/services/exchange/exolix/api/helpers/parse_decimal.dart new file mode 100644 index 0000000000..ba42835f7b --- /dev/null +++ b/lib/services/exchange/exolix/api/helpers/parse_decimal.dart @@ -0,0 +1,27 @@ +import 'package:decimal/decimal.dart'; + +Decimal parseDecimal(dynamic value) { + if (value is Decimal) return value; + if (value is int) return Decimal.fromInt(value); + if (value is double) { + final parsed = Decimal.tryParse(value.toString()); + if (parsed != null) return parsed; + throw FormatException( + "Could not convert double to Decimal", + value.toString(), + ); + } + if (value is String) { + final parsed = Decimal.tryParse(value); + if (parsed != null) return parsed; + throw FormatException( + "Expected a numeric Decimal value but got unparseable string", + value, + ); + } + throw FormatException( + "Expected a Decimal-compatible value (num or numeric String) but got" + " ${value.runtimeType}", + "$value", + ); +} diff --git a/lib/services/exchange/exolix/exolix_exchange.dart b/lib/services/exchange/exolix/exolix_exchange.dart new file mode 100644 index 0000000000..d8c0f1a83a --- /dev/null +++ b/lib/services/exchange/exolix/exolix_exchange.dart @@ -0,0 +1,306 @@ +import 'package:decimal/decimal.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../app_config.dart'; +import '../../../exceptions/exchange/exchange_exception.dart'; +import '../../../models/exchange/response_objects/estimate.dart'; +import '../../../models/exchange/response_objects/range.dart'; +import '../../../models/exchange/response_objects/trade.dart'; +import '../../../models/isar/exchange_cache/currency.dart'; +import '../exchange.dart'; +import '../exchange_response.dart'; +import 'api/dto/exolix_currency.dart'; +import 'api/exolix_api.dart'; + +class ExolixExchange extends Exchange { + ExolixExchange._(); + + static ExolixExchange? _instance; + static ExolixExchange get instance => _instance ??= ExolixExchange._(); + + static const exchangeName = "Exolix"; + + @override + String get name => exchangeName; + + @override + Future> createTrade({ + required String from, + required String to, + required String? fromNetwork, + required String? toNetwork, + required bool fixedRate, + required Decimal amount, + required String addressTo, + String? extraId, + required String addressRefund, + required String refundExtraId, + Estimate? estimate, + required bool reversed, + }) async { + try { + if (fromNetwork == null || toNetwork == null) { + throw ExchangeException("Exolix requires coin network args", .generic); + } + + final result = await ExolixApi.createTransaction( + coinFrom: from, + networkFrom: fromNetwork, + coinTo: to, + networkTo: toNetwork, + withdrawalAddress: addressTo, + amount: reversed ? null : amount, + withdrawalAmount: reversed ? amount : null, + withdrawalExtraId: extraId, + refundAddress: addressRefund, + refundExtraId: refundExtraId, + rateType: fixedRate ? .fixed : .float, + ); + + final trade = Trade( + uuid: const Uuid().v1(), + tradeId: result.id, + rateType: result.rateType == .float ? "estimated" : "fixed", + direction: reversed ? "reversed" : "normal", + timestamp: result.createdAt ?? DateTime.now(), + updatedAt: result.createdAt ?? DateTime.now(), + payInCurrency: result.coinFrom.coinCode, + payInAmount: result.amount.toString(), + payInAddress: result.depositAddress, + payInNetwork: result.coinFrom.network, + payInExtraId: result.depositExtraId ?? "", + payInTxid: result.hashIn.hash ?? "", + payOutCurrency: result.coinTo.coinCode, + payOutAmount: result.amountTo.toString(), + payOutAddress: result.withdrawalAddress, + payOutNetwork: result.coinTo.network, + payOutExtraId: result.withdrawalExtraId ?? "", + payOutTxid: result.hashOut.hash ?? "", + refundAddress: result.refundAddress ?? addressRefund, + refundExtraId: result.refundExtraId ?? refundExtraId, + status: result.status.name, + exchangeName: exchangeName, + ); + + return ExchangeResponse(value: trade); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future>> getAllCurrencies( + bool fixedRate, + ) async { + try { + const pageSize = 100; // some reasonable value + final collected = []; + int page = 1; + + // First page gives us `count` so we know when to stop. + final first = await ExolixApi.getCurrencies( + page: page, + size: pageSize, + withNetworks: true, + ); + collected.addAll(first.data); + final total = first.count; + + while (collected.length < total && first.data.isNotEmpty) { + page += 1; + final next = await ExolixApi.getCurrencies( + page: page, + size: pageSize, + withNetworks: true, + ); + if (next.data.isEmpty) { + // Server says we're done even though count disagrees — stop rather + // than loop forever. + break; + } + collected.addAll(next.data); + } + + final results = []; + for (final currency in collected) { + for (final net in currency.networks) { + results.add( + Currency( + exchangeName: exchangeName, + ticker: currency.code, + name: net.isDefault + ? currency.name + : "${currency.name} (${net.shortName})", + network: net.network, + image: net.icon ?? currency.icon ?? "", + isFiat: false, + rateType: .both, + isStackCoin: AppConfig.isStackCoin(currency.code), + tokenContract: net.contract, + isAvailable: true, + ), + ); + } + } + + return ExchangeResponse(value: results); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future>> getEstimates( + String from, + String? fromNetwork, + String to, + String? toNetwork, + Decimal amount, + bool fixedRate, + bool reversed, + ) async { + try { + final response = await ExolixApi.getRate( + coinFrom: from, + coinTo: to, + networkFrom: fromNetwork, + networkTo: toNetwork, + amount: reversed ? null : amount, + withdrawalAmount: reversed ? amount : null, + rateType: fixedRate ? .fixed : .float, + ); + + final estimate = Estimate( + estimatedAmount: reversed ? response.fromAmount : response.toAmount, + fixedRate: fixedRate, + reversed: reversed, + exchangeProvider: exchangeName, + ); + + return ExchangeResponse(value: [estimate]); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> getRange( + String from, + String? fromNetwork, + String to, + String? toNetwork, + bool fixedRate, + ) async { + try { + final response = await ExolixApi.getRate( + coinFrom: from, + coinTo: to, + networkFrom: fromNetwork, + networkTo: toNetwork, + amount: Decimal.one, // hack in a random value placeholder I guess? + rateType: fixedRate ? .fixed : .float, + ); + + return ExchangeResponse( + value: Range(min: response.minAmount, max: response.maxAmount), + ); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> getTrade(String tradeId) async { + try { + throw UnimplementedError("Not currently used in this app"); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future>> getTrades() async { + try { + throw UnimplementedError("Not currently used in this app"); + } catch (e) { + return ExchangeResponse>( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> updateTrade(Trade trade) async { + try { + final result = await ExolixApi.getTransaction(id: trade.tradeId); + + return ExchangeResponse( + value: Trade( + uuid: trade.uuid, + tradeId: result.id, + rateType: result.rateType == .float ? "estimated" : "fixed", + direction: trade.direction, + timestamp: result.createdAt ?? DateTime.now(), + updatedAt: result.createdAt ?? DateTime.now(), + payInCurrency: result.coinFrom.coinCode, + payInAmount: result.amount.toString(), + payInAddress: result.depositAddress, + payInNetwork: result.coinFrom.network, + payInExtraId: result.depositExtraId ?? "", + payInTxid: result.hashIn.hash ?? "", + payOutCurrency: result.coinTo.coinCode, + payOutAmount: result.amountTo.toString(), + payOutAddress: result.withdrawalAddress, + payOutNetwork: result.coinTo.network, + payOutExtraId: result.withdrawalExtraId ?? "", + payOutTxid: result.hashOut.hash ?? "", + refundAddress: result.refundAddress ?? trade.refundAddress, + refundExtraId: result.refundExtraId ?? trade.refundExtraId, + status: result.status.name, + exchangeName: exchangeName, + ), + ); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } +} diff --git a/lib/services/exchange/lets_exchange/lets_exchange_api.dart b/lib/services/exchange/lets_exchange/lets_exchange_api.dart new file mode 100644 index 0000000000..0a3ed236ce --- /dev/null +++ b/lib/services/exchange/lets_exchange/lets_exchange_api.dart @@ -0,0 +1,379 @@ +import "dart:convert"; +import "dart:io"; + +import "package:decimal/decimal.dart"; +import "package:meta/meta.dart"; + +import "../../../app_config.dart"; +import "../../../external_api_keys.dart"; +import "../../../networking/http.dart"; +import "../../../utilities/logger.dart"; +import "../../../utilities/prefs.dart"; +import "../../tor_service.dart"; +import "models/coin_info.dart"; +import "models/coin_v2.dart"; +import "models/transaction.dart"; + +class LetsExchangeApiException implements Exception { + final int? statusCode; + final String message; + final dynamic body; + + LetsExchangeApiException({required this.message, this.statusCode, this.body}); + + @override + String toString() => + "LetsExchangeApiException(" + "statusCode: $statusCode, " + "message: $message, " + "body: $body)"; +} + +abstract final class LetsExchangeApi { + static const base = "api.letsexchange.io"; + + /// Override to inject a mock client in tests. + static HTTP _client = const HTTP(); + + // ignore: avoid_setters_without_getters + @visibleForTesting + static set client(HTTP client) { + _client = client; + } + + static Map get _headers => { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": "Bearer $kLetsExchangeToken", + }; + + static ({InternetAddress host, int port})? _resolveProxyInfo() { + if (!AppConfig.hasFeature(AppFeature.tor)) { + return null; + } + if (Prefs.instance.useTor) { + return TorService.sharedInstance.getProxyInfo(); + } + return null; + } + + static T _decode(int code, String body, T Function(dynamic) parse) { + return switch (code) { + 200 => parse(jsonDecode(body)), + + final int status => throw LetsExchangeApiException( + message: switch (status) { + 403 => "Wrong API key in Bearer token", + 404 => "Not found", + 422 => "Unprocessable entity", + 500 => "Unexpected server error", + _ => "Unexpected status code", + }, + statusCode: status, + body: body, + ), + }; + } + + static Future _get( + Uri uri, { + required T Function(dynamic) parse, + }) async { + final response = await _client.get( + url: uri, + headers: _headers, + proxyInfo: _resolveProxyInfo(), + ); + + Logging.instance.t("LetsExchangeApi GET $uri: ${response.code}"); + + return _decode(response.code, response.body, parse); + } + + static Future _post( + Uri uri, { + required Map body, + required T Function(dynamic) parse, + }) async { + final response = await _client.post( + url: uri, + headers: _headers, + body: jsonEncode(body..["affiliate_id"] = kLetsExchangeId), + proxyInfo: _resolveProxyInfo(), + ); + + Logging.instance.t("LetsExchangeApi POST $uri: ${response.code}"); + + return _decode(response.code, response.body, parse); + } + + // =========================================================================== + // ======== API ============================================================== + + static Future> fetchCoins() async { + final uri = Uri.https(base, "/api/v2/coins", { + "affiliate_id": kLetsExchangeId, + }); + + return _get( + uri, + parse: (value) => (value as List) + .map((e) => CoinV2.fromJson((e as Map).cast())) + .toList(), + ); + } + + static Future getCoinInfo(CoinInfoRequest request) async { + final uri = Uri.https(base, "/api/v1/info"); + + return _post( + uri, + body: request.toMap(), + parse: (value) => CoinInfo.fromJson((value as Map).cast()), + ); + } + + static Future getCoinInfoRevert(CoinInfoRequest request) async { + final uri = Uri.https(base, "/api/v1/info-revert"); + + return _post( + uri, + body: request.toMap(), + parse: (value) => CoinInfo.fromJson((value as Map).cast()), + ); + } + + static Future createTransaction( + CreateTransactionRequest request, + ) async { + final uri = Uri.https(base, "/api/v1/transaction"); + + return _post( + uri, + body: request.toMap(), + parse: (value) => Transaction.fromJson((value as Map).cast()), + ); + } + + static Future createTransactionRevert( + CreateTransactionRevertRequest request, + ) async { + final uri = Uri.https(base, "/api/v1/transaction-revert"); + + return _post( + uri, + body: request.toMap(), + parse: (value) => Transaction.fromJson((value as Map).cast()), + ); + } + + static Future getTransaction(String id) async { + final uri = Uri.https(base, "/api/v1/transaction/$id"); + + return _get( + uri, + parse: (value) => Transaction.fromJson((value as Map).cast()), + ); + } +} + +// ============================================================================= +// ============ Request objects +=============================================== + +/// For `LetsExchangeApi.getCoinInfo` [amount] is the amount of [from] +/// the user will send; for `LetsExchangeApi.getCoinInfoRevert` it is the +/// amount of [to] the user wants to receive. [float] is only relevant to +/// `LetsExchangeApi.getCoinInfo` and is omitted from the body when null. +class CoinInfoRequest { + CoinInfoRequest({ + required this.from, + required this.to, + required this.networkFrom, + required this.networkTo, + required this.amount, + this.promocode, + this.float, + this.partnerUserIp, + }); + + final String from; + final String to; + final String networkFrom; + final String networkTo; + final Decimal amount; + final String? promocode; + final bool? float; + final String? partnerUserIp; + + factory CoinInfoRequest.fromJson(Map json) => + CoinInfoRequest( + from: json["from"] as String, + to: json["to"] as String, + networkFrom: json["network_from"] as String, + networkTo: json["network_to"] as String, + amount: Decimal.parse(json["amount"].toString()), + promocode: json["promocode"] as String?, + float: json["float"] as bool?, + partnerUserIp: json["partner_user_ip"] as String?, + ); + + Map toMap() => { + "from": from, + "to": to, + "network_from": networkFrom, + "network_to": networkTo, + "amount": amount.toString(), + if (promocode != null) "promocode": promocode, + if (float != null) "float": float, + if (partnerUserIp != null) "partner_user_ip": partnerUserIp, + }; + + @override + String toString() => toMap().toString(); +} + +class CreateTransactionRequest { + CreateTransactionRequest({ + required this.float, + required this.coinFrom, + required this.coinTo, + required this.networkFrom, + required this.networkTo, + required this.depositAmount, + required this.withdrawal, + required this.withdrawalExtraId, + this.returnAddress, + this.returnExtraId, + this.rateId, + this.promocode, + this.email, + this.partnerUserIp, + }); + + final bool float; + final String coinFrom; + final String coinTo; + final String networkFrom; + final String networkTo; + final Decimal depositAmount; + final String withdrawal; + + /// Must be present; pass an empty string when the coin has no extra ID. + final String withdrawalExtraId; + final String? returnAddress; + final String? returnExtraId; + + /// Rate identifier for the FIXED (`float: false`) flow. + final String? rateId; + final String? promocode; + final String? email; + final String? partnerUserIp; + + factory CreateTransactionRequest.fromJson(Map json) => + CreateTransactionRequest( + float: json["float"] as bool, + coinFrom: json["coin_from"] as String, + coinTo: json["coin_to"] as String, + networkFrom: json["network_from"] as String, + networkTo: json["network_to"] as String, + depositAmount: Decimal.parse(json["deposit_amount"].toString()), + withdrawal: json["withdrawal"] as String, + withdrawalExtraId: json["withdrawal_extra_id"] as String, + returnAddress: json["return"] as String?, + returnExtraId: json["return_extra_id"] as String?, + rateId: json["rate_id"] as String?, + promocode: json["promocode"] as String?, + email: json["email"] as String?, + partnerUserIp: json["partner_user_ip"] as String?, + ); + + Map toMap() => { + "float": float, + "coin_from": coinFrom, + "coin_to": coinTo, + "network_from": networkFrom, + "network_to": networkTo, + "deposit_amount": depositAmount.toString(), + "withdrawal": withdrawal, + "withdrawal_extra_id": withdrawalExtraId, + if (returnAddress != null) "return": returnAddress, + if (returnExtraId != null) "return_extra_id": returnExtraId, + if (rateId != null) "rate_id": rateId, + if (promocode != null) "promocode": promocode, + if (email != null) "email": email, + if (partnerUserIp != null) "partner_user_ip": partnerUserIp, + }; + + @override + String toString() => toMap().toString(); +} + +class CreateTransactionRevertRequest { + CreateTransactionRevertRequest({ + required this.float, + required this.coinFrom, + required this.coinTo, + required this.networkFrom, + required this.networkTo, + required this.withdrawalAmount, + required this.withdrawal, + required this.withdrawalExtraId, + required this.rateId, + this.returnAddress, + this.returnExtraId, + this.email, + this.partnerUserIp, + }); + + final bool float; + final String coinFrom; + final String coinTo; + final String networkFrom; + final String networkTo; + final Decimal withdrawalAmount; + final String withdrawal; + + /// Must be present; pass an empty string when the coin has no extra ID. + final String withdrawalExtraId; + final String rateId; + final String? returnAddress; + final String? returnExtraId; + final String? email; + final String? partnerUserIp; + + factory CreateTransactionRevertRequest.fromJson(Map json) => + CreateTransactionRevertRequest( + float: json["float"] as bool, + coinFrom: json["coin_from"] as String, + coinTo: json["coin_to"] as String, + networkFrom: json["network_from"] as String, + networkTo: json["network_to"] as String, + withdrawalAmount: Decimal.parse(json["withdrawal_amount"].toString()), + withdrawal: json["withdrawal"] as String, + withdrawalExtraId: json["withdrawal_extra_id"] as String, + rateId: json["rate_id"] as String, + returnAddress: json["return"] as String?, + returnExtraId: json["return_extra_id"] as String?, + email: json["email"] as String?, + partnerUserIp: json["partner_user_ip"] as String?, + ); + + Map toMap() => { + "float": float, + "coin_from": coinFrom, + "coin_to": coinTo, + "network_from": networkFrom, + "network_to": networkTo, + "withdrawal_amount": withdrawalAmount.toString(), + "withdrawal": withdrawal, + "withdrawal_extra_id": withdrawalExtraId, + "rate_id": rateId, + if (returnAddress != null) "return": returnAddress, + if (returnExtraId != null) "return_extra_id": returnExtraId, + if (email != null) "email": email, + if (partnerUserIp != null) "partner_user_ip": partnerUserIp, + }; + + @override + String toString() => toMap().toString(); +} diff --git a/lib/services/exchange/lets_exchange/lets_exchange_exchange.dart b/lib/services/exchange/lets_exchange/lets_exchange_exchange.dart new file mode 100644 index 0000000000..ba8f94209f --- /dev/null +++ b/lib/services/exchange/lets_exchange/lets_exchange_exchange.dart @@ -0,0 +1,307 @@ +import 'package:decimal/decimal.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../app_config.dart'; +import '../../../exceptions/exchange/exchange_exception.dart'; +import '../../../models/exchange/response_objects/estimate.dart'; +import '../../../models/exchange/response_objects/range.dart'; +import '../../../models/exchange/response_objects/trade.dart'; +import '../../../models/isar/exchange_cache/currency.dart'; +import '../../../utilities/logger.dart'; +import '../exchange.dart'; +import '../exchange_response.dart'; +import 'lets_exchange_api.dart'; +import 'models/coin_info.dart'; +import 'models/transaction.dart'; + +class LetsExchangeExchange extends Exchange { + LetsExchangeExchange._(); + + static LetsExchangeExchange? _instance; + static LetsExchangeExchange get instance => + _instance ??= LetsExchangeExchange._(); + + static const exchangeName = "LetsExchange"; + + Trade _buildTrade({ + required Transaction result, + required String uuid, + required String rateType, + required String direction, + required DateTime timestamp, + }) { + return Trade( + uuid: uuid, + tradeId: result.transactionId, + rateType: rateType, + direction: direction, + timestamp: timestamp, + updatedAt: DateTime.now(), + payInCurrency: result.coinFrom, + payInAmount: result.depositAmount.toString(), + payInAddress: result.deposit, + payInNetwork: result.coinFromNetwork, + payInExtraId: result.depositExtraId ?? "", + payInTxid: result.hashIn ?? "", + payOutCurrency: result.coinTo, + payOutAmount: result.withdrawalAmount.toString(), + payOutAddress: result.withdrawal, + payOutNetwork: result.coinToNetwork, + payOutExtraId: result.withdrawalExtraId ?? "", + payOutTxid: result.hashOut ?? "", + refundAddress: result.returnAddress ?? "", + refundExtraId: result.returnExtraId ?? "", + status: result.status, + exchangeName: exchangeName, + ); + } + + @override + Future> createTrade({ + required String from, + required String to, + required String? fromNetwork, + required String? toNetwork, + required bool fixedRate, + required Decimal amount, + required String addressTo, + String? extraId, + required String addressRefund, + required String refundExtraId, + Estimate? estimate, + required bool reversed, + }) async { + try { + if (fromNetwork == null) throw Exception("fromNetwork must not be null"); + if (toNetwork == null) throw Exception("toNetwork must not be null"); + + if (reversed && estimate?.rateId == null) { + throw Exception("rateId required for reversed trade"); + } + + if (!reversed && fixedRate && estimate?.rateId == null) { + throw Exception("rateId required for fixed rate trade"); + } + + final Transaction result; + if (reversed) { + final request = CreateTransactionRevertRequest( + float: !fixedRate, + coinFrom: from.toUpperCase(), + coinTo: to.toUpperCase(), + networkFrom: fromNetwork, + networkTo: toNetwork, + withdrawalAmount: amount, + withdrawal: addressTo, + withdrawalExtraId: extraId ?? "", + returnAddress: addressRefund, + returnExtraId: refundExtraId, + rateId: estimate!.rateId!, + ); + result = await LetsExchangeApi.createTransactionRevert(request); + } else { + final request = CreateTransactionRequest( + float: !fixedRate, + coinFrom: from.toUpperCase(), + coinTo: to.toUpperCase(), + networkFrom: fromNetwork, + networkTo: toNetwork, + depositAmount: amount, + withdrawal: addressTo, + withdrawalExtraId: extraId ?? "", + returnAddress: addressRefund, + returnExtraId: refundExtraId, + rateId: estimate?.rateId, + ); + result = await LetsExchangeApi.createTransaction(request); + } + + final trade = _buildTrade( + result: result, + uuid: const Uuid().v1(), + rateType: !fixedRate ? "estimated" : "fixed", + direction: reversed ? "reversed" : "normal", + timestamp: DateTime.now(), + ); + + return ExchangeResponse(value: trade); + } catch (e, s) { + Logging.instance.e("createTrade", error: e, stackTrace: s); + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future>> getAllCurrencies( + bool fixedRate, + ) async { + try { + final coins = await LetsExchangeApi.fetchCoins(); + + final currencies = [ + for (final coin in coins) + for (final network in coin.networks) + Currency( + exchangeName: exchangeName, + ticker: coin.code, + name: coin.name, + network: network.code, + image: coin.icon, + isFiat: false, + isAvailable: coin.isActive && network.isActive, + tokenContract: network.contractAddress, + rateType: .both, + isStackCoin: AppConfig.isStackCoin(coin.code), + ), + ]; + + return ExchangeResponse(value: currencies); + } catch (e, s) { + Logging.instance.e("getAllCurrencies", error: e, stackTrace: s); + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future>> getEstimates( + String from, + String? fromNetwork, + String to, + String? toNetwork, + Decimal amount, + bool fixedRate, + bool reversed, + ) async { + try { + if (fromNetwork == null) throw Exception("fromNetwork must not be null"); + if (toNetwork == null) throw Exception("toNetwork must not be null"); + + final CoinInfo info; + if (reversed) { + final request = CoinInfoRequest( + from: from.toUpperCase(), + to: to.toUpperCase(), + networkFrom: fromNetwork, + networkTo: toNetwork, + amount: amount, + ); + info = await LetsExchangeApi.getCoinInfoRevert(request); + } else { + final request = CoinInfoRequest( + from: from.toUpperCase(), + to: to.toUpperCase(), + networkFrom: fromNetwork, + networkTo: toNetwork, + amount: amount, + float: !fixedRate, + ); + info = await LetsExchangeApi.getCoinInfo(request); + } + + final estimate = Estimate( + rateId: info.rateId, + estimatedAmount: info.amount, + fixedRate: fixedRate, + reversed: reversed, + exchangeProvider: exchangeName, + ); + + return ExchangeResponse(value: [estimate]); + } catch (e, s) { + Logging.instance.e("getEstimates", error: e, stackTrace: s); + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> getRange( + String from, + String? fromNetwork, + String to, + String? toNetwork, + bool fixedRate, + ) async { + try { + if (fromNetwork == null) throw Exception("fromNetwork must not be null"); + if (toNetwork == null) throw Exception("toNetwork must not be null"); + + // `/v1/info` requires an amount, but the returned min/max are the pair's + // limits and don't depend on it, so we probe with a nominal value + final request = CoinInfoRequest( + from: from.toUpperCase(), + to: to.toUpperCase(), + networkFrom: fromNetwork, + networkTo: toNetwork, + amount: Decimal.parse("0.1"), + float: !fixedRate, + ); + + final info = await LetsExchangeApi.getCoinInfo(request); + + return ExchangeResponse( + value: Range(max: info.maxAmount, min: info.minAmount), + ); + } catch (e, s) { + Logging.instance.e("getRange", error: e, stackTrace: s); + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> getTrade(String tradeId) async { + throw UnimplementedError("Not currently used in this app"); + } + + @override + Future>> getTrades() async { + throw UnimplementedError("Not currently used in this app"); + } + + @override + String get name => exchangeName; + + @override + Future> updateTrade(Trade trade) async { + try { + final result = await LetsExchangeApi.getTransaction(trade.tradeId); + + final updated = _buildTrade( + result: result, + uuid: trade.uuid, + rateType: trade.rateType, + direction: trade.direction, + timestamp: trade.timestamp, + ); + + return ExchangeResponse(value: updated); + } catch (e, s) { + Logging.instance.e("updateTrade", error: e, stackTrace: s); + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } +} diff --git a/lib/services/exchange/lets_exchange/models/coin_info.dart b/lib/services/exchange/lets_exchange/models/coin_info.dart new file mode 100644 index 0000000000..db1098a497 --- /dev/null +++ b/lib/services/exchange/lets_exchange/models/coin_info.dart @@ -0,0 +1,66 @@ +import "package:decimal/decimal.dart"; + +class CoinInfo { + CoinInfo({ + required this.minAmount, + required this.maxAmount, + required this.amount, + required this.rate, + required this.profit, + required this.withdrawalFee, + required this.rateId, + required this.rateIdExpiredAt, + }); + + final Decimal minAmount; + final Decimal maxAmount; + final Decimal amount; + + final Decimal rate; + + final Decimal? profit; + final Decimal withdrawalFee; + + final String? rateId; + + final DateTime? rateIdExpiredAt; + + factory CoinInfo.fromJson(Map json) { + final rawProfit = json["profit"] as String?; + + final rawExpiredAt = json["rate_id_expired_at"] is int + ? json["rate_id_expired_at"] as int + : json["rate_id_expired_at"] is String + ? int.tryParse(json["rate_id_expired_at"] as String) + : null; + + final expiredAt = rawExpiredAt == null + ? null + : DateTime.fromMillisecondsSinceEpoch(rawExpiredAt); + + return CoinInfo( + minAmount: Decimal.parse(json["min_amount"] as String), + maxAmount: Decimal.parse(json["max_amount"] as String), + amount: Decimal.parse(json["amount"] as String), + rate: Decimal.parse(json["rate"] as String), + profit: rawProfit == null ? null : Decimal.tryParse(rawProfit), + withdrawalFee: Decimal.parse(json["withdrawal_fee"] as String), + rateId: json["rate_id"] as String?, + rateIdExpiredAt: expiredAt, + ); + } + + Map toMap() => { + "min_amount": minAmount.toString(), + "max_amount": maxAmount.toString(), + "amount": amount.toString(), + "rate": rate.toString(), + "profit": profit?.toString(), + "withdrawal_fee": withdrawalFee.toString(), + "rate_id": rateId, + "rate_id_expired_at": rateIdExpiredAt?.millisecondsSinceEpoch.toString(), + }; + + @override + String toString() => toMap().toString(); +} diff --git a/lib/services/exchange/lets_exchange/models/coin_v2.dart b/lib/services/exchange/lets_exchange/models/coin_v2.dart new file mode 100644 index 0000000000..6fbf5ec6e0 --- /dev/null +++ b/lib/services/exchange/lets_exchange/models/coin_v2.dart @@ -0,0 +1,105 @@ +class CoinV2 { + CoinV2({ + required this.code, + required this.name, + required this.isActive, + required this.icon, + required this.additionalInfoGet, + required this.additionalInfoSend, + required this.defaultNetworkCode, + required this.defaultNetworkName, + required this.networks, + }); + + final String code; + final String name; + + final bool isActive; + final String icon; + final String? additionalInfoGet; + final String? additionalInfoSend; + final String? defaultNetworkCode; + final String? defaultNetworkName; + final List networks; + + factory CoinV2.fromJson(Map json) => CoinV2( + code: json["code"] as String, + name: json["name"] as String, + isActive: int.parse(json["is_active"].toString()) == 1, + icon: json["icon"] as String? ?? "", + additionalInfoGet: json["additional_info_get"] as String?, + additionalInfoSend: json["additional_info_send"] as String?, + defaultNetworkCode: json["default_network_code"] as String?, + defaultNetworkName: json["default_network_name"] as String?, + networks: (json["networks"] as List) + .map((dynamic e) => CoinNetwork.fromJson(e as Map)) + .toList(), + ); + + Map toMap() => { + "code": code, + "name": name, + "is_active": isActive, + "icon": icon, + "additional_info_get": additionalInfoGet, + "additional_info_send": additionalInfoSend, + "default_network_code": defaultNetworkCode, + "default_network_name": defaultNetworkName, + "networks": networks.map((CoinNetwork e) => e.toMap()).toList(), + }; + + @override + String toString() => toMap().toString(); +} + +class CoinNetwork { + CoinNetwork({ + required this.name, + required this.code, + required this.isActive, + required this.hasExtra, + required this.extraName, + required this.explorer, + required this.contractAddress, + required this.validationAddressRegex, + required this.validationAddressExtraRegex, + }); + + final String name; + final String code; + final bool isActive; + final bool hasExtra; + final String? extraName; + final String? explorer; + final String? contractAddress; + final String? validationAddressRegex; + final String? validationAddressExtraRegex; + + factory CoinNetwork.fromJson(Map json) => CoinNetwork( + name: json["name"] as String, + code: json["code"] as String, + isActive: int.parse(json["is_active"].toString()) == 1, + hasExtra: int.parse(json["has_extra"].toString()) == 1, + extraName: json["extra_name"] as String?, + explorer: json["explorer"] as String?, + contractAddress: json["contract_address"] as String?, + validationAddressRegex: json["validation_address_regex"] as String?, + validationAddressExtraRegex: + json["validation_address_extra_regex"] as String?, + ); + + Map toMap() => { + "name": name, + "code": code, + "is_active": isActive, + "has_extra": hasExtra, + "extra_name": extraName, + "explorer": explorer, + "contract_address": contractAddress, + "validation_address_regex": validationAddressRegex, + "validation_address_extra_regex": validationAddressExtraRegex, + }; + + @override + String toString() => toMap().toString(); +} diff --git a/lib/services/exchange/lets_exchange/models/transaction.dart b/lib/services/exchange/lets_exchange/models/transaction.dart new file mode 100644 index 0000000000..bbc95f141c --- /dev/null +++ b/lib/services/exchange/lets_exchange/models/transaction.dart @@ -0,0 +1,202 @@ +import "package:decimal/decimal.dart"; + +/// A single AML signal returned when a transaction status is `aml_check_failed` +class AmlErrorSignal { + AmlErrorSignal({ + required this.signal, + required this.signalId, + required this.signalPercent, + required this.level, + }); + + final String signal; + final int signalId; + final double signalPercent; + final int level; + + factory AmlErrorSignal.fromJson(Map json) => AmlErrorSignal( + signal: json["signal"] as String, + signalId: json["signalId"] as int, + signalPercent: json["signalPercent"] as double, + level: json["level"] as int, + ); + + Map toMap() => { + "signal": signal, + "signalId": signalId, + "signalPercent": signalPercent, + "level": level, + }; + + @override + String toString() => toMap().toString(); +} + +class Transaction { + Transaction({ + required this.transactionId, + required this.status, + required this.coinFrom, + required this.coinFromName, + required this.coinFromNetwork, + required this.coinTo, + required this.coinToName, + required this.coinToNetwork, + required this.depositAmount, + required this.withdrawalAmount, + required this.realDepositAmount, + required this.realWithdrawalAmount, + required this.deposit, + required this.depositExtraId, + required this.withdrawal, + required this.withdrawalExtraId, + required this.rate, + required this.hashIn, + required this.hashOut, + required this.returnAddress, + required this.returnHash, + required this.returnAmount, + required this.returnExtraId, + required this.isFloat, + required this.coinFromExplorerUrl, + required this.coinToExplorerUrl, + required this.needConfirmations, + required this.confirmations, + required this.executionTime, + required this.profit, + required this.amlErrorSignals, + }); + + final String transactionId; + final String status; + final String coinFrom; + final String coinFromName; + final String coinFromNetwork; + final String coinTo; + final String coinToName; + final String coinToNetwork; + final Decimal depositAmount; + final Decimal withdrawalAmount; + + /// `GET /v1/transaction/{id}` only — received deposit amount. + final Decimal? realDepositAmount; + + /// `GET /v1/transaction/{id}` only — recalculated withdrawal amount. + final Decimal? realWithdrawalAmount; + final String deposit; + final String? depositExtraId; + final String withdrawal; + final String? withdrawalExtraId; + final Decimal rate; + + /// `GET /v1/transaction/{id}` only — incoming transaction hash. + final String? hashIn; + + /// `GET /v1/transaction/{id}` only — outgoing transaction hash. + final String? hashOut; + final String? returnAddress; + final String? returnHash; + final Decimal? returnAmount; + final String? returnExtraId; + final bool isFloat; + final String coinFromExplorerUrl; + final String coinToExplorerUrl; + final int needConfirmations; + + /// `GET /v1/transaction/{id}` only — current number of confirmations. + final int? confirmations; + + /// `GET /v1/transaction/{id}` only — exchange duration in seconds. + final int? executionTime; + + /// `GET /v1/transaction/{id}` only — bonus value in BTC when a promo code + /// was used. + final Decimal? profit; + final List amlErrorSignals; + + factory Transaction.fromJson(Map json) { + final String? rawRealDeposit = json["real_deposit_amount"] as String?; + final String? rawRealWithdrawal = json["real_withdrawal_amount"] as String?; + final num? rawProfit = json["profit"] as num?; + return Transaction( + transactionId: json["transaction_id"] as String, + status: json["status"] as String, + coinFrom: json["coin_from"] as String, + coinFromName: json["coin_from_name"] as String, + coinFromNetwork: json["coin_from_network"] as String, + coinTo: json["coin_to"] as String, + coinToName: json["coin_to_name"] as String, + coinToNetwork: json["coin_to_network"] as String, + depositAmount: Decimal.parse(json["deposit_amount"] as String), + withdrawalAmount: Decimal.parse(json["withdrawal_amount"] as String), + realDepositAmount: rawRealDeposit == null + ? null + : Decimal.tryParse(rawRealDeposit), + realWithdrawalAmount: rawRealWithdrawal == null + ? null + : Decimal.tryParse(rawRealWithdrawal), + deposit: json["deposit"] as String, + depositExtraId: json["deposit_extra_id"] as String?, + withdrawal: json["withdrawal"] as String, + withdrawalExtraId: json["withdrawal_extra_id"] as String?, + rate: Decimal.parse(json["rate"] as String), + hashIn: json["hash_in"] as String?, + hashOut: json["hash_out"] as String?, + returnAddress: json["return"] as String?, + returnHash: json["return_hash"] as String?, + returnAmount: Decimal.tryParse(json["return_amount"] as String? ?? ""), + returnExtraId: json["return_extra_id"] as String?, + isFloat: switch (json["is_float"]) { + final bool value => value, + "true" => true, + _ => false, + }, + coinFromExplorerUrl: json["coin_from_explorer_url"] as String, + coinToExplorerUrl: json["coin_to_explorer_url"] as String, + needConfirmations: json["need_confirmations"] as int, + confirmations: json["confirmations"] as int?, + executionTime: json["execution_time"] as int?, + profit: rawProfit == null ? null : Decimal.parse(rawProfit.toString()), + amlErrorSignals: ((json["aml_error_signals"] as List?) ?? const []) + .map((e) => AmlErrorSignal.fromJson((e as Map).cast())) + .toList(), + ); + } + + Map toMap() => { + "transaction_id": transactionId, + "status": status, + "coin_from": coinFrom, + "coin_from_name": coinFromName, + "coin_from_network": coinFromNetwork, + "coin_to": coinTo, + "coin_to_name": coinToName, + "coin_to_network": coinToNetwork, + "deposit_amount": depositAmount.toString(), + "withdrawal_amount": withdrawalAmount.toString(), + "real_deposit_amount": realDepositAmount?.toString(), + "real_withdrawal_amount": realWithdrawalAmount?.toString(), + "deposit": deposit, + "deposit_extra_id": depositExtraId, + "withdrawal": withdrawal, + "withdrawal_extra_id": withdrawalExtraId, + "rate": rate.toString(), + "hash_in": hashIn, + "hash_out": hashOut, + "return": returnAddress, + "return_hash": returnHash, + "return_amount": returnAmount?.toString(), + "return_extra_id": returnExtraId, + "is_float": isFloat, + "coin_from_explorer_url": coinFromExplorerUrl, + "coin_to_explorer_url": coinToExplorerUrl, + "need_confirmations": needConfirmations, + "confirmations": confirmations, + "execution_time": executionTime, + "profit": profit?.toString(), + "aml_error_signals": amlErrorSignals.map((e) => e.toMap()).toList(), + }; + + @override + String toString() => toMap().toString(); +} diff --git a/lib/services/exchange/nanswap/nanswap_exchange.dart b/lib/services/exchange/nanswap/nanswap_exchange.dart index 2392199e79..a26a35cfb9 100644 --- a/lib/services/exchange/nanswap/nanswap_exchange.dart +++ b/lib/services/exchange/nanswap/nanswap_exchange.dart @@ -138,24 +138,23 @@ class NanswapExchange extends Exchange { } return ExchangeResponse( - value: - response.value! - .where((e) => filter.contains(e.id)) - .map( - (e) => Currency( - exchangeName: exchangeName, - ticker: e.id, - name: e.name, - network: e.network, - image: e.image, - isFiat: false, - rateType: SupportedRateType.estimated, - isStackCoin: AppConfig.isStackCoin(e.id), - tokenContract: null, - isAvailable: true, - ), - ) - .toList(), + value: response.value! + .where((e) => filter.contains(e.id)) + .map( + (e) => Currency( + exchangeName: exchangeName, + ticker: e.id, + name: e.name, + network: e.network, + image: e.image, + isFiat: false, + rateType: SupportedRateType.estimated, + isStackCoin: AppConfig.isStackCoin(e.id), + tokenContract: null, + isAvailable: true, + ), + ) + .toList(), ); } on ExchangeException catch (e) { return ExchangeResponse(exception: e); @@ -391,7 +390,7 @@ class NanswapExchange extends Exchange { uuid: trade.uuid, tradeId: t.id, rateType: trade.rateType, - direction: trade.rateType, + direction: trade.direction, timestamp: trade.timestamp, updatedAt: DateTime.now(), payInCurrency: t.from, diff --git a/lib/services/exchange/trocador/trocador_exchange.dart b/lib/services/exchange/trocador/trocador_exchange.dart index ffb217a9fa..800f921816 100644 --- a/lib/services/exchange/trocador/trocador_exchange.dart +++ b/lib/services/exchange/trocador/trocador_exchange.dart @@ -67,38 +67,37 @@ class TrocadorExchange extends Exchange { Estimate? estimate, required bool reversed, }) async { - final response = - reversed - ? await TrocadorAPI.createNewPaymentRateTrade( - isOnion: false, - rateId: estimate?.rateId, - fromTicker: from.toLowerCase(), - fromNetwork: onlySupportedNetwork, - toTicker: to.toLowerCase(), - toNetwork: onlySupportedNetwork, - toAmount: amount.toString(), - receivingAddress: addressTo, - receivingMemo: null, - refundAddress: addressRefund, - refundMemo: null, - exchangeProvider: estimate!.exchangeProvider!, - isFixedRate: fixedRate, - ) - : await TrocadorAPI.createNewStandardRateTrade( - isOnion: false, - rateId: estimate?.rateId, - fromTicker: from.toLowerCase(), - fromNetwork: onlySupportedNetwork, - toTicker: to.toLowerCase(), - toNetwork: onlySupportedNetwork, - fromAmount: amount.toString(), - receivingAddress: addressTo, - receivingMemo: null, - refundAddress: addressRefund, - refundMemo: null, - exchangeProvider: estimate!.exchangeProvider!, - isFixedRate: fixedRate, - ); + final response = reversed + ? await TrocadorAPI.createNewPaymentRateTrade( + isOnion: false, + rateId: estimate?.rateId, + fromTicker: from.toLowerCase(), + fromNetwork: onlySupportedNetwork, + toTicker: to.toLowerCase(), + toNetwork: onlySupportedNetwork, + toAmount: amount.toString(), + receivingAddress: addressTo, + receivingMemo: null, + refundAddress: addressRefund, + refundMemo: null, + exchangeProvider: estimate!.exchangeProvider!, + isFixedRate: fixedRate, + ) + : await TrocadorAPI.createNewStandardRateTrade( + isOnion: false, + rateId: estimate?.rateId, + fromTicker: from.toLowerCase(), + fromNetwork: onlySupportedNetwork, + toTicker: to.toLowerCase(), + toNetwork: onlySupportedNetwork, + fromAmount: amount.toString(), + receivingAddress: addressTo, + receivingMemo: null, + refundAddress: addressRefund, + refundMemo: null, + exchangeProvider: estimate!.exchangeProvider!, + isFixedRate: fixedRate, + ); if (response.value == null) { return ExchangeResponse(exception: response.exception); @@ -144,23 +143,22 @@ class TrocadorExchange extends Exchange { _cachedCurrencies?.removeWhere((e) => e.network != onlySupportedNetwork); - final value = - _cachedCurrencies - ?.map( - (e) => Currency( - exchangeName: exchangeName, - ticker: e.ticker, - name: e.name, - network: e.network, - image: e.image, - isFiat: false, - rateType: SupportedRateType.both, - isStackCoin: AppConfig.isStackCoin(e.ticker), - tokenContract: null, - isAvailable: true, - ), - ) - .toList(); + final value = _cachedCurrencies + ?.map( + (e) => Currency( + exchangeName: exchangeName, + ticker: e.ticker, + name: e.name, + network: e.network, + image: e.image, + isFiat: false, + rateType: SupportedRateType.both, + isStackCoin: AppConfig.isStackCoin(e.ticker), + tokenContract: null, + isAvailable: true, + ), + ) + .toList(); if (value == null) { return ExchangeResponse( @@ -222,24 +220,23 @@ class TrocadorExchange extends Exchange { bool fixedRate, bool reversed, ) async { - final response = - reversed - ? await TrocadorAPI.getNewPaymentRate( - isOnion: false, - fromTicker: from, - fromNetwork: onlySupportedNetwork, - toTicker: to, - toNetwork: onlySupportedNetwork, - toAmount: amount.toString(), - ) - : await TrocadorAPI.getNewStandardRate( - isOnion: false, - fromTicker: from, - fromNetwork: onlySupportedNetwork, - toTicker: to, - toNetwork: onlySupportedNetwork, - fromAmount: amount.toString(), - ); + final response = reversed + ? await TrocadorAPI.getNewPaymentRate( + isOnion: false, + fromTicker: from, + fromNetwork: onlySupportedNetwork, + toTicker: to, + toNetwork: onlySupportedNetwork, + toAmount: amount.toString(), + ) + : await TrocadorAPI.getNewStandardRate( + isOnion: false, + fromTicker: from, + fromNetwork: onlySupportedNetwork, + toTicker: to, + toNetwork: onlySupportedNetwork, + fromAmount: amount.toString(), + ); if (response.value == null) { return ExchangeResponse(exception: response.exception); @@ -249,8 +246,11 @@ class TrocadorExchange extends Exchange { final List cOrLowerQuotes = []; for (final quote in response.value!.quotes) { + final provider = quote.provider.toLowerCase(); if (quote.fixed == fixedRate && - quote.provider.toLowerCase() != "changenow") { + provider != "changenow" && + provider != "letsexchange" && + provider != "exolix") { final rating = quote.kycRating.toLowerCase(); if (rating == "a" || rating == "b") { estimates.add( @@ -288,9 +288,8 @@ class TrocadorExchange extends Exchange { } return ExchangeResponse( - value: - estimates - ..sort((a, b) => b.estimatedAmount.compareTo(a.estimatedAmount)), + value: estimates + ..sort((a, b) => b.estimatedAmount.compareTo(a.estimatedAmount)), ); } diff --git a/lib/services/exchange/wizard_swap/wizard_swap_api.dart b/lib/services/exchange/wizard_swap/wizard_swap_api.dart new file mode 100644 index 0000000000..9e19e1ffae --- /dev/null +++ b/lib/services/exchange/wizard_swap/wizard_swap_api.dart @@ -0,0 +1,191 @@ +import 'dart:convert'; + +import 'package:decimal/decimal.dart'; + +import '../../../app_config.dart'; +import '../../../networking/http.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/prefs.dart'; +import '../../tor_service.dart'; + +abstract class WizardSwapApi { + static const _client = HTTP(); + static const baseUrl = "https://www.wizardswap.io/api"; + + static Uri _getUri(String endpoint) => Uri.parse("$baseUrl$endpoint"); + + static Future _makeGetRequest(Uri uri) async { + int code = -1; + try { + final response = await _client.get( + url: uri, + headers: {'Accept': 'application/json'}, + proxyInfo: !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ); + + code = response.code; + + if (code != 200) { + throw Exception( + "WizardSwapApi GET failed CODE=$code, response body=${response.body}", + ); + } + + return response.body; + } catch (e, s) { + Logging.instance.e("rethrowing", error: e, stackTrace: s); + rethrow; + } + } + + static Future _makePostRequest( + Uri uri, + Map body, + ) async { + int code = -1; + try { + final response = await _client.post( + url: uri, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode(body), + proxyInfo: !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ); + + code = response.code; + + if (code != 200) { + throw Exception( + "WizardSwapApi POST failed CODE=$code, body=${response.body}", + ); + } + + return response.body; + } catch (e, s) { + Logging.instance.e("rethrowing", error: e, stackTrace: s); + rethrow; + } + } + + static Map _decode(dynamic map) { + if (map is! Map) { + throw Exception( + "Expected a `Map`, but found a `${map.runtimeType}: $map", + ); + } + + try { + return Map.from(map); + } catch (_) { + Logging.instance.e("$map is NOT Map"); + rethrow; + } + } + + static Future>> getCurrencies() async { + final body = await _makeGetRequest(_getUri("/currency")); + final data = jsonDecode(body); + if (data is! List) { + throw Exception("$body is not a json list!"); + } + + return data.map(_decode).toList(); + } + + /// [symbol] should be lowercase. Example: btc + static Future> getCurrencyInfo(String symbol) async { + final body = await _makeGetRequest(_getUri("/currency/$symbol")); + return _decode(jsonDecode(body)); + } + + static Future getExchange(String id) async { + final body = await _makeGetRequest(_getUri("/exchange/$id")); + return _decode(jsonDecode(body)); + } + + static Future postEstimate( + String from, + String to, + Decimal fromAmount, + String apiKey, + ) async { + final body = await _makePostRequest(_getUri("/estimate"), { + "currency_from": from, + "currency_to": to, + "amount_from": fromAmount, + "api_key": apiKey, + }); + + final map = _decode(jsonDecode(body)); + + // sometimes this json value will contain an error message lol... + final amount = Decimal.tryParse(map["estimated_amount"].toString()); + if (amount == null) { + throw Exception(map["estimated_amount"]); + } + + return WzEstimate( + from: from, + to: to, + amountFrom: fromAmount, + amountTo: amount, + ); + } + + static Future postExchange( + String from, + String to, + String toAddress, + Decimal fromAmount, + String refundAddress, + String? toExtraId, + String? refundExtraId, + String apiKey, + ) async { + final body = await _makePostRequest(_getUri("/exchange"), { + "currency_from": from, + "currency_to": to, + "address_to": toAddress, + "amount_from": fromAmount, + "refund_address": refundAddress, + if (toExtraId != null) "extra_id_to": toExtraId, + if (refundExtraId != null) "refund_extra_id": refundExtraId, + "api_key": apiKey, + }); + return _decode(jsonDecode(body)); + } +} + +final class WzEstimate { + final String from; + final String to; + final Decimal amountFrom; + final Decimal amountTo; + + WzEstimate({ + required this.from, + required this.to, + required this.amountFrom, + required this.amountTo, + }); + + @override + String toString() { + return 'WzEstimate {' + 'from: $from, ' + 'to: $to, ' + 'amountFrom: $amountFrom, ' + 'amountTo: $amountTo ' + '}'; + } +} diff --git a/lib/services/exchange/wizard_swap/wizard_swap_exchange.dart b/lib/services/exchange/wizard_swap/wizard_swap_exchange.dart new file mode 100644 index 0000000000..b73f9944c3 --- /dev/null +++ b/lib/services/exchange/wizard_swap/wizard_swap_exchange.dart @@ -0,0 +1,327 @@ +import 'package:decimal/decimal.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../app_config.dart'; +import '../../../exceptions/exchange/exchange_exception.dart'; +import '../../../external_api_keys.dart'; +import '../../../models/exchange/response_objects/estimate.dart'; +import '../../../models/exchange/response_objects/range.dart'; +import '../../../models/exchange/response_objects/trade.dart'; +import '../../../models/isar/exchange_cache/currency.dart'; +import '../exchange.dart'; +import '../exchange_response.dart'; +import 'wizard_swap_api.dart'; + +class WizardSwapExchange extends Exchange { + WizardSwapExchange._(); + + static WizardSwapExchange? _instance; + static WizardSwapExchange get instance => + _instance ??= WizardSwapExchange._(); + + static const exchangeName = "Wizard Swap"; + + @override + String get name => exchangeName; + + @override + Future> createTrade({ + required String from, + required String to, + required String? fromNetwork, + required String? toNetwork, + required Decimal amount, + required String addressTo, + String? extraId, + required String addressRefund, + required String refundExtraId, + Estimate? estimate, + bool fixedRate = false, + bool reversed = false, + }) async { + try { + if (reversed) { + throw ExchangeException( + "$runtimeType does not support reversed trades", + ExchangeExceptionType.generic, + ); + } + if (fixedRate) { + throw ExchangeException( + "$runtimeType does not support fixedRate trades", + ExchangeExceptionType.generic, + ); + } + + final json = await WizardSwapApi.postExchange( + from, + to, + addressTo, + amount, + addressRefund, + extraId, + refundExtraId, + kWizSwapApiKey, + ); + + // since the wizard swap api is somewhat lacking we'll make some + // assumptions regarding date + final timestamp = DateTime.parse( + "${(json["timestamp"] as String).replaceFirst(" ", "T")}Z", + ); + + final trade = Trade( + uuid: const Uuid().v1(), + tradeId: json["id"] as String, + rateType: "estimated", + direction: "normal", + timestamp: timestamp, + updatedAt: timestamp, + payInCurrency: from, + payInAmount: json["expected_amount"] as String, + payInAddress: json["address_from"] as String, + payInNetwork: from, // need something here... + payInExtraId: json["extra_id_from"] as String, + payInTxid: json["tx_from"] as String, + payOutCurrency: to, + payOutAmount: json["amount_to"] as String, + payOutAddress: json["address_to"] as String, + payOutNetwork: to, // need something here... + payOutExtraId: json["extra_id_to"] as String, + payOutTxid: json["tx_to"] as String, + refundAddress: json["refund_address"] as String? ?? addressRefund, + refundExtraId: refundExtraId, + status: json["status"] as String? ?? "unknown", + exchangeName: exchangeName, + ); + + return ExchangeResponse(value: trade); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future>> getAllCurrencies( + bool fixedRate, + ) async { + try { + if (fixedRate) { + throw ExchangeException( + "$runtimeType does not support fixedRate", + ExchangeExceptionType.generic, + ); + } + + final response = await WizardSwapApi.getCurrencies(); + + final List result = []; + + for (final json in response) { + final ticker = json["symbol"] as String; + + // lol why do we even have to do this??? There is less info returned + // by this call than in the json response for all currencies???????? + final info = await WizardSwapApi.getCurrencyInfo(ticker); + + final currency = Currency( + exchangeName: exchangeName, + ticker: json["symbol"] as String, + name: json["name"] as String, + network: json["parent_symbol"] as String? ?? ticker, + image: info["image"] as String, + isFiat: false, + rateType: .estimated, + isStackCoin: AppConfig.isStackCoin(ticker), + tokenContract: null, + isAvailable: json["enabled"] == 1, + ); + + result.add(currency); + } + + return ExchangeResponse(value: result); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future>> getEstimates( + String from, + String? fromNetwork, + String to, + String? toNetwork, + Decimal amount, + bool fixedRate, + bool reversed, + ) async { + try { + if (reversed) { + throw ExchangeException( + "$runtimeType does not support reversed trades", + ExchangeExceptionType.generic, + ); + } + if (fixedRate) { + throw ExchangeException( + "$runtimeType does not support fixedRate trades", + ExchangeExceptionType.generic, + ); + } + + final response = await WizardSwapApi.postEstimate( + from, + to, + amount, + kWizSwapApiKey, + ); + + final estimate = Estimate( + estimatedAmount: response.amountTo, + fixedRate: fixedRate, + reversed: reversed, + exchangeProvider: exchangeName, + ); + + return ExchangeResponse(value: [estimate]); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> getRange( + String from, + String? fromNetwork, + String to, + String? toNetwork, + bool fixedRate, + ) async { + try { + if (fixedRate) { + throw ExchangeException( + "$runtimeType does not support fixedRate trades", + ExchangeExceptionType.generic, + ); + } + + /// lol ???? + final all = await WizardSwapApi.getCurrencies(); + final coin = all.firstWhere( + (e) => + e["symbol"].toString().toLowerCase() == + from.toString().toLowerCase(), + ); + + return ExchangeResponse( + value: Range(min: Decimal.tryParse(coin["minamt"].toString())), + ); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> getTrade(String tradeId) async { + try { + throw UnimplementedError("Not currently used in this app"); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future>> getTrades() async { + try { + throw UnimplementedError("Not currently used in this app"); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> updateTrade(Trade trade) async { + try { + final json = await WizardSwapApi.getExchange(trade.tradeId); + + final updated = Trade( + uuid: trade.uuid, + tradeId: trade.tradeId, + rateType: trade.rateType, + direction: trade.direction, + timestamp: trade.timestamp, + updatedAt: DateTime.now(), + payInCurrency: trade.payInCurrency, + payInAmount: json["expected_amount"] as String, + payInAddress: json["address_from"] as String, + payInNetwork: trade.payInNetwork, + payInExtraId: json["extra_id_from"] as String, + payInTxid: json["tx_from"] as String, + payOutCurrency: trade.payOutCurrency, + payOutAmount: json["amount_to"] as String, + payOutAddress: json["address_to"] as String, + payOutNetwork: trade.payOutNetwork, + payOutExtraId: json["extra_id_to"] as String, + payOutTxid: json["tx_to"] as String, + refundAddress: json["refund_address"] as String? ?? trade.refundAddress, + refundExtraId: trade.refundExtraId, + status: json["status"] as String? ?? "unknown", + exchangeName: exchangeName, + ); + + return ExchangeResponse(value: updated); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } +} diff --git a/lib/services/mwebd_service.dart b/lib/services/mwebd_service.dart index 2462257505..10ad475318 100644 --- a/lib/services/mwebd_service.dart +++ b/lib/services/mwebd_service.dart @@ -6,6 +6,7 @@ import 'dart:math'; import 'package:mutex/mutex.dart'; import 'package:mweb_client/mweb_client.dart'; +import '../utilities/dynamic_object.dart'; import '../utilities/logger.dart'; import '../utilities/prefs.dart'; import '../utilities/stack_file_system.dart'; @@ -24,10 +25,7 @@ final class MwebdService { CryptoCurrencyNetwork.test4 => throw UnimplementedError(), }; - final Map< - CryptoCurrencyNetwork, - ({OpaqueMwebdServer server, MwebClient client}) - > + final Map _map = {}; late final StreamSubscription @@ -178,9 +176,9 @@ final class MwebdService { } /// Get server status. Returns null if no server was initialized. - Future getServerStatus(CryptoCurrencyNetwork net) { - return _updateLock.protect(() { - return mwebdServerInterface.getServerStatus(_map[net]?.server); + Future getServerStatus(CryptoCurrencyNetwork net) { + return _updateLock.protect(() async { + return _map[net]?.client.status(StatusRequest()); }); } @@ -205,11 +203,15 @@ final class MwebdService { "${Platform.pathSeparator}logs" "${Platform.pathSeparator}debug.log"; + final file = File(path); + + if (await file.exists()) { + offset = await file.length(); + } + Future poll() async { if (!controller.isClosed) { - final file = File(path); - - if (!file.existsSync()) { + if (!(await file.exists())) { return; } diff --git a/lib/services/node_service.dart b/lib/services/node_service.dart index c1bc338b37..6a83299119 100644 --- a/lib/services/node_service.dart +++ b/lib/services/node_service.dart @@ -15,7 +15,9 @@ import 'package:http/http.dart'; import '../app_config.dart'; import '../db/hive/db.dart'; +import '../models/epicbox_server_model.dart'; import '../models/node_model.dart'; +import '../utilities/default_epicboxes.dart'; import '../utilities/default_nodes.dart'; import '../utilities/flutter_secure_storage_interface.dart'; import '../utilities/logger.dart'; @@ -166,15 +168,14 @@ class NodeService extends ChangeNotifier { } List getNodesFor(CryptoCurrency coin) { - final list = - DB.instance - .values(boxName: DB.boxNameNodeModels) - .where( - (e) => - e.coinName == coin.identifier && - !e.id.startsWith(DefaultNodes.defaultNodeIdPrefix), - ) - .toList(); + final list = DB.instance + .values(boxName: DB.boxNameNodeModels) + .where( + (e) => + e.coinName == coin.identifier && + !e.id.startsWith(DefaultNodes.defaultNodeIdPrefix), + ) + .toList(); // add default to end of list list.addAll( @@ -270,8 +271,10 @@ class NodeService extends ChangeNotifier { bool enabled, bool shouldNotifyListeners, ) async { - final model = - DB.instance.get(boxName: DB.boxNameNodeModels, key: id)!; + final model = DB.instance.get( + boxName: DB.boxNameNodeModels, + key: id, + )!; await DB.instance.put( boxName: DB.boxNameNodeModels, key: model.id, @@ -286,6 +289,103 @@ class NodeService extends ChangeNotifier { } } + //============================================================================ + // Epic Box server management + //============================================================================ + + Future updateDefaultEpicBoxes() async { + // final primaryEpicBox = getPrimaryEpicBox(); + // + // for (final defaultEpicBox in DefaultEpicBoxes.all) { + // final savedEpicBox = DB.instance.get( + // boxName: DB.boxNameEpicBoxModels, + // key: defaultEpicBox.id, + // ); + // if (savedEpicBox == null) { + // await DB.instance.put( + // boxName: DB.boxNameEpicBoxModels, + // key: defaultEpicBox.id, + // value: defaultEpicBox, + // ); + // } else { + // await DB.instance.put( + // boxName: DB.boxNameEpicBoxModels, + // key: savedEpicBox.id, + // value: defaultEpicBox.copyWith(enabled: savedEpicBox.enabled), + // ); + // } + // + // if (primaryEpicBox != null && primaryEpicBox.id == defaultEpicBox.id) { + // await setPrimaryEpicBox( + // epicBox: defaultEpicBox.copyWith(enabled: primaryEpicBox.enabled), + // ); + // } + // } + + // set default primary if none exists + if (getPrimaryEpicBox() == null) { + await setPrimaryEpicBox(epicBox: DefaultEpicBoxes.defaultEpicBoxServer); + } + } + + Future setPrimaryEpicBox({ + required EpicBoxServerModel epicBox, + bool shouldNotifyListeners = false, + }) async { + await DB.instance.put( + boxName: DB.boxNamePrimaryEpicBox, + key: 'primary', + value: epicBox, + ); + if (shouldNotifyListeners) { + notifyListeners(); + } + } + + EpicBoxServerModel? getPrimaryEpicBox() { + return DB.instance.get( + boxName: DB.boxNamePrimaryEpicBox, + key: 'primary', + ); + } + + List getEpicBoxes() { + return DB.instance + .values(boxName: DB.boxNameEpicBoxModels) + .toList(); + } + + EpicBoxServerModel? getEpicBoxById({required String id}) { + return DB.instance.get( + boxName: DB.boxNameEpicBoxModels, + key: id, + ); + } + + Future addEpicBox( + EpicBoxServerModel epicBox, + bool shouldNotifyListeners, + ) async { + await DB.instance.put( + boxName: DB.boxNameEpicBoxModels, + key: epicBox.id, + value: epicBox, + ); + if (shouldNotifyListeners) { + notifyListeners(); + } + } + + Future deleteEpicBox(String id, bool shouldNotifyListeners) async { + await DB.instance.delete( + boxName: DB.boxNameEpicBoxModels, + key: id, + ); + if (shouldNotifyListeners) { + notifyListeners(); + } + } + //============================================================================ Future updateCommunityNodes() async { diff --git a/lib/services/notifications_api.dart b/lib/services/notifications_api.dart index 13dec90d71..4263c951f1 100644 --- a/lib/services/notifications_api.dart +++ b/lib/services/notifications_api.dart @@ -13,6 +13,7 @@ import 'dart:async'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import '../models/notification_model.dart'; +import '../utilities/logger.dart'; import '../utilities/prefs.dart'; import 'notifications_service.dart'; @@ -24,7 +25,8 @@ abstract final class NotificationApi { static Future _notificationDetails() async { return const NotificationDetails( android: AndroidNotificationDetails( - 'channel id', 'channel name', + 'channel id', + 'channel name', channelDescription: 'channel description', // importance: Importance.max, priority: Priority.high, @@ -84,6 +86,23 @@ abstract final class NotificationApi { static late Prefs prefs; static late NotificationsService notificationsService; + static Future _showOsNotification({ + required String title, + required String body, + String? payload, + }) async { + await init(); + final id = await prefs.incrementCurrentNotificationIndex(); + await _notifications.show( + id, + title, + body, + await _notificationDetails(), + payload: payload, + ); + return id; + } + static Future showNotification({ required String title, required String body, @@ -98,9 +117,11 @@ abstract final class NotificationApi { String? changeNowId, String? payload, }) async { - await init(); - await prefs.incrementCurrentNotificationIndex(); - final id = prefs.currentNotificationId; + final id = await _showOsNotification( + title: title, + body: body, + payload: payload, + ); String confirms = ""; if (txid != null && @@ -123,15 +144,21 @@ abstract final class NotificationApi { changeNowId: changeNowId, ); - await Future.wait([ - _notifications.show( - id, - title, - body, - await _notificationDetails(), - payload: payload, - ), - notificationsService.add(model, true), - ]); + await notificationsService.add(model, true); + } + + static Future showLocalOnly({ + required String title, + required String body, + }) async { + try { + await _showOsNotification(title: title, body: body); + } catch (e, s) { + Logging.instance.w( + "NotificationApi.showLocalOnly failed", + error: e, + stackTrace: s, + ); + } } } diff --git a/lib/services/ord_api.dart b/lib/services/ord_api.dart new file mode 100644 index 0000000000..79800860fd --- /dev/null +++ b/lib/services/ord_api.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../app_config.dart'; +import '../networking/http.dart'; +import '../utilities/prefs.dart'; +import 'tor_service.dart'; + +class OrdAPI { + final String baseUrl; + final HTTP _client = const HTTP(); + + OrdAPI({required this.baseUrl}); + + static const _jsonHeaders = {'Accept': 'application/json'}; + + ({InternetAddress host, int port})? get _proxyInfo => + !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null; + + /// Check an output for inscriptions. + /// Returns the list of inscription IDs found on the output, or empty list. + Future> getInscriptionIdsForOutput(String txid, int vout) async { + final response = await _client.get( + url: Uri.parse('$baseUrl/output/$txid:$vout'), + headers: _jsonHeaders, + proxyInfo: _proxyInfo, + ); + + if (response.code != 200) { + throw Exception( + 'OrdAPI getInscriptionIdsForOutput failed: ' + 'status=${response.code}', + ); + } + + final json = jsonDecode(response.body) as Map; + final inscriptions = json['inscriptions'] as List?; + + if (inscriptions == null || inscriptions.isEmpty) { + return []; + } + + return inscriptions.cast(); + } + + /// Fetch full inscription metadata by ID. + Future> getInscriptionData(String inscriptionId) async { + final response = await _client.get( + url: Uri.parse('$baseUrl/inscription/$inscriptionId'), + headers: _jsonHeaders, + proxyInfo: _proxyInfo, + ); + + if (response.code != 200) { + throw Exception( + 'OrdAPI getInscriptionData failed: ' + 'status=${response.code}', + ); + } + + return jsonDecode(response.body) as Map; + } + + /// Build the content URL for an inscription. + String contentUrl(String inscriptionId) => '$baseUrl/content/$inscriptionId'; +} diff --git a/lib/services/price.dart b/lib/services/price.dart index ee08952eeb..7af1ec2ba8 100644 --- a/lib/services/price.dart +++ b/lib/services/price.dart @@ -159,7 +159,19 @@ class PriceAPI { for (final map in coinGeckoData) { final String coinName = map["name"] as String; - final coin = AppConfig.getCryptoCurrencyByPrettyName(coinName); + late CryptoCurrency coin; + try { + coin = AppConfig.getCryptoCurrencyByPrettyName( + coinName == "Factor" ? "Fact0rn" : coinName, + ); + } catch (e, s) { + Logging.instance.e( + "Failed to find matching app coin for $coinName. Moving on", + error: e, + stackTrace: s, + ); + continue; + } try { final price = Decimal.parse(map["current_price"].toString()); @@ -190,7 +202,7 @@ class PriceAPI { static Future?> availableBaseCurrencies() async { final externalCalls = Prefs.instance.externalCalls; - final HTTP client = HTTP(); + const client = HTTP(); if ((!Util.isTestEnv && !externalCalls) || !(await Prefs.instance.isExternalCallsSet())) { @@ -288,4 +300,98 @@ class PriceAPI { return tokenPrices; } } + + /// Get prices and 24h change for Solana SOL tokens. + /// + /// Uses CoinGecko API to fetch prices for tokens by their Solana mint addresses. + /// Format: GET /api/v3/simple/token_price/solana?vs_currencies=usd&contract_addresses=mint1,mint2&include_24hr_change=true + Future> + getPricesAnd24hChangeForSolTokens({ + required Set contractAddresses, + required String baseCurrency, + }) async { + final Map tokenPrices = {}; + + if (AppConfig.coins.whereType().isEmpty || + contractAddresses.isEmpty) { + return tokenPrices; + } + + final externalCalls = Prefs.instance.externalCalls; + if ((!Util.isTestEnv && !externalCalls) || + !(await Prefs.instance.isExternalCallsSet())) { + Logging.instance.i("User does not want to use external calls"); + return tokenPrices; + } + + try { + // requires API key + + // // Build comma-separated list of mint addresses. + // final mintsParam = contractAddresses.join(','); + // final uri = Uri.parse( + // "https://api.coingecko.com/api/v3/simple/token_price/solana" + // "?vs_currencies=${baseCurrency.toLowerCase()}" + // "&contract_addresses=$mintsParam" + // "&include_24hr_change=true", + // ); + // + // final coinGeckoResponse = await client.get( + // url: uri, + // headers: {'Content-Type': 'application/json'}, + // proxyInfo: Prefs.instance.useTor + // ? TorService.sharedInstance.getProxyInfo() + // : null, + // ); + // + // if (coinGeckoResponse.code == 200) { + // try { + // final coinGeckoData = jsonDecode(coinGeckoResponse.body) as Map; + // + // for (final mint in contractAddresses) { + // final map = coinGeckoData[mint.toLowerCase()] as Map?; + // if (map != null) { + // try { + // final price = Decimal.parse( + // map[baseCurrency.toLowerCase()].toString(), + // ); + // final change24h = double.parse( + // map["${baseCurrency.toLowerCase()}_24h_change"].toString(), + // ); + // + // tokenPrices[mint.toLowerCase()] = ( + // value: price, + // change24h: change24h, + // ); + // } catch (e) { + // // only log the error as we don't want to interrupt the rest of the loop + // Logging.instance.w( + // "getPricesAnd24hChangeForSolTokens($baseCurrency,$mint): Failed to parse price data: $e", + // ); + // } + // } + // } + // } catch (e, s) { + // // only log the error as we don't want to interrupt the rest of the loop + // Logging.instance.w( + // "getPricesAnd24hChangeForSolTokens($baseCurrency): Error parsing response: $e\n$s\nRESPONSE: ${coinGeckoResponse.body}", + // ); + // } + // } else { + // Logging.instance.w( + // "getPricesAnd24hChangeForSolTokens($baseCurrency): HTTP ${coinGeckoResponse.code}", + // ); + // } + + return tokenPrices; + } catch (e, s) { + Logging.instance.e( + "getPricesAnd24hChangeForSolTokens($baseCurrency,$contractAddresses): ", + error: e, + stackTrace: s, + ); + // return previous cached values + return tokenPrices; + } + } } diff --git a/lib/services/price_service.dart b/lib/services/price_service.dart index 51fa4fab43..4cbf27d864 100644 --- a/lib/services/price_service.dart +++ b/lib/services/price_service.dart @@ -25,6 +25,9 @@ class PriceService extends ChangeNotifier { Future> get tokenContractAddressesToCheck async => (await MainDB.instance.getEthContracts().addressProperty().findAll()) .toSet(); + Future> get solTokenContractAddressesToCheck async => + (await MainDB.instance.getSolContracts().addressProperty().findAll()) + .toSet(); final Duration updateInterval = const Duration(seconds: 60); Timer? _timer; @@ -73,6 +76,22 @@ class PriceService extends ChangeNotifier { } } + final _solTokenContractAddressesToCheck = await solTokenContractAddressesToCheck; + + if (_solTokenContractAddressesToCheck.isNotEmpty) { + final solTokenPriceMap = await _priceAPI.getPricesAnd24hChangeForSolTokens( + contractAddresses: _solTokenContractAddressesToCheck, + baseCurrency: baseTicker, + ); + + for (final map in solTokenPriceMap.entries) { + if (_cachedTokenPrices[map.key] != map.value) { + _cachedTokenPrices[map.key] = map.value; + shouldNotify = true; + } + } + } + if (shouldNotify) { notifyListeners(); } diff --git a/lib/services/shopinbit/shopinbit_api.dart b/lib/services/shopinbit/shopinbit_api.dart new file mode 100644 index 0000000000..fd1f12c47c --- /dev/null +++ b/lib/services/shopinbit/shopinbit_api.dart @@ -0,0 +1,7 @@ +export 'src/client.dart'; +export 'src/token_manager.dart'; +export 'src/api_response.dart'; +export 'src/api_exception.dart'; +export 'src/webhook_verifier.dart'; +export 'src/endpoints.dart'; +export 'src/models/models.dart'; diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart new file mode 100644 index 0000000000..d8e49e09fe --- /dev/null +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -0,0 +1,597 @@ +import "dart:async"; +import "dart:io"; + +import "package:drift/drift.dart"; +import "package:flutter/foundation.dart"; + +import "../../db/drift/shared_db/shared_database.dart"; +import "../../db/drift/shared_db/tables/notifications.dart"; +import "../../models/shopinbit/shopinbit_enums.dart"; +import "../../utilities/logger.dart"; +import "../notifications_api.dart"; +import "src/api_response.dart"; +import "src/client.dart"; +import "src/models/message.dart"; +import "src/models/ticket.dart"; + +/// Display name sent to ShopinBit as `customer_pseudonym`. +const String kShopInBitCustomerPseudonym = "Satoshi"; + +/// A refresh currently in flight for one ticket. [forced] records whether it +/// will (re)fetch the message list, so a later forced caller knows whether it +/// can safely piggy-back on this one or must run its own forced refresh. +class _InFlightRefresh { + const _InFlightRefresh(this.completer, this.forced); + final Completer completer; + final bool forced; +} + +class ShopInBitService { + ShopInBitService({required this.client, required this.db}); + + final ShopInBitClient client; + final SharedDatabase db; + + final Map _inFlight = {}; + + /// The ticket whose conversation is currently on screen, if any. Kept in + /// sync by the ticket-detail view with its actual visibility (topmost route, + /// app foregrounded). A new reply for it skips the system notification and + /// is recorded already-read — the user is looking right at it. + int? viewingTicketId; + + // -- Customer key -- + + /// Returns the most-recently-used customer key. Generates a new one if + /// the DB has no settings yet. Always leaves [client] pointing at the + /// returned key. + Future ensureCustomerKey() async { + final ShopInBitSetting? current = await db.shopInBitSettingsDao + .getCurrentSettings(); + if (current != null) { + await db.shopInBitSettingsDao.touch(current.customerKey); + return current.customerKey; + } + return generateCustomerKey(); + } + + Future generateCustomerKey() async { + final ApiResponse resp = await client.generateKey(); + return useCustomerKey(resp.valueOrThrow); + } + + Future recoverCustomerKey(String key) => useCustomerKey(key); + + /// Switch the active customer key. Tickets for OTHER customer keys stay + /// in the DB — switching is just a header change plus an upsert into + /// settings. The UI filters tickets by the active key. + Future useCustomerKey(String key) async { + await db.shopInBitSettingsDao.upsert(key); + return key; + } + + // -- Refresh -- + + /// Refresh every ticket the API reports for the current customer key. + /// New tickets are hydrated and inserted; existing tickets are patched. + Future refreshAll() async { + final String key = await ensureCustomerKey(); + final ApiResponse> resp = await client.getTicketsByCustomer( + key, + ); + if (resp.hasError || resp.value == null) { + Logging.instance.w( + "ShopInBitService.refreshAll: failed to fetch ticket list", + error: resp.exception, + ); + return; + } + await Future.wait( + resp.value! + .where((e) => !e.isKnownReceipt) + .map((ref) => _refreshRef(ref, key, false)), + ); + } + + /// Refresh a single ticket. The row must already exist; use this for + /// polling and post-action refreshes. For an unknown ticket id, call + /// [refreshAll] (which has the customer-key context needed to insert). + Future refreshOne( + int apiTicketId, { + bool forceUpdateMessages = false, + }) async { + final ShopInBitTicket? existing = await db.shopInBitTicketsDao.getByApiId( + apiTicketId, + ); + if (existing == null) return; + await _refreshRef( + TicketRef(id: existing.apiTicketId, number: existing.ticketNumber), + existing.customerKey, + forceUpdateMessages, + ); + } + + // -- Actions -- + + /// Create a new ticket. We know every required field at this point + /// (they're the inputs we just sent), so the DB row is inserted + /// synchronously with full provenance data and an empty conversation; + /// dynamic fields are then patched in by a background refresh. + Future createRequest({ + required ShopInBitCategory category, + required String comment, + required String deliveryCountry, + required String? deliveryState, + String? voucherCode, + }) async { + final String key = await ensureCustomerKey(); + final ApiResponse resp = await client.createRequest( + customerPseudonym: kShopInBitCustomerPseudonym, + externalCustomerKey: key, + serviceType: category.apiValue, + comment: comment, + deliveryCountry: deliveryCountry, + deliveryState: deliveryState, + voucherCode: voucherCode, + ); + if (resp.hasError || resp.value == null) return null; + final TicketRef ref = resp.value!; + + const ticketState = TicketState.newTicket; + await db.shopInBitTicketsDao.insertTicket( + ShopInBitTicketsCompanion.insert( + apiTicketId: ref.id, + customerKey: key, + ticketNumber: ref.number, + category: category, + requestDescription: comment, + deliveryCountry: deliveryCountry, + status: ShopInBitOrderStatus.fromTicketState(ticketState)!, + statusRaw: ticketState.value, + ), + ); + + unawaited(refreshOne(ref.id)); + return ref; + } + + Future sendMessage( + int apiTicketId, + String message, + String customerKey, { + List? attachments, + }) async { + final ApiResponse> resp = + attachments != null && attachments.isNotEmpty + ? await client.sendAttachments( + apiTicketId, + message: message, + customerKey: customerKey, + attachments: attachments, + ) + : await client.sendMessage( + apiTicketId, + message, + customerKey: customerKey, + ); + if (resp.hasError) return false; + unawaited(refreshOne(apiTicketId, forceUpdateMessages: true)); + return true; + } + + /// Mark a ticket read, so it stops surfacing as unread. Read state is + /// local-only and never round-trips to the API. Also clears the ticket's + /// notification rows so the bell/feed drop it in step with the dot. + /// + /// The recorded read time is clamped up to the ticket's own + /// `lastAgentMessageAt`: unread is derived by comparing that (server-set) + /// timestamp against lastReadAt, so a client clock lagging real time would + /// otherwise write a read time earlier than the reply and leave the dot lit + /// after the user has plainly read it. All times are UTC. + /// + /// Best-effort: callers fire-and-forget (poll loop, view dispose), so a DB + /// failure is logged, not thrown. + Future markTicketRead(int apiTicketId) async { + try { + final ticket = await db.shopInBitTicketsDao.getByApiId(apiTicketId); + final DateTime now = DateTime.now().toUtc(); + final DateTime? lastAgent = ticket?.lastAgentMessageAt?.toUtc(); + final DateTime readAt = (lastAgent != null && lastAgent.isAfter(now)) + ? lastAgent + : now; + await db.shopInBitTicketsDao.markRead(apiTicketId, readAt); + await db.appNotificationsDao.markReadByTarget( + AppNotificationType.shopinbit, + "$apiTicketId", + ); + } catch (e, s) { + Logging.instance.w( + "ShopInBitService.markTicketRead failed", + error: e, + stackTrace: s, + ); + } + } + + /// Mark the active customer key's ShopinBit notifications read — called when + /// the user views the notifications list, so the bell/feed clear like the + /// wallet notifications do. Per-ticket dots (lastReadAt) are untouched. + /// + /// Best-effort: callers fire-and-forget from view dispose, so a DB failure + /// is logged, not thrown as an unhandled async error. + Future markAllNotificationsRead() async { + try { + final settings = await db.shopInBitSettingsDao.getCurrentSettings(); + if (settings == null) return; + await db.appNotificationsDao.markAllRead( + type: AppNotificationType.shopinbit, + scopeId: settings.customerKey, + ); + } catch (e, s) { + Logging.instance.w( + "ShopInBitService.markAllNotificationsRead failed", + error: e, + stackTrace: s, + ); + } + } + + // -- Internals -- + + /// Hydrate-or-update one ticket. Branches on whether the row already + /// exists: existing rows get a partial patch, brand-new rows are only + /// inserted if /full, /status, and /messages all succeed (no empty + /// placeholder rows). + /// + /// Concurrent calls for the same ticket id are coalesced onto the + /// in-flight refresh — later callers await the same completer rather + /// than kicking off a second round-trip. + Future _refreshRef( + TicketRef ref, + String customerKey, + bool forceUpdateMessages, + ) { + final int id = ref.id; + + final _InFlightRefresh? pending = _inFlight[id]; + if (pending != null) { + // Join the in-flight refresh only if it will do what we need. An unforced + // caller is always satisfied; a forced caller is satisfied only if the + // in-flight refresh is itself forced (it will fetch messages too). + // Otherwise joining would silently drop our force, so wait for the + // in-flight one to settle and then run our own forced refresh — a + // just-sent message MUST actually be fetched, or the view removes its + // optimistic bubble and the message vanishes until the next refresh. + if (pending.forced || !forceUpdateMessages) { + return pending.completer.future; + } + return pending.completer.future.then( + (_) => _refreshRef(ref, customerKey, true), + onError: (_, _) => _refreshRef(ref, customerKey, true), + ); + } + + final Completer completer = Completer(); + _inFlight[id] = _InFlightRefresh(completer, forceUpdateMessages); + + // Fire-and-forget: _runRefresh should never throw (it routes errors through + // the completer), so the unawaited future is safe. Every caller — + // including the first — awaits the completer, guaranteeing there's a + // listener for any error. + unawaited( + _refreshRefBody(ref, customerKey, forceUpdateMessages, completer), + ); + return completer.future; + } + + Future _refreshRefBody( + TicketRef ref, + String customerKey, + bool forceUpdateMessages, + Completer completer, + ) async { + final int id = ref.id; + try { + // get status first. If it fails there is no reason to make the remaining + // two API calls + final statusResp = await client.getTicketStatus( + id, + customerKey: customerKey, + ); + + if (statusResp.exception?.statusCode == 403) { + Logging.instance.w( + "$runtimeType._refreshBody status call permission denied. " + "Ignoring ticket.", + ); + } else { + final status = statusResp.valueOrThrow; + + final ShopInBitTicket? existing = await db.shopInBitTicketsDao + .getByApiId(id); + + final ApiResponse? fullResp; + if (existing == null || + // status.state.value != existing.statusRaw || + status.updatedAt.isAfter(existing.updatedAt)) { + fullResp = await client.getTicketFull(id, customerKey: customerKey); + + if (kDebugMode) { + final detail = existing == null + ? "existing == null" + : status.state.value != existing.statusRaw + ? "status.state.value != existing.statusRaw" + : "status.updatedAt.isAfter(existing.updatedAt)"; + + Logging.instance.w( + "Called getTicketFull($id, customerKey: $customerKey) because: " + "$detail\n\n" + "Response: ${fullResp.value ?? fullResp.exception}", + ); + } + } else { + fullResp = null; + } + + Future> fetchMessages() async { + final messagesResp = await client.getMessages( + id, + customerKey: customerKey, + ); + return messagesResp.valueOrThrow; + } + + if (existing == null) { + if (fullResp == null) { + throw Exception("Expected actual ticket full response (not null)"); + } + + await _insertHydrated( + ref: ref, + customerKey: customerKey, + full: fullResp.valueOrThrow, + status: status, + messages: await fetchMessages(), + ); + } else { + final List? messages; + + // Use the same predicate as the notify path (its documented single + // source of truth): a null stored timestamp with an incoming reply + // counts as new, so a ticket's FIRST agent reply is fetched — not + // just bannered — instead of being skipped and never pulled in. + if (forceUpdateMessages || + _hasNewerAgentMessage(existing, status) || + existing.messages.isEmpty) { + messages = await fetchMessages(); + if (kDebugMode) { + Logging.instance.w( + "Called fetchMessages for id=${ref.id} " + "AND number=${ref.number}\n\n" + "Response: $messages", + ); + } + } else { + messages = null; + } + + await _patchExisting( + existing: existing, + full: fullResp?.value, + status: status, + messages: messages, + ); + } + } + + completer.complete(); + } catch (e, s) { + completer.completeError(e, s); + } finally { + _inFlight.remove(id); + } + } + + Future _insertHydrated({ + required TicketRef ref, + required String customerKey, + required TicketFull full, + required TicketStatus status, + required List messages, + }) async { + final ShopInBitOrderStatus? mappedStatus = + ShopInBitOrderStatus.fromTicketState(status.state); + if (mappedStatus == null) return; + + final ShopInBitCategory category = _inferCategory(messages); + + await db.shopInBitTicketsDao.insertTicket( + ShopInBitTicketsCompanion.insert( + apiTicketId: ref.id, + customerKey: customerKey, + ticketNumber: ref.number, + category: category, + requestDescription: _extractRequestDescription(messages), + deliveryCountry: full.deliveryCountry, + status: mappedStatus, + statusRaw: status.stateRaw, + offerProductName: Value(full.productName), + offerPrice: Value(full.customerPrice), + paymentInvoiceStatus: Value(status.paymentInvoiceStatus), + trackingLink: Value(status.trackingLink), + lastAgentMessageAt: Value(status.lastAgentMessageAt), + feeTicketNumber: Value( + category == ShopInBitCategory.car + ? _extractFeeTicketNumber(messages) + : null, + ), + messages: Value(messages), + updatedAt: Value(DateTime.now()), + ), + ); + } + + /// Patch path: only touches columns the API actually returned. Stable + /// provenance fields (category, requestDescription, ticketNumber) are + /// never overwritten on update — they were authoritative at insert time. + Future _patchExisting({ + required ShopInBitTicket existing, + required TicketFull? full, + required TicketStatus? status, + required List? messages, + }) async { + final ShopInBitOrderStatus? mappedStatus = status == null + ? null + : ShopInBitOrderStatus.fromTicketState(status.state); + + await db.shopInBitTicketsDao.updateTicket( + existing.apiTicketId, + ShopInBitTicketsCompanion( + // From /status — only patch when we got a recognised state. + status: mappedStatus == null + ? const Value.absent() + : Value(mappedStatus), + statusRaw: status == null + ? const Value.absent() + : Value(status.stateRaw), + paymentInvoiceStatus: status == null + ? const Value.absent() + : Value(status.paymentInvoiceStatus), + trackingLink: status == null + ? const Value.absent() + : Value(status.trackingLink), + lastAgentMessageAt: status?.lastAgentMessageAt == null + ? const Value.absent() + : Value(status!.lastAgentMessageAt), + deliveryCountry: full == null + ? const Value.absent() + : Value(full.deliveryCountry), + offerProductName: full == null + ? const Value.absent() + : Value(full.productName), + offerPrice: full == null + ? const Value.absent() + : Value(full.customerPrice), + + // From /messages. + messages: messages == null ? const Value.absent() : Value(messages), + feeTicketNumber: messages == null + ? const Value.absent() + : Value( + existing.category == ShopInBitCategory.car + ? _extractFeeTicketNumber(messages) + : null, + ), + + updatedAt: Value(DateTime.now()), + ), + ); + + await _maybeNotifyNewReply(existing, status); + } + + static bool _hasNewerAgentMessage( + ShopInBitTicket existing, + TicketStatus status, + ) { + final DateTime? incoming = status.lastAgentMessageAt; + if (incoming == null) return false; + final DateTime? stored = existing.lastAgentMessageAt; + return stored == null || incoming.isAfter(stored); + } + + Future _maybeNotifyNewReply( + ShopInBitTicket existing, + TicketStatus? status, + ) async { + if (status == null || !_hasNewerAgentMessage(existing, status)) return; + final DateTime newAgentAt = status.lastAgentMessageAt!; + // A message the user has already read is not news, even when the stored + // agent timestamp is missing (bare-insert rows, or an API response that + // omitted the field on an earlier poll). + final DateTime? lastReadAt = existing.lastReadAt; + if (lastReadAt != null && !newAgentAt.isAfter(lastReadAt)) return; + + const String title = "ShopinBit"; + final String body = "New reply to request ${existing.ticketNumber}"; + + final bool viewing = existing.apiTicketId == viewingTicketId; + + // iconAsset stays null: the card falls back to the ShopinBit brand icon, + // and not persisting the path means an asset move can't strand old rows. + await db.appNotificationsDao.add( + AppNotificationsCompanion.insert( + type: AppNotificationType.shopinbit, + title: title, + body: Value(body), + scopeId: Value(existing.customerKey), + targetId: Value("${existing.apiTicketId}"), + read: Value(viewing), + ), + ); + + if (viewing) { + // Mark read here rather than waiting for the detail view's next poll: + // that poll reads a not-yet-requeried snapshot and would leave the + // bell/dot lit for a full interval while the user reads the reply. + await markTicketRead(existing.apiTicketId); + } else { + unawaited(NotificationApi.showLocalOnly(title: title, body: body)); + } + + // Keep this scope's notification history bounded now that a row was added. + await db.appNotificationsDao.pruneScope( + AppNotificationType.shopinbit, + existing.customerKey, + ); + } +} + +// -- Message parsers -- +// +// All "rich" fields the API doesn't surface directly are parsed from the +// first user message. The car flow seeds the comment with the standard +// "car research fee (#XYZ)" line; travel requests start with +// "Arrangement:" followed by structured labels. If either format changes +// server-side, update these regexes. + +final RegExp _kCarResearchFeeRegex = RegExp(r"car research fee \(#([^)]+)\)"); +final RegExp _kTravelArrangementRegex = RegExp( + r"^Arrangement:\s", + multiLine: true, +); +final RegExp _kHtmlBrRegex = RegExp(r"", caseSensitive: false); +final RegExp _kHtmlTagRegex = RegExp(r"<[^>]+>"); + +TicketMessage? _firstUserMessage(List messages) { + for (final TicketMessage m in messages) { + if (!m.fromAgent) return m; + } + return null; +} + +ShopInBitCategory _inferCategory(List messages) { + final TicketMessage? first = _firstUserMessage(messages); + if (first == null) return ShopInBitCategory.concierge; + final String content = first.content; + if (_kCarResearchFeeRegex.hasMatch(content)) return ShopInBitCategory.car; + if (_kTravelArrangementRegex.hasMatch(content)) { + return ShopInBitCategory.travel; + } + return ShopInBitCategory.concierge; +} + +String? _extractFeeTicketNumber(List messages) { + final TicketMessage? first = _firstUserMessage(messages); + if (first == null) return null; + return _kCarResearchFeeRegex.firstMatch(first.content)?.group(1); +} + +String _extractRequestDescription(List messages) { + final TicketMessage? first = _firstUserMessage(messages); + if (first == null) return ""; + return first.content + .replaceAll(_kHtmlBrRegex, "\n") + .replaceAll(_kHtmlTagRegex, "") + .trim(); +} diff --git a/lib/services/shopinbit/src/api_exception.dart b/lib/services/shopinbit/src/api_exception.dart new file mode 100644 index 0000000000..6e35192572 --- /dev/null +++ b/lib/services/shopinbit/src/api_exception.dart @@ -0,0 +1,24 @@ +class ApiException implements Exception { + final String message; + final int? statusCode; + final String? responseBody; + + ApiException(this.message, {this.statusCode, this.responseBody}); + + factory ApiException.fromResponse(int statusCode, String body) { + return ApiException( + 'HTTP $statusCode', + statusCode: statusCode, + responseBody: body, + ); + } + + factory ApiException.network(Object error) { + return ApiException('Network error: $error'); + } + + @override + String toString() => + 'ApiException: $message' + '${statusCode != null ? ' (status: $statusCode)' : ''}'; +} diff --git a/lib/services/shopinbit/src/api_response.dart b/lib/services/shopinbit/src/api_response.dart new file mode 100644 index 0000000000..a1e9135063 --- /dev/null +++ b/lib/services/shopinbit/src/api_response.dart @@ -0,0 +1,18 @@ +import 'api_exception.dart'; + +class ApiResponse { + final T? value; + final ApiException? exception; + + ApiResponse({this.value, this.exception}); + + bool get hasError => exception != null; + + T get valueOrThrow { + if (exception != null) throw exception!; + return value as T; + } + + @override + String toString() => '{error: $exception, value: $value}'; +} diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart new file mode 100644 index 0000000000..eee9018e2e --- /dev/null +++ b/lib/services/shopinbit/src/client.dart @@ -0,0 +1,1097 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +import '../../../app_config.dart'; +import '../../../networking/http.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/prefs.dart'; +import '../../tor_service.dart'; +import 'api_exception.dart'; +import 'api_response.dart'; +import 'endpoints.dart'; +import 'models/address.dart'; +import 'models/car_research.dart'; +import 'models/message.dart'; +import 'models/payment.dart'; +import 'models/ticket.dart'; +import 'models/voucher.dart'; +import 'token_manager.dart'; + +const _kTag = "ShopInBitClient"; + +// 429 retry policy: up to 3 retries, backoff capped at 30s. +const int _kMaxRetries = 3; +const Duration _kMaxBackoff = Duration(seconds: 30); + +// Per-request ceiling so a stalled socket (common on a sleeping/backgrounded +// device) can't hang a request forever. A hung poll would otherwise latch the +// caller's in-flight guard and silently stop all further polling. +const Duration _kRequestTimeout = Duration(seconds: 30); + +// Uploads can carry up to 50 MB; the 30s request ceiling would kill them on +// slow links, so multipart sends get their own generous ceiling. +const _kUploadTimeout = Duration(minutes: 5); + +class ShopInBitClient { + final String accessKey; + final String partnerSecret; + final String baseUrl; + final bool sandbox; + final HTTP _httpClient; + final TokenManager _tokenManager; + final Random _rng = Random(); + + ShopInBitClient({ + required this.accessKey, + required this.partnerSecret, + this.baseUrl = Endpoints.production, + this.sandbox = false, + String? externalCustomerKey, + HTTP? httpClient, + }) : _httpClient = httpClient ?? const HTTP(), + _tokenManager = TokenManager( + accessKey: accessKey, + partnerSecret: partnerSecret, + baseUrl: baseUrl, + httpClient: httpClient, + ); + + // -- Auth -- + + Future> authenticate() async { + try { + await _tokenManager.getValidToken(); + return ApiResponse(); + } on ApiException catch (e) { + return ApiResponse(exception: e); + } catch (e) { + return ApiResponse(exception: ApiException('Authentication failed: $e')); + } + } + + // -- Utility -- + + Future> generateKey() async { + return _request( + 'GET', + '/generate-key', + customerKey: null, + parse: (json) { + return json['external_customer_key'] as String; + }, + ); + } + + Future>> getHealth() async { + return _request('GET', '/health', customerKey: null, parse: (json) => json); + } + + Future>>> getCountries() async { + return _requestRaw( + 'GET', + '/meta/countries', + customerKey: null, + needsAuth: false, + parse: (body) { + final decoded = jsonDecode(body); + if (decoded is List) { + return decoded.cast>(); + } + return [decoded as Map]; + }, + ); + } + + // -- Tickets -- + + Future> createRequest({ + required String customerPseudonym, + required String externalCustomerKey, + required String serviceType, + required String comment, + required String deliveryCountry, + required String? deliveryState, + String? voucherCode, + }) async { + return _request( + 'POST', + '/requests', + body: { + 'customer_pseudonym': customerPseudonym, + 'external_customer_key': externalCustomerKey, + 'service_type': serviceType, + 'comment': comment, + 'delivery_country': deliveryCountry, + if (deliveryState != null) 'delivery_state': deliveryState, + if (voucherCode != null) 'voucher_code': voucherCode, + }, + parse: (json) { + return TicketRef( + id: json['ticket_id'] is int + ? json['ticket_id'] as int + : int.parse(json['ticket_id'].toString()), + number: json['ticket_number'] as String, + ); + }, + customerKey: externalCustomerKey, + ); + } + + Future> getTicketStatus( + int ticketId, { + required String customerKey, + }) async { + return _request( + 'GET', + '/tickets/$ticketId/status', + parse: TicketStatus.fromJson, + customerKey: customerKey, + ); + } + + Future> getTicketFull( + int ticketId, { + required String customerKey, + }) async { + return _request( + 'GET', + '/tickets/$ticketId/full', + parse: TicketFull.fromJson, + customerKey: customerKey, + ); + } + + Future>> getTicketsByCustomer( + String customerKey, + ) async { + return _request( + 'GET', + '/tickets/by-customer/$customerKey', + parse: (json) { + final list = json['tickets'] as List; + return list + .map((e) => TicketRef.fromJson(e as Map)) + .toList(); + }, + customerKey: customerKey, + ); + } + + // -- Messages -- + + Future>> sendMessage( + int ticketId, + String message, { + required String customerKey, + }) async { + return _request( + 'POST', + '/tickets/$ticketId/messages', + body: {'message': message}, + parse: (json) => json, + customerKey: customerKey, + ); + } + + Future>> getMessages( + int ticketId, { + required String customerKey, + }) async { + return _request( + 'GET', + '/tickets/$ticketId/messages', + parse: (json) { + final list = json['messages'] as List; + // Tolerate a single malformed message: skip it rather than throwing, + // which would discard the entire conversation for this (and every + // subsequent) poll and silently stall the chat. + final messages = []; + for (final raw in list) { + try { + messages.add(TicketMessage.fromJson(raw as Map)); + } catch (e, s) { + Logging.instance.w( + "$_kTag skipping malformed ticket message", + error: e, + stackTrace: s, + ); + } + } + return messages; + }, + customerKey: customerKey, + ); + } + + // -- Attachments -- + + Future>> sendAttachments( + int ticketId, { + required String message, + required List attachments, + required String customerKey, + }) async { + if (attachments.isEmpty) { + return _validationError( + "No files to upload. Use POST /tickets/{id}/messages for text-only messages.", + ); + } + + int combinedBytes = 0; + final List<_AttachmentUpload> uploads = []; + + for (final file in attachments) { + final fileName = file.uri.pathSegments.last; + final resolved = resolveAttachmentType(fileName); + + if (resolved == null) { + return _validationError("Unsupported file type: $fileName"); + } + + final sizeBytes = await file.length(); + if (sizeBytes > resolved.category.maxBytes) { + return _validationError( + "$fileName is larger than the " + "${resolved.category.maxBytes ~/ 1000000} MB " + "${resolved.category.name} limit", + ); + } + + combinedBytes += sizeBytes; + if (combinedBytes > kCombinedAttachmentMaxBytes) { + return _validationError("Combined upload size exceeds the 50 MB limit"); + } + + uploads.add((path: file.path, contentType: resolved.mimeType)); + } + + return _multipartRequest( + "/tickets/$ticketId/attachments", + fields: {"message": message}, + uploads: uploads, + parse: (json) => json, + customerKey: customerKey, + ); + } + + /// Build a URL for fetching an attachment via `/attachment-proxy/`. + /// + /// For use in HTTP clients that can set headers, use the returned URL with + /// the standard Authorization + External-Customer-Key headers. + /// For inline images (e.g. in HTML where headers can't be set), pass + /// [useQueryAuth] = true to append token and customer_key as query params. + Future> getAttachmentUrl( + String attachmentPath, { + String? customerKey, + bool useQueryAuth = false, + }) async { + try { + final token = await _tokenManager.getValidToken(); + final resolved = _resolvePath('/attachment-proxy/$attachmentPath'); + var uri = Uri.parse('$baseUrl$resolved'); + if (useQueryAuth) { + uri = uri.replace( + queryParameters: { + 'token': token, + if (customerKey != null) 'customer_key': customerKey, + }, + ); + } + return ApiResponse(value: uri); + } on ApiException catch (e) { + return ApiResponse(exception: e); + } catch (e) { + return ApiResponse(exception: ApiException.network(e)); + } + } + + /// Download an attachment from `/attachment-proxy/`. + Future> getAttachment( + String attachmentPath, { + String? customerKey, + }) async { + try { + final token = await _tokenManager.getValidToken(); + final resolved = _resolvePath('/attachment-proxy/$attachmentPath'); + final uri = Uri.parse('$baseUrl$resolved'); + Logging.instance.t("$_kTag GET $uri"); + final headers = _headers(token, customerKey: customerKey); + final response = await _httpClient + .get(url: uri, headers: headers, proxyInfo: _proxyInfo) + .timeout(_kRequestTimeout); + if (response.code >= 200 && response.code < 300) { + return ApiResponse(value: response); + } else { + Logging.instance.w( + "$_kTag GET $resolved HTTP:${response.code} " + "body: ${response.body}", + ); + return ApiResponse( + exception: ApiException.fromResponse(response.code, response.body), + ); + } + } on ApiException catch (e) { + Logging.instance.e( + "$_kTag getAttachment($attachmentPath) threw: ", + error: e, + ); + return ApiResponse(exception: e); + } catch (e, s) { + Logging.instance.e( + "$_kTag getAttachment($attachmentPath) threw: ", + error: e, + stackTrace: s, + ); + return ApiResponse(exception: ApiException.network(e)); + } + } + + // -- Address -- + + Future>> submitAddress( + int ticketId, { + required Address shipping, + required String customerKey, + Address? billing, + }) async { + return _request( + 'POST', + '/tickets/$ticketId/address', + body: {'shipping': shipping.toJson(), 'billing': billing?.toJson()}, + parse: (json) => json, + customerKey: customerKey, + ); + } + + // -- Payment -- + + /// Read existing invoice state. Use this for polling, page-reload recovery, + /// and any view that just wants to show the current invoice; per ShopinBit + /// 1.0.4 this endpoint is read-only and will not create or regenerate the + /// invoice. Call [putPayment] for that. + Future> getPayment( + int ticketId, { + required String customerKey, + }) async { + return _request( + 'GET', + '/tickets/$ticketId/payment', + parse: PaymentInfo.fromJson, + customerKey: customerKey, + ); + } + + /// Create or regenerate the BTCPay invoice for [ticketId]. Per the 1.0.4 + /// spec call this only after the customer has accepted the offer, submitted + /// shipping/billing, seen the Terms & Conditions, and explicitly clicked + /// PAY NOW. Repeated calls regenerate the invoice and invalidate any in- + /// flight payment. + /// Create a payment invoice, or regenerate an expired/invalid one with + /// [retry] = true (spec: PUT ...?retry=true). + Future> putPayment( + int ticketId, { + required String customerKey, + bool retry = false, + }) async { + return _request( + 'PUT', + '/tickets/$ticketId/payment', + query: retry ? const {'retry': 'true'} : null, + parse: PaymentInfo.fromJson, + customerKey: customerKey, + ); + } + + // -- Vouchers -- + + /// Pre-check a voucher code (does not consume usage or create a ticket). + Future> checkVoucher( + String code, { + required String customerKey, + }) async { + return _request( + 'GET', + '/vouchers/validate', + query: {'code': code}, + parse: VoucherInfo.fromJson, + customerKey: customerKey, + ); + } + + /// Redeem a VIP voucher (creates ticket in one call). VIP/VIP_PRIORITY only. + Future> redeemVipVoucher({ + required String voucherCode, + required String customerPseudonym, + required String serviceType, + required String comment, + required String customerKey, + String? deliveryCountry, + }) async { + return _request( + 'POST', + '/vouchers/validate', + body: { + 'voucher_code': voucherCode, + 'customer_pseudonym': customerPseudonym, + 'service_type': serviceType, + 'comment': comment, + if (deliveryCountry != null) 'delivery_country': deliveryCountry, + }, + parse: VipRedemptionResult.fromJson, + customerKey: customerKey, + ); + } + + // -- Car Research Fee -- + + /// Create the car research fee invoice. Both [billing] and [request] are + /// required; without a request the server returns 422 and creates nothing. + /// The stored request lets the backend build the customer-facing car ticket + /// once the fee is paid. + Future> createCarResearchInvoice({ + required Address billing, + required CarResearchRequest request, + required String customerKey, + }) async { + return _request( + 'POST', + '/car-research/invoice', + body: { + 'billing': billing.toJson(), + 'request': request.toJson(), + 'external_customer_key': customerKey, + }, + parse: CarResearchInvoice.fromJson, + customerKey: customerKey, + ); + } + + /// Replace [invoiceId] using the billing/request payload already stored by + /// the server. + Future> retryCarResearchInvoice({ + required String invoiceId, + required String customerKey, + }) async { + return _request( + 'POST', + '/car-research/invoice', + body: {'invoice_id': invoiceId, 'retry': true}, + parse: CarResearchInvoice.fromJson, + customerKey: customerKey, + ); + } + + /// Unresolved car research invoices for the current partner/customer pair. + /// Used to recover a fee payment the user started but did not finish. + Future>> + getCurrentCarResearchInvoices({required String customerKey}) async { + return _requestRaw( + 'GET', + '/car-research/invoices/current', + parse: (body) { + if (body.isEmpty) return []; + final decoded = jsonDecode(body); + final list = decoded is List + ? decoded + : (decoded as Map)['invoices'] as List? ?? + const []; + return list + .map( + (e) => + CarResearchCurrentInvoice.fromJson(e as Map), + ) + .toList(); + }, + customerKey: customerKey, + ); + } + + /// Poll the car research invoice status. Read-only: it never confirms + /// payment. Once [CarResearchInvoiceStatus.finalized] is true the response + /// carries the receipt and real ticket references. + Future> getCarResearchInvoiceStatus( + String invoiceId, { + required String customerKey, + }) async { + return _request( + 'GET', + '/car-research/invoice/$invoiceId/status', + parse: CarResearchInvoiceStatus.fromJson, + customerKey: customerKey, + ); + } + + // -- Push Notifications -- + + Future>> registerPushSubscription({ + String? deviceToken, + String? endpoint, + Map? keys, + String? platform, + String? environment, + String? expirationTime, + int? ticketId, + + required String customerKey, + }) async { + return _request( + 'POST', + '/notifications/push-subscriptions', + body: { + if (deviceToken != null) 'deviceToken': deviceToken, + if (endpoint != null) 'endpoint': endpoint, + if (keys != null) 'keys': keys, + if (platform != null) 'platform': platform, + if (environment != null) 'environment': environment, + if (expirationTime != null) 'expirationTime': expirationTime, + if (ticketId != null) 'ticketId': ticketId, + }, + parse: (json) => json, + customerKey: customerKey, + ); + } + + // -- Webhooks -- + + Future>>> listWebhooks() async { + return _request( + 'GET', + '/partners/webhooks', + customerKey: null, + parse: (json) { + if (json.containsKey('webhooks')) { + return (json['webhooks'] as List) + .cast>(); + } + return [json]; + }, + ); + } + + Future>> createWebhook({ + required String webhookUrl, + required List eventTypes, + }) async { + return _request( + 'POST', + '/partners/webhooks', + customerKey: null, + body: {'webhook_url': webhookUrl, 'event_types': eventTypes}, + parse: (json) => json, + ); + } + + Future>> rotateWebhookSecret( + String webhookId, + ) async { + return _request( + 'POST', + '/partners/webhooks/$webhookId/rotate', + customerKey: null, + parse: (json) => json, + ); + } + + Future> deleteWebhook(String webhookId) async { + return _request( + 'DELETE', + '/partners/webhooks/$webhookId', + customerKey: null, + parse: (_) {}, + ); + } + + // -- Sandbox -- + + Future>> sandboxSetState( + int ticketId, + String state, { + required String customerKey, + }) async { + return _request( + 'POST', + '/sandbox/state/$ticketId/$state', + parse: (json) => json, + customerKey: customerKey, + ); + } + + Future>> sandboxSetPayment( + int ticketId, + String status, { + required String customerKey, + }) async { + return _request( + 'POST', + '/sandbox/payment/$ticketId/$status', + parse: (json) => json, + customerKey: customerKey, + ); + } + + // -- Internals -- + + ({InternetAddress host, int port})? get _proxyInfo => + !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null; + + /// Prepend /sandbox to paths when in sandbox mode, except for paths that + /// already start with /sandbox, /meta, /health, or /token. + String _resolvePath(String path) { + if (!sandbox) return path; + if (path.startsWith('/sandbox') || + path.startsWith('/meta') || + path.startsWith('/health') || + path.startsWith('/token') || + path.startsWith('/partners')) { + return path; + } + return '/sandbox$path'; + } + + Map _headers(String token, {String? customerKey}) { + final h = { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + if (customerKey != null) { + h['External-Customer-Key'] = customerKey; + } + return h; + } + + Future _send( + String method, + String path, { + Map? body, + Map? query, + required String? customerKey, + bool needsAuth = true, + }) async { + final resolved = _resolvePath(path); + var uri = Uri.parse('$baseUrl$resolved'); + if (query != null && query.isNotEmpty) { + uri = uri.replace(queryParameters: query); + } + Map headers; + if (needsAuth) { + final token = await _tokenManager.getValidToken(); + headers = _headers(token, customerKey: customerKey); + } else { + headers = {'Accept': 'application/json'}; + } + final proxy = _proxyInfo; + + Logging.instance.t("$_kTag $method $uri"); + + Future dispatch() { + switch (method) { + case 'GET': + return _httpClient.get(url: uri, headers: headers, proxyInfo: proxy); + case 'POST': + return _httpClient.post( + url: uri, + headers: headers, + body: body != null ? _asciiSafeJson(body) : null, + proxyInfo: proxy, + ); + case 'PUT': + return _httpClient.put( + url: uri, + headers: headers, + body: body != null ? jsonEncode(body) : null, + proxyInfo: proxy, + ); + case 'PATCH': + return _httpClient.patch( + url: uri, + headers: headers, + body: body != null ? _asciiSafeJson(body) : null, + proxyInfo: proxy, + ); + case 'DELETE': + return _httpClient.delete( + url: uri, + headers: headers, + proxyInfo: proxy, + ); + default: + throw ApiException('Unsupported method: $method'); + } + } + + // Retry on 429 (Too Many Requests) with backoff so we stop hammering the + // API the moment it tells us to. Respects a server-sent Retry-After when + // present, otherwise exponential backoff with jitter. Everything funnels + // through here, so all endpoints get this for free. + int attempt = 0; + bool reauthed = false; + while (true) { + final response = await dispatch().timeout(_kRequestTimeout); + // A 401 means the bearer token is stale/expired: invalidate it, + // re-authenticate once, and retry before surfacing the error. + if (response.code == 401 && needsAuth && !reauthed) { + reauthed = true; + _tokenManager.invalidate(); + final token = await _tokenManager.getValidToken(); + headers = _headers(token, customerKey: customerKey); + Logging.instance.w( + "$_kTag $method $resolved HTTP:401, re-authenticating", + ); + continue; + } + if (response.code != 429 || attempt >= _kMaxRetries) { + return response; + } + final Duration delay = _backoffDelay(attempt, response.headers); + Logging.instance.w( + "$_kTag $method $resolved HTTP:429, backing off " + "${delay.inMilliseconds}ms (retry ${attempt + 1}/$_kMaxRetries)", + ); + await Future.delayed(delay); + attempt++; + } + } + + /// Next poll interval after a failed poll: double [current], capped at [max]. + static Duration nextPollBackoff(Duration current, Duration max) { + final Duration next = current * 2; + return next > max ? max : next; + } + + /// How long to wait before retrying a 429. Prefers a sane `Retry-After` + /// header; otherwise 1s, 2s, 4s... with jitter, capped at [_kMaxBackoff]. + Duration _backoffDelay(int attempt, Map headers) { + final Duration? retryAfter = _parseRetryAfter(headers['retry-after']); + if (retryAfter != null) { + return retryAfter > _kMaxBackoff ? _kMaxBackoff : retryAfter; + } + final int base = 1000 * (1 << attempt); + final int ms = base + _rng.nextInt(500); + return ms > _kMaxBackoff.inMilliseconds + ? _kMaxBackoff + : Duration(milliseconds: ms); + } + + /// Parse a `Retry-After` value, which is either delay-seconds or an + /// HTTP-date. Returns null if absent or unparseable. + Duration? _parseRetryAfter(String? value) { + if (value == null) return null; + final String trimmed = value.trim(); + final int? seconds = int.tryParse(trimmed); + if (seconds != null) { + return seconds < 0 ? Duration.zero : Duration(seconds: seconds); + } + try { + final Duration diff = HttpDate.parse(trimmed).difference(DateTime.now()); + return diff.isNegative ? Duration.zero : diff; + } catch (_) { + return null; + } + } + + // Encode [body] as JSON with all non-ASCII characters replaced by \uXXXX + // escapes. The HTTP wrapper writes string bodies with the latin1 default of + // HttpClientRequest.write, which mangles multi-byte UTF-8 like the U+00B1/±. + static String _asciiSafeJson(Object body) { + final raw = jsonEncode(body); + final buf = StringBuffer(); + for (int i = 0; i < raw.length; i++) { + final c = raw.codeUnitAt(i); + if (c < 0x80) { + buf.writeCharCode(c); + } else { + buf.write('\\u'); + buf.write(c.toRadixString(16).padLeft(4, '0')); + } + } + return buf.toString(); + } + + Future> _request( + String method, + String path, { + Map? body, + Map? query, + required String? customerKey, + required T Function(Map) parse, + }) async { + try { + final response = await _send( + method, + path, + body: body, + query: query, + customerKey: customerKey, + ); + + if (kDebugMode) { + Logging.instance.i( + "$_kTag $method HTTP:${response.code} " + "body: ${response.body}", + ); + } + + final resolved = _resolvePath(path); + + if (response.code >= 200 && response.code < 300) { + Logging.instance.t("$_kTag $method $resolved HTTP:${response.code}"); + if (response.body.isEmpty) { + // An empty 2xx body would make object parsers fabricate placeholder + // objects (e.g. a ticket with id 0); surface it as an error instead. + return ApiResponse( + exception: ApiException( + "Empty response body for $method $resolved", + ), + ); + } + final json = jsonDecode(response.body) as Map; + return ApiResponse(value: parse(json)); + } else { + Logging.instance.w( + "$_kTag $method $resolved HTTP:${response.code} " + "body: ${response.body}", + ); + return ApiResponse( + exception: ApiException.fromResponse(response.code, response.body), + ); + } + } on ApiException catch (e) { + Logging.instance.e("$_kTag _request($method $path) threw: ", error: e); + return ApiResponse(exception: e); + } catch (e, s) { + Logging.instance.e( + "$_kTag _request($method $path) threw: ", + error: e, + stackTrace: s, + ); + return ApiResponse(exception: ApiException.network(e)); + } + } + + /// Like [_request] but gives the parse function the raw response body + /// string, for endpoints that return non-object JSON (e.g. arrays). + Future> _requestRaw( + String method, + String path, { + Map? body, + Map? query, + required String? customerKey, + bool needsAuth = true, + required T Function(String) parse, + }) async { + try { + final response = await _send( + method, + path, + body: body, + query: query, + customerKey: customerKey, + needsAuth: needsAuth, + ); + + final resolved = _resolvePath(path); + + if (response.code >= 200 && response.code < 300) { + Logging.instance.t("$_kTag $method $resolved HTTP:${response.code}"); + return ApiResponse(value: parse(response.body)); + } else { + Logging.instance.w( + "$_kTag $method $resolved HTTP:${response.code} " + "body: ${response.body}", + ); + return ApiResponse( + exception: ApiException.fromResponse(response.code, response.body), + ); + } + } on ApiException catch (e) { + Logging.instance.e("$_kTag _requestRaw($method $path) threw: ", error: e); + return ApiResponse(exception: e); + } catch (e, s) { + Logging.instance.e( + "$_kTag _requestRaw($method $path) threw: ", + error: e, + stackTrace: s, + ); + return ApiResponse(exception: ApiException.network(e)); + } + } + + /// Client-side rejection: the request never left the device. Uses the same + /// ApiException channel as server failures so call sites handle one format. + ApiResponse _validationError(String message) => + ApiResponse(exception: ApiException(message)); + + /// Multipart sibling of [_request]. package:http is used only to *encode* + /// the multipart/form-data body. + /// Transport goes through [_httpClient] so uploads ride the same + /// Tor-capable pipe as every other request. + /// + /// The encoded body is held in memory once (bounded to 50 MB by the + /// validation in [sendAttachments]) so the 401 re-auth can resend it + /// without re-reading files from disk. Deliberately no 429 auto-retry: + /// unlike [_send], a retry here would re-send the full upload body, which + /// the user should trigger explicitly. + Future> _multipartRequest( + String path, { + required Map fields, + required List<_AttachmentUpload> uploads, + required T Function(Map) parse, + required String customerKey, + }) async { + final resolved = _resolvePath(path); + final uri = Uri.parse("$baseUrl$resolved"); + + try { + // Encode once. finalize() fixes the boundary and yields the body + // stream; the content-type header carrying that boundary is only + // valid after finalize() has run, so it is read afterwards. + final encoder = http.MultipartRequest("POST", uri)..fields.addAll(fields); + for (final upload in uploads) { + encoder.files.add( + await http.MultipartFile.fromPath( + "attachments", // repeated field name, as the doc specifies + upload.path, + contentType: http.MediaType.parse(upload.contentType), + ), + ); + } + final bodyBytes = await encoder.finalize().toBytes(); + final contentType = encoder.headers["content-type"]!; + + Future sendOnce(String token) { + Logging.instance.t("$_kTag POST $uri"); + return _httpClient + .postBytes( + url: uri, + headers: { + "Authorization": "Bearer $token", + "External-Customer-Key": customerKey, + "Content-Type": contentType, + "Accept": "application/json", + }, + bodyBytes: bodyBytes, + proxyInfo: _proxyInfo, + ) + .timeout(_kUploadTimeout); + } + + Response response = await sendOnce(await _tokenManager.getValidToken()); + + // Mirror [_send]'s single re-auth on a stale bearer token; the encoded + // bytes are immutable, so resending needs no rebuild. + if (response.code == 401) { + _tokenManager.invalidate(); + Logging.instance.w("$_kTag POST $resolved HTTP:401, re-authenticating"); + response = await sendOnce(await _tokenManager.getValidToken()); + } + + if (response.code >= 200 && response.code < 300) { + Logging.instance.t("$_kTag POST $resolved HTTP:${response.code}"); + final json = jsonDecode(response.body) as Map; + return ApiResponse(value: parse(json)); + } else { + Logging.instance.w( + "$_kTag POST $resolved HTTP:${response.code} " + "body: ${response.body}", + ); + return ApiResponse( + exception: ApiException.fromResponse(response.code, response.body), + ); + } + } on ApiException catch (e) { + Logging.instance.e( + "$_kTag _multipartRequest(POST $path) threw: ", + error: e, + ); + return ApiResponse(exception: e); + } catch (e, s) { + Logging.instance.e( + "$_kTag _multipartRequest(POST $path) threw: ", + error: e, + stackTrace: s, + ); + return ApiResponse(exception: ApiException.network(e)); + } + } +} + +/// Per-category limits from POST /tickets/{ticket_id}/attachments. +/// +/// The doc says "MB" without defining it, so the stricter 1000-based reading +/// is enforced: a client-side pass then implies a server-side pass under +/// either interpretation. Confirm with it@shopinbit.com and pin the answer +/// here. +enum AttachmentCategory { + image(5 * 1000 * 1000), + document(10 * 1000 * 1000), + video(50 * 1000 * 1000); + + const AttachmentCategory(this.maxBytes); + + final int maxBytes; +} + +/// Combined upload cap for a single attachments message. +const kCombinedAttachmentMaxBytes = 50 * 1000 * 1000; + +/// Extensions accepted by [resolveAttachmentType], in file-picker +/// allowedExtensions form (no dots). Keep in sync with the switch in +/// [resolveAttachmentType]; drift fails safe because every picked file is +/// re-validated through the resolver anyway. +const kAllowedAttachmentExtensions = [ + "jpg", "jpeg", "png", "webp", "heic", "heif", "gif", // images + "pdf", "docx", "xlsx", "odt", // documents + "mp4", "mov", "webm", "3gp", "3gpp", // videos +]; + +typedef ResolvedAttachment = ({String mimeType, AttachmentCategory category}); + +/// Path + content type, materialized into multipart bytes inside +/// [_multipartRequest]: validation stays separate from encoding, and the +/// encoded bytes can be resent on 401 without re-reading files from disk. +typedef _AttachmentUpload = ({String path, String contentType}); + +/// Maps a filename to its API-supported MIME type and size category. +/// Returns null for unsupported types. Public so the UI can validate at +/// pick time with the same rules [ShopInBitClient.sendAttachments] enforces +/// at send time. +ResolvedAttachment? resolveAttachmentType(String fileName) { + final extension = fileName.split(".").last.toLowerCase(); + return switch (extension) { + "jpg" || "jpeg" => (mimeType: "image/jpeg", category: .image), + "png" => (mimeType: "image/png", category: .image), + "webp" => (mimeType: "image/webp", category: .image), + "heic" => (mimeType: "image/heic", category: .image), + "heif" => (mimeType: "image/heif", category: .image), + "gif" => (mimeType: "image/gif", category: .image), + "pdf" => (mimeType: "application/pdf", category: .document), + "docx" => ( + mimeType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + category: .document, + ), + "xlsx" => ( + mimeType: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + category: .document, + ), + "odt" => ( + mimeType: "application/vnd.oasis.opendocument.text", + category: .document, + ), + "mp4" => (mimeType: "video/mp4", category: .video), + "mov" => (mimeType: "video/quicktime", category: .video), + "webm" => (mimeType: "video/webm", category: .video), + "3gp" || "3gpp" => (mimeType: "video/3gpp", category: .video), + _ => null, + }; +} diff --git a/lib/services/shopinbit/src/endpoints.dart b/lib/services/shopinbit/src/endpoints.dart new file mode 100644 index 0000000000..00a18f669b --- /dev/null +++ b/lib/services/shopinbit/src/endpoints.dart @@ -0,0 +1,3 @@ +class Endpoints { + static const production = 'https://api.shopinbit.com'; +} diff --git a/lib/services/shopinbit/src/models/address.dart b/lib/services/shopinbit/src/models/address.dart new file mode 100644 index 0000000000..63bce5b2dd --- /dev/null +++ b/lib/services/shopinbit/src/models/address.dart @@ -0,0 +1,49 @@ +class Address { + final String? company; + final String? vat; + final String firstName; + final String lastName; + final String street; + final String zip; + final String city; + final String country; + final String? state; + + Address({ + this.company, + this.vat, + required this.firstName, + required this.lastName, + required this.street, + required this.zip, + required this.city, + required this.country, + required this.state, + }); + + Map toJson() => { + 'company': company, + 'vat': vat, + 'firstName': firstName, + 'lastName': lastName, + 'street': street, + 'zip': zip, + 'city': city, + 'country': country, + 'state': state, + }; + + factory Address.fromJson(Map json) { + return Address( + company: json['company'] as String?, + vat: json['vat'] as String?, + firstName: json['firstName'] as String, + lastName: json['lastName'] as String, + street: json['street'] as String, + zip: json['zip'] as String, + city: json['city'] as String, + country: json['country'] as String, + state: json['state'] as String?, + ); + } +} diff --git a/lib/services/shopinbit/src/models/auth_token.dart b/lib/services/shopinbit/src/models/auth_token.dart new file mode 100644 index 0000000000..af7816aadd --- /dev/null +++ b/lib/services/shopinbit/src/models/auth_token.dart @@ -0,0 +1,25 @@ +class AuthToken { + final String accessToken; + final String tokenType; + final DateTime expiresAt; + + AuthToken({ + required this.accessToken, + required this.tokenType, + required this.expiresAt, + }); + + factory AuthToken.fromJson(Map json) { + return AuthToken( + accessToken: json['access_token'] as String, + tokenType: json['token_type'] as String, + // Tokens valid for 10 minutes per API docs. + expiresAt: DateTime.now().add(const Duration(minutes: 10)), + ); + } + + bool get isExpired => DateTime.now().isAfter(expiresAt); + + bool get expiresSoon => + DateTime.now().isAfter(expiresAt.subtract(const Duration(minutes: 1))); +} diff --git a/lib/services/shopinbit/src/models/car_research.dart b/lib/services/shopinbit/src/models/car_research.dart new file mode 100644 index 0000000000..4290c53110 --- /dev/null +++ b/lib/services/shopinbit/src/models/car_research.dart @@ -0,0 +1,173 @@ +/// Optional request payload cached with a car research fee invoice. When +/// provided, the backend creates the real car research ticket itself after the +/// fee is paid (the BTCPay webhook failsafe), so the client does not have to. +class CarResearchRequest { + final String customerPseudonym; + final String comment; + final String deliveryCountry; + final String? deliveryState; + + CarResearchRequest({ + required this.customerPseudonym, + required this.comment, + required this.deliveryCountry, + required this.deliveryState, + }); + + Map toJson() => { + 'customer_pseudonym': customerPseudonym, + 'comment': comment, + 'delivery_country': deliveryCountry, + if (deliveryState != null) 'delivery_state': deliveryState, + }; +} + +/// An unresolved car research invoice returned by +/// GET /car-research/invoices/current, used to recover a payment the user +/// started but did not finish. +class CarResearchCurrentInvoice { + final String invoiceId; + final String status; + final String? additional; + final DateTime? expiresAt; + final Map paymentLinks; + final bool hasRequestPayload; + final DateTime? createdAt; + + CarResearchCurrentInvoice({ + required this.invoiceId, + required this.status, + required this.additional, + required this.expiresAt, + required this.paymentLinks, + required this.hasRequestPayload, + required this.createdAt, + }); + + factory CarResearchCurrentInvoice.fromJson(Map json) { + final linksRaw = json['payment_links'] as Map? ?? {}; + final expiresRaw = json['expires_at'] as String; + final createdRaw = json['created_at'] as String; + return CarResearchCurrentInvoice( + invoiceId: json['invoice_id'] as String, + status: json['status'] as String, + additional: json['additional'] as String?, + expiresAt: DateTime.parse(expiresRaw), + paymentLinks: linksRaw.map((k, v) => MapEntry(k, v as String)), + hasRequestPayload: json['has_request_payload'] as bool, + createdAt: DateTime.parse(createdRaw), + ); + } +} + +/// Whether a car research invoice status counts as paid/finalized. +/// +/// Prefer the `finalized` boolean from the status endpoint (see +/// [CarResearchInvoiceStatus.finalized]). This is the fallback for the raw +/// status/additional strings: Processing, Settled, or Expired with PaidLate, +/// plus lowercase values for older concierge-style statuses. +bool carResearchIsFinalized(String? status, String? additional) { + final s = (status ?? '').toLowerCase().trim(); + final a = (additional ?? '').toLowerCase().trim(); + if (s == 'processing' || s == 'settled') return true; + if (s == 'expired' && a == 'paidlate') return true; + return const { + 'paid', + 'paid_over', + 'paid_late', + 'payment_processing', + 'confirmed', + 'complete', + 'completed', + 'finalized', + }.contains(s); +} + +class CarResearchInvoice { + final String btcpayInvoice; + final DateTime expiresAt; + final Map paymentLinks; + + CarResearchInvoice({ + required this.btcpayInvoice, + required this.expiresAt, + required this.paymentLinks, + }); + + factory CarResearchInvoice.fromJson(Map json) { + final linksRaw = json['payment_links'] as Map? ?? {}; + return CarResearchInvoice( + btcpayInvoice: json['btcpay_invoice'] as String, + expiresAt: DateTime.parse(json['expires_at'] as String), + paymentLinks: linksRaw.map((k, v) => MapEntry(k, v as String)), + ); + } +} + +/// Result of GET /car-research/invoice/{invoice_id}/status. +/// +/// Read-only: it never confirms payment, so poll until [finalized] is true. +/// Once finalized it carries the created ticket references: +/// +/// * [realTicketId] / [realTicketNumber]: the customer-facing car research +/// chat. Open this for the customer after payment. +/// * [receiptTicketId] / [receiptTicketNumber]: the paid-fee receipt only; +/// do NOT use it as the active customer chat. +/// +/// The sandbox populates only the receipt references and leaves the real ticket +/// fields null, so [realTicketId] is nullable. +class CarResearchInvoiceStatus { + final String status; + final String? additional; + final Map paymentLinks; + final bool finalized; + final int? receiptTicketId; + final String? receiptTicketNumber; + final int? realTicketId; + final String? realTicketNumber; + final String externalCustomerKey; + + CarResearchInvoiceStatus({ + required this.status, + this.additional, + required this.paymentLinks, + required this.finalized, + this.receiptTicketId, + this.receiptTicketNumber, + this.realTicketId, + this.realTicketNumber, + required this.externalCustomerKey, + }); + + factory CarResearchInvoiceStatus.fromJson(Map json) { + final linksRaw = json['payment_links'] as Map? ?? {}; + return CarResearchInvoiceStatus( + status: json['status'] as String, + additional: json['additional']?.toString(), + paymentLinks: linksRaw.map((k, v) => MapEntry(k, v as String)), + finalized: json['finalized'] as bool, + receiptTicketId: json['receipt_ticket_id'] as int?, + receiptTicketNumber: json['receipt_ticket_number'] as String?, + realTicketId: json['real_ticket_id'] as int?, + realTicketNumber: json['real_ticket_number'] as String?, + externalCustomerKey: json['external_customer_key'] as String, + ); + } + + Map toMap() { + return { + "status": status, + "additional": additional, + "payment_links": paymentLinks, + "finalized": finalized, + "receipt_ticket_id": receiptTicketId, + "receipt_ticket_number": receiptTicketNumber, + "real_ticket_id": realTicketId, + "real_ticket_number": realTicketNumber, + "external_customer_key": externalCustomerKey, + }; + } + + @override + String toString() => toMap().toString(); +} diff --git a/lib/services/shopinbit/src/models/message.dart b/lib/services/shopinbit/src/models/message.dart new file mode 100644 index 0000000000..1251368ddb --- /dev/null +++ b/lib/services/shopinbit/src/models/message.dart @@ -0,0 +1,235 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' show parseFragment; + +class TicketMessage { + final DateTime timestamp; + final bool fromAgent; + final String content; + + TicketMessage({ + required this.timestamp, + required this.fromAgent, + required this.content, + }); + + /// [content] parsed once into ordered renderable segments: plain-text runs, + /// decoded inline base64 images, and `/attachment-proxy/` images and file + /// links, kept in the order they appear in the message. + late final List contentSegments = _parseSegments( + content, + ); + + factory TicketMessage.fromJson(Map json) { + return TicketMessage( + timestamp: DateTime.parse(json['timestamp'] as String).toUtc(), + fromAgent: json['from_agent'] as bool, + content: json['content'] as String, + ); + } + + Map toMap() => { + "timestamp": timestamp.toIso8601String(), + "from_agent": fromAgent, + "content": content, + }; + + @override + String toString() => toMap().toString(); +} + +/// A renderable piece of a ticket message, in document order. +sealed class MessageContentSegment {} + +/// A run of plain text (structural tags stripped, entities decoded). +class MessageTextSegment extends MessageContentSegment { + MessageTextSegment(this.text); + final String text; +} + +/// A decoded inline base64 (data-URI) image. +class MessageImageSegment extends MessageContentSegment { + MessageImageSegment(this.bytes); + final Uint8List bytes; +} + +/// An authenticated `/attachment-proxy/` image, fetched on demand with the +/// current token. [proxyPath] is the attachment-proxy path (query/fragment +/// stripped); [filename] is the `alt` text when present. +class MessageProxyImageSegment extends MessageContentSegment { + MessageProxyImageSegment({required this.proxyPath, this.filename}); + final String proxyPath; + final String? filename; +} + +/// An authenticated `/attachment-proxy/` file link, opened in the browser. +/// [proxyPath] is the attachment-proxy path; [filename] is the link text. +class MessageFileLinkSegment extends MessageContentSegment { + MessageFileLinkSegment({required this.proxyPath, this.filename}); + final String proxyPath; + final String? filename; +} + +/// Parse a ticket message's HTML [content] into ordered renderable segments. +/// +/// Walks the parsed DOM in document order so text, inline images, proxy images +/// and file links render where they appear. Attachment ``/`` become +/// media segments and their markup is never shown as text; other markup is +/// flattened to its text. The parser handles malformed/adversarial HTML and +/// entity decoding, so there's no hand-rolled tokeniser to keep correct. +List _parseSegments(String content) { + final segments = []; + final text = StringBuffer(); + + void flushText() { + final trimmed = text.toString().trim(); + if (trimmed.isNotEmpty) segments.add(MessageTextSegment(trimmed)); + text.clear(); + } + + void visit(dom.Node node) { + if (node is dom.Text) { + text.write(node.data); + return; + } + if (node is! dom.Element) return; + + switch (node.localName) { + case 'br': + text.write('\n'); + return; + case 'img': + final src = node.attributes['src']; + if (src == null) return; + final bytes = _decodeInlineImage(src); + if (bytes != null) { + flushText(); + segments.add(MessageImageSegment(bytes)); + } else if (_isAttachmentProxy(src)) { + final proxyPath = _proxyPathOf(src); + if (proxyPath != null) { + flushText(); + segments.add( + MessageProxyImageSegment( + proxyPath: proxyPath, + filename: _emptyOrNull(node.attributes['alt']), + ), + ); + } + } + return; + case 'a': + final href = node.attributes['href']; + if (href != null && _isAttachmentProxy(href)) { + final proxyPath = _proxyPathOf(href); + if (proxyPath != null) { + flushText(); + segments.add( + MessageFileLinkSegment( + proxyPath: proxyPath, + filename: _emptyOrNull(node.text), + ), + ); + } + // The link text is its label, not body text; don't recurse. + return; + } + } + + for (final child in node.nodes) { + visit(child); + } + } + + for (final node in parseFragment(content).nodes) { + visit(node); + } + flushText(); + return segments; +} + +final _whitespaceRe = RegExp(r'\s'); + +// Decoded inline images are cached by their base64 payload and reused across +// rebuilds. refreshOne rebuilds TicketMessage objects every ~30s poll, so +// without this the `late final` memo re-decodes each poll and hands a fresh +// Uint8List to Image.memory; MemoryImage compares bytes by identity, so that's +// an image cache miss -> re-decode + GPU re-upload + a visible flicker every +// poll. Returning the same instance keeps the provider equal so the cache hits. +// Bounded by total decoded size so large/many inline images can't grow it +// without limit. +const int _kInlineImageCacheMaxBytes = 16 * 1024 * 1024; +final _inlineImageCache = {}; +int _inlineImageCacheBytes = 0; + +/// Decode a `data:image/;base64,` URI to bytes, or null if [src] is +/// not such a data URI or the payload doesn't decode. Cached by payload. +Uint8List? _decodeInlineImage(String src) { + if (!src.startsWith('data:image/')) return null; + const marker = ';base64,'; + final idx = src.indexOf(marker); + if (idx < 0) return null; + final b64 = src.substring(idx + marker.length).replaceAll(_whitespaceRe, ''); + if (b64.isEmpty) return null; + + final cached = _inlineImageCache.remove(b64); + if (cached != null) { + _inlineImageCache[b64] = cached; // move to most-recently-used + return cached; + } + + final Uint8List bytes; + try { + bytes = base64Decode(b64); + } catch (_) { + return null; + } + _inlineImageCache[b64] = bytes; + _inlineImageCacheBytes += bytes.length; + while (_inlineImageCacheBytes > _kInlineImageCacheMaxBytes && + _inlineImageCache.length > 1) { + final oldest = _inlineImageCache.keys.first; + _inlineImageCacheBytes -= _inlineImageCache.remove(oldest)?.length ?? 0; + } + return bytes; +} + +bool _isAttachmentProxy(String url) => url.contains('/attachment-proxy/'); + +String? _proxyPathOf(String url) { + const marker = '/attachment-proxy/'; + final idx = url.indexOf(marker); + if (idx < 0) return null; + var rest = url.substring(idx + marker.length); + final q = rest.indexOf(RegExp(r'[?#]')); + if (q >= 0) rest = rest.substring(0, q); + if (rest.isEmpty) return null; + // Percent-encoded path separators (`%2f`, `%5c`) survive Uri.path + // normalisation un-decoded, so the dot-segment check below would miss a + // traversal smuggled through them; reject those outright. + final lower = rest.toLowerCase(); + if (lower.contains('%2f') || lower.contains('%5c')) return null; + // Reject anything that still escapes the attachment-proxy namespace once the + // path is normalised (literal `../`, or `%2e%2e` which Uri does decode). The + // result is interpolated into a request URL that carries the user's auth + // token, so a traversal could otherwise point that authenticated request at + // another endpoint on the host. + final Uri probe; + try { + probe = Uri.parse('https://x$marker$rest'); + } catch (_) { + return null; + } + if (!probe.path.startsWith(marker) || probe.path.length <= marker.length) { + return null; + } + return rest; +} + +String? _emptyOrNull(String? s) { + if (s == null) return null; + final t = s.trim(); + return t.isEmpty ? null : t; +} diff --git a/lib/services/shopinbit/src/models/models.dart b/lib/services/shopinbit/src/models/models.dart new file mode 100644 index 0000000000..7d4208c2fc --- /dev/null +++ b/lib/services/shopinbit/src/models/models.dart @@ -0,0 +1,8 @@ +export 'auth_token.dart'; +export 'ticket.dart'; +export 'message.dart'; +export 'address.dart'; +export 'payment.dart'; +export 'car_research.dart'; +export 'voucher.dart'; +export 'webhook_event.dart'; diff --git a/lib/services/shopinbit/src/models/payment.dart b/lib/services/shopinbit/src/models/payment.dart new file mode 100644 index 0000000000..cdc397a11f --- /dev/null +++ b/lib/services/shopinbit/src/models/payment.dart @@ -0,0 +1,39 @@ +import 'package:decimal/decimal.dart'; + +class PaymentInfo { + final String status; + final String customerPrice; + final String partnerPrice; + final Decimal? vatRate; + final String currency; + final DateTime? rateLockedUntil; + final Map paymentLinks; + final String? due; + + PaymentInfo({ + required this.status, + required this.customerPrice, + required this.partnerPrice, + required this.vatRate, + required this.currency, + this.rateLockedUntil, + required this.paymentLinks, + this.due, + }); + + factory PaymentInfo.fromJson(Map json) { + final linksRaw = json['payment_links'] as Map? ?? {}; + return PaymentInfo( + status: json['status'] as String, + customerPrice: json['customer_price'] as String, + partnerPrice: json['partner_price'] as String, + vatRate: Decimal.tryParse(json['vat_rate'].toString()), + currency: json['currency'] as String, + rateLockedUntil: DateTime.tryParse( + json['rate_locked_until']?.toString() ?? '', + ), + paymentLinks: linksRaw.map((k, v) => MapEntry(k, v as String)), + due: json['due'] as String?, + ); + } +} diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart new file mode 100644 index 0000000000..1e42367043 --- /dev/null +++ b/lib/services/shopinbit/src/models/ticket.dart @@ -0,0 +1,223 @@ +import 'package:decimal/decimal.dart'; + +import '../../../../utilities/logger.dart'; + +/// Splits a raw `tracking_link` value into individual tracking URLs. +/// +/// Multiple links may be joined with any of `,`, `|`, or `;` (and a single +/// value may mix them). Returns an empty list for null/empty input. Each URL is +/// trimmed and empty segments are discarded. +List splitTrackingLinks(String? raw) { + if (raw == null) return const []; + return raw + .split(RegExp(r'[,|;]')) + .map((s) { + final url = s.trim(); + if (url.startsWith("http://") || url.startsWith("https://")) { + return url; + } else { + return "https://$url"; + } + }) + .where((s) => s.isNotEmpty) + .toList(); +} + +enum TicketState { + newTicket('NEW'), + checking('CHECKING'), + inProgress('IN PROGRESS'), + offerAvailable('OFFER AVAILABLE'), + clearing('CLEARING'), + shipped('SHIPPED'), + refunded('REFUNDED'), + fulfilled('FULFILLED'), + pendingClose('PENDING CLOSE'), + replyNeeded('REPLY NEEDED'), + closed('CLOSED'), + closedCancelled('CLOSED/CANCELLED'), + merged('MERGED'), + unknown('UNKNOWN'); + + final String value; + const TicketState(this.value); + + static TicketState fromString(String s) { + for (final e in TicketState.values) { + if (e.value == s) return e; + } + Logging.instance.w( + "ShopInBit: unrecognised TicketState '$s' from API: " + "mapping to TicketState.unknown", + ); + return TicketState.unknown; + } + + bool get isTerminal => switch (this) { + .closed || + .closedCancelled || + .merged || + .pendingClose || + .refunded => true, + _ => false, + }; +} + +class TicketRef { + final int id; + final String number; + + /// [kind] is nullable for backwards compat only + final String? kind; + + /// True only when [kind] explicitly marks this as a receipt ticket. + /// False does not rule it out, since legacy tickets have a null [kind]. + bool get isKnownReceipt => kind == "receipt"; + + TicketRef({required this.id, required this.number, this.kind}); + + factory TicketRef.fromJson(Map json) { + return TicketRef( + id: _toInt(json['id']), + number: json['number'] as String, + kind: json['ticket_kind'] as String?, + ); + } + + Map toMap() { + return {"id": id, "number": number, "kind": kind}; + } + + @override + String toString() => toMap().toString(); +} + +class TicketStatus { + final int ticketId; + final TicketState state; + final String stateRaw; + final DateTime updatedAt; + final DateTime? lastAgentMessageAt; + final String? paymentInvoiceStatus; + final String? trackingLink; + + TicketStatus({ + required this.ticketId, + required this.state, + required this.stateRaw, + required this.updatedAt, + this.lastAgentMessageAt, + this.paymentInvoiceStatus, + this.trackingLink, + }); + + /// The tracking link(s) split into individual URLs. + /// + /// A ticket may carry zero, one, or several tracking URLs. When there are + /// several the API joins them into [trackingLink] using any of `,`, `|`, or + /// `;` as the separator (mixed separators occur in practice), so we split on + /// all three. + List get trackingLinks => splitTrackingLinks(trackingLink); + + factory TicketStatus.fromJson(Map json) { + final rawState = json['state'] as String; + return TicketStatus( + ticketId: _toInt(json['ticket_id']), + state: TicketState.fromString(rawState), + stateRaw: rawState, + updatedAt: DateTime.parse(json['updated_at'] as String), + lastAgentMessageAt: json['last_agent_message_at'] != null + ? DateTime.parse(json['last_agent_message_at'] as String) + : null, + paymentInvoiceStatus: json['payment_invoice_status'] as String?, + // Production returns "" (not null) when there is no tracking link yet; + // normalize so callers can treat it like any other absent value. + trackingLink: _emptyToNull(json['tracking_link']), + ); + } + + Map toMap() { + return { + "ticket_id": ticketId, + "state": state.toString(), + "updated_at": updatedAt.toIso8601String(), + "last_agent_message_at": lastAgentMessageAt?.toIso8601String(), + "payment_invoice_status": paymentInvoiceStatus, + "tracking_link": trackingLink, + }; + } + + @override + String toString() => toMap().toString(); +} + +class TicketFull { + final int id; + final String number; + final String? productName; + final String? customerPrice; + final String? partnerPrice; + final String? partnerCommission; + final String? netPurchasePrice; + final String? netShippingCosts; + final String deliveryCountry; + final Decimal? vatRate; + + TicketFull({ + required this.id, + required this.number, + required this.productName, + required this.customerPrice, + required this.partnerPrice, + required this.partnerCommission, + required this.netPurchasePrice, + required this.netShippingCosts, + required this.deliveryCountry, + required this.vatRate, + }); + + factory TicketFull.fromJson(Map json) { + return TicketFull( + id: _toInt(json['id']), + number: json['number'] as String, + productName: json['product_name'] as String?, + customerPrice: json['customer_price'] as String?, + partnerPrice: json['partner_price'] as String?, + partnerCommission: json['partner_commission'] as String?, + netPurchasePrice: json['net_purchase_price'] as String?, + netShippingCosts: json['net_shipping_costs'] as String?, + deliveryCountry: + (json['delivery_country'] ?? json['deliverycountry']) as String, + vatRate: Decimal.tryParse(json['vat_rate'].toString()), + ); + } + + Map toMap() { + return { + "id": id, + "number": number, + "product_name": productName, + "customer_price": customerPrice, + "partner_price": partnerPrice, + "partner_commission": partnerCommission, + "net_purchase_price": netPurchasePrice, + "net_shipping_costs": netShippingCosts, + "delivery_country": deliveryCountry, + "vat_rate": vatRate, + }; + } + + @override + String toString() => toMap().toString(); +} + +int _toInt(dynamic value) { + if (value is int) return value; + return int.parse(value.toString()); +} + +String? _emptyToNull(dynamic value) { + final s = value?.toString().trim(); + if (s == null || s.isEmpty) return null; + return s; +} diff --git a/lib/services/shopinbit/src/models/voucher.dart b/lib/services/shopinbit/src/models/voucher.dart new file mode 100644 index 0000000000..fa7e9a47eb --- /dev/null +++ b/lib/services/shopinbit/src/models/voucher.dart @@ -0,0 +1,71 @@ +class VoucherInfo { + final bool valid; + final String? voucherCode; + final double? discountAmount; + final String? voucherType; + final int? priorityLevel; + final int? usageCount; + final int? maxUsage; + final bool? isUnlimited; + final int? remainingUses; + final String? validFrom; + final String? validUntil; + final String? error; + + VoucherInfo({ + required this.valid, + this.voucherCode, + this.discountAmount, + this.voucherType, + this.priorityLevel, + this.usageCount, + this.maxUsage, + this.isUnlimited, + this.remainingUses, + this.validFrom, + this.validUntil, + this.error, + }); + + factory VoucherInfo.fromJson(Map json) { + return VoucherInfo( + valid: json['valid'] as bool, + voucherCode: json['voucher_code'] as String?, + discountAmount: (json['discount_amount'] as num?)?.toDouble(), + voucherType: json['voucher_type'] as String?, + priorityLevel: json['priority_level'] as int?, + usageCount: json['usage_count'] as int?, + maxUsage: json['max_usage'] as int?, + isUnlimited: json['is_unlimited'] as bool?, + remainingUses: json['remaining_uses'] as int?, + validFrom: json['valid_from'] as String?, + validUntil: json['valid_until'] as String?, + error: json['error'] as String?, + ); + } +} + +class VipRedemptionResult { + final int ticketId; + final String ticketNumber; + final String externalCustomerKey; + final String voucherCode; + + VipRedemptionResult({ + required this.ticketId, + required this.ticketNumber, + required this.externalCustomerKey, + required this.voucherCode, + }); + + factory VipRedemptionResult.fromJson(Map json) { + return VipRedemptionResult( + ticketId: json['ticket_id'] is int + ? json['ticket_id'] as int + : int.parse(json['ticket_id'].toString()), + ticketNumber: json['ticket_number'] as String, + externalCustomerKey: json['external_customer_key'] as String, + voucherCode: json['voucher_code'] as String, + ); + } +} diff --git a/lib/services/shopinbit/src/models/webhook_event.dart b/lib/services/shopinbit/src/models/webhook_event.dart new file mode 100644 index 0000000000..e1ff040f39 --- /dev/null +++ b/lib/services/shopinbit/src/models/webhook_event.dart @@ -0,0 +1,38 @@ +import '../../../../utilities/logger.dart'; + +enum WebhookEventType { + ticketStateChanged('ticket.state_changed'), + ticketMessageCreated('ticket.message_created'), + // Sentinel for any webhook event_type the API sends that this client does + // not recognise. Callers MUST drop these events rather than dispatch them: + // coercing an unknown event onto a known handler is worse than ignoring it. + unknown('UNKNOWN'); + + final String value; + const WebhookEventType(this.value); + + static WebhookEventType fromString(String s) { + for (final e in WebhookEventType.values) { + if (e.value == s) return e; + } + Logging.instance.w( + "ShopInBit: unrecognised WebhookEventType '$s' from API: " + "mapping to WebhookEventType.unknown (event will be dropped)", + ); + return WebhookEventType.unknown; + } +} + +class WebhookEvent { + final WebhookEventType eventType; + final Map data; + + WebhookEvent({required this.eventType, required this.data}); + + factory WebhookEvent.fromJson(Map json) { + return WebhookEvent( + eventType: WebhookEventType.fromString(json['event_type'] as String), + data: json['data'] as Map, + ); + } +} diff --git a/lib/services/shopinbit/src/token_manager.dart b/lib/services/shopinbit/src/token_manager.dart new file mode 100644 index 0000000000..a72d8135a0 --- /dev/null +++ b/lib/services/shopinbit/src/token_manager.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'dart:convert'; + +import '../../../app_config.dart'; +import '../../../networking/http.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/prefs.dart'; +import '../../tor_service.dart'; +import 'api_exception.dart'; +import 'models/auth_token.dart'; + +class TokenManager { + final String accessKey; + final String partnerSecret; + final String baseUrl; + final HTTP _httpClient; + + AuthToken? _token; + Completer? _refreshCompleter; + + TokenManager({ + required this.accessKey, + required this.partnerSecret, + required this.baseUrl, + HTTP? httpClient, + }) : _httpClient = httpClient ?? const HTTP(); + + Future getValidToken() { + if (_token != null && !_token!.expiresSoon) { + return Future.value(_token!.accessToken); + } + + if (_refreshCompleter != null) { + return _refreshCompleter!.future; + } + + final completer = Completer(); + _refreshCompleter = completer; + + _authenticate() + .then((token) { + _token = token; + completer.complete(token.accessToken); + }) + .catchError((Object e) { + completer.completeError(e); + }) + .whenComplete(() { + _refreshCompleter = null; + }); + + return completer.future; + } + + Future _authenticate() async { + final uri = Uri.parse('$baseUrl/token'); + Logging.instance.t("ShopInBitClient POST $uri (authenticate)"); + + final Response response; + try { + final formBody = Uri( + queryParameters: {'username': accessKey, 'password': partnerSecret}, + ).query; + response = await _httpClient.post( + url: uri, + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: formBody, + proxyInfo: !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ); + } catch (e, s) { + Logging.instance.e( + "ShopInBitClient authenticate() network error: ", + error: e, + stackTrace: s, + ); + throw ApiException.network(e); + } + + if (response.code != 200) { + Logging.instance.w( + "ShopInBitClient authenticate() HTTP:${response.code} " + "body: ${response.body}", + ); + throw ApiException.fromResponse(response.code, response.body); + } + + Logging.instance.t("ShopInBitClient authenticate() success"); + final json = jsonDecode(response.body) as Map; + return AuthToken.fromJson(json); + } + + void invalidate() { + _token = null; + } +} diff --git a/lib/services/shopinbit/src/webhook_verifier.dart b/lib/services/shopinbit/src/webhook_verifier.dart new file mode 100644 index 0000000000..a596a3f398 --- /dev/null +++ b/lib/services/shopinbit/src/webhook_verifier.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +class WebhookVerifier { + /// Verify a webhook delivery from ShopInBit. + /// + /// [body] is the raw request body. + /// [signatureHeader] is the `X-Concierge-Signature` header value, + /// formatted as `t=,v1=`. + /// [secret] is the subscription secret. + /// [toleranceSeconds] is the max age of the timestamp (default 300 = 5 min). + static bool verify( + String body, + String signatureHeader, + String secret, { + int toleranceSeconds = 300, + }) { + final parts = {}; + for (final segment in signatureHeader.split(',')) { + final idx = segment.indexOf('='); + if (idx == -1) continue; + parts[segment.substring(0, idx)] = segment.substring(idx + 1); + } + + final timestampStr = parts['t']; + final v1 = parts['v1']; + if (timestampStr == null || v1 == null) return false; + + final timestamp = int.tryParse(timestampStr); + if (timestamp == null) return false; + + // Check timestamp freshness. + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if ((now - timestamp).abs() > toleranceSeconds) return false; + + // Compute HMAC-SHA256 of ".". + final payload = '$timestampStr.$body'; + final key = utf8.encode(secret); + final bytes = utf8.encode(payload); + final hmac = Hmac(sha256, key); + final digest = hmac.convert(bytes); + final expected = digest.toString(); + + // Constant-time comparison. + if (expected.length != v1.length) return false; + var result = 0; + for (var i = 0; i < expected.length; i++) { + result |= expected.codeUnitAt(i) ^ v1.codeUnitAt(i); + } + return result == 0; + } +} diff --git a/lib/services/solana/solana_token_api.dart b/lib/services/solana/solana_token_api.dart new file mode 100644 index 0000000000..3798e45c2a --- /dev/null +++ b/lib/services/solana/solana_token_api.dart @@ -0,0 +1,428 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:solana/dto.dart'; +import 'package:solana/solana.dart'; + +/// Exception for Solana token API errors. +class SolanaTokenApiException implements Exception { + final String message; + final Exception? originalException; + + SolanaTokenApiException(this.message, {this.originalException}); + + @override + String toString() => 'SolanaTokenApiException: $message'; +} + +/// Result wrapper for Solana token API calls. +class SolanaTokenApiResponse { + final T? value; + final Exception? exception; + + SolanaTokenApiResponse({this.value, this.exception}); + + bool get isSuccess => exception == null && value != null; + bool get isError => exception != null; + + @override + String toString() => isSuccess ? 'Success($value)' : 'Error($exception)'; +} + +/// Data class for token account information. +class TokenAccountInfo { + final String address; + final String owner; + final String mint; + final BigInt balance; + final int decimals; + final bool isNative; + + TokenAccountInfo({ + required this.address, + required this.owner, + required this.mint, + required this.balance, + required this.decimals, + required this.isNative, + }); + + factory TokenAccountInfo.fromJson(String address, Map json) { + Map? parsed; + Map? infoMap; + + try { + final data = json['data']; + if (data is Map) { + final dataMap = Map.from(data); + final parsedVal = dataMap['parsed']; + if (parsedVal is Map) { + parsed = Map.from(parsedVal); + } + } + if (parsed != null) { + final infoVal = parsed['info']; + if (infoVal is Map) { + infoMap = Map.from(infoVal); + } + } + } catch (e) { + // Silently ignore parsing errors, use empty map + } + + final info = infoMap ?? {}; + + final owner = info['owner']; + final mint = info['mint']; + final tokenAmount = info['tokenAmount']; + final amountStr = (tokenAmount is Map) + ? (tokenAmount as Map)['amount'] + : null; + final decimalsVal = (tokenAmount is Map) + ? (tokenAmount as Map)['decimals'] + : null; + + final isNative = (parsed is Map) + ? ((parsed as Map)['type'] == 'account' && + (parsed as Map)['program'] == 'spl-token') + : false; + + return TokenAccountInfo( + address: address, + owner: owner is String ? owner : (owner?.toString() ?? ''), + mint: mint is String ? mint : (mint?.toString() ?? ''), + balance: BigInt.parse((amountStr?.toString() ?? '0')), + decimals: decimalsVal is int + ? decimalsVal + : (int.tryParse(decimalsVal?.toString() ?? '0') ?? 0), + isNative: isNative, + ); + } + + @override + String toString() => + 'TokenAccountInfo(address=$address, owner=$owner, mint=$mint, balance=$balance, decimals=$decimals)'; +} + +/// Solana SPL Token API service. +/// +/// Provides methods to interact with Solana token accounts and metadata +/// using RPC calls. Uses the solana package's RpcClient under the hood. +class SolanaTokenAPI { + static final SolanaTokenAPI _instance = SolanaTokenAPI._internal(); + + factory SolanaTokenAPI() { + return _instance; + } + + SolanaTokenAPI._internal(); + + RpcClient? _rpcClient; + + void initializeRpcClient(RpcClient rpcClient) { + _rpcClient = rpcClient; + } + + void _checkClient() { + if (_rpcClient == null) { + throw SolanaTokenApiException( + 'RPC client not initialized. Call initializeRpcClient() first.', + ); + } + } + + Future>> getTokenAccountsByOwner( + String ownerAddress, { + String? mint, + }) async { + try { + _checkClient(); + + const splTokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + + final result = await _rpcClient!.getTokenAccountsByOwner( + ownerAddress, + mint != null + ? TokenAccountsFilter.byMint(mint) + : TokenAccountsFilter.byProgramId(splTokenProgramId), + encoding: Encoding.jsonParsed, + ); + + final accountAddresses = result.value + .map((account) => account.pubkey) + .toList(); + + return SolanaTokenApiResponse>(value: accountAddresses); + } on Exception catch (e) { + return SolanaTokenApiResponse>( + exception: SolanaTokenApiException( + 'Failed to get token accounts: ${e.toString()}', + originalException: e, + ), + ); + } + } + + Future> getTokenAccountBalance( + String tokenAccountAddress, + ) async { + try { + _checkClient(); + + final response = await _rpcClient!.getAccountInfo( + tokenAccountAddress, + encoding: Encoding.jsonParsed, + ); + + if (response.value == null) { + return SolanaTokenApiResponse(value: BigInt.zero); + } + + final accountData = response.value!; + + try { + final parsedData = accountData.data; + + if (parsedData is ParsedAccountData) { + try { + final extractedBalance = parsedData.when( + splToken: (spl) { + return spl.when( + account: (info, type, accountType) { + try { + final tokenAmount = info.tokenAmount; + return BigInt.parse(tokenAmount.amount); + } catch (e) { + return null; + } + }, + mint: (info, type, accountType) => null, + unknown: (type) => null, + ); + }, + stake: (_) => null, + token2022: (token2022Data) { + return token2022Data.when( + account: (info, type, accountType) { + try { + final tokenAmount = info.tokenAmount; + return BigInt.parse(tokenAmount.amount); + } catch (e) { + return null; + } + }, + mint: (info, type, accountType) => null, + unknown: (type) => null, + ); + }, + unsupported: (_) => null, + ); + + if (extractedBalance != null && extractedBalance is BigInt) { + return SolanaTokenApiResponse( + value: extractedBalance as BigInt, + ); + } + } catch (e) { + // Ignore parsing errors. + } + } + + return SolanaTokenApiResponse(value: BigInt.zero); + } catch (e) { + return SolanaTokenApiResponse(value: BigInt.zero); + } + } on Exception catch (e) { + return SolanaTokenApiResponse( + exception: SolanaTokenApiException( + 'Failed to get token balance: ${e.toString()}', + originalException: e, + ), + ); + } + } + + // TODO: Implement full RPC call when API is ready. + Future> getTokenSupply(String mint) async { + try { + _checkClient(); + // TODO: Get the mint account info when RPC APIs are stable. + return SolanaTokenApiResponse( + value: BigInt.parse('1000000000000000000'), + ); + } on Exception catch (e) { + return SolanaTokenApiResponse( + exception: SolanaTokenApiException( + 'Failed to get token supply: ${e.toString()}', + originalException: e, + ), + ); + } + } + + // TODO: Implement full RPC call when API is ready. + Future> getTokenAccountInfo( + String tokenAccountAddress, + ) async { + try { + _checkClient(); + + // Return placeholder data. + // + // TODO: Implement actual RPC call using proper client methods. + return SolanaTokenApiResponse( + value: TokenAccountInfo( + address: tokenAccountAddress, + owner: 'placeholder_owner', + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + balance: BigInt.from(1000000000), + decimals: 6, + isNative: false, + ), + ); + } on Exception catch (e) { + return SolanaTokenApiResponse( + exception: SolanaTokenApiException( + 'Failed to get token account info: ${e.toString()}', + originalException: e, + ), + ); + } + } + + String findAssociatedTokenAddress(String ownerAddress, String mint) { + // Return a placeholder. + // + // TODO: Implement ATA derivation using Solana package. + return ''; + } + + Future> ownsToken( + String ownerAddress, + String mint, + ) async { + try { + _checkClient(); + + // Get token accounts for this owner and mint. + final accounts = await getTokenAccountsByOwner(ownerAddress, mint: mint); + + if (accounts.isError) { + return SolanaTokenApiResponse(exception: accounts.exception); + } + + // If we got token accounts, the user owns this token. + final hasTokenAccount = + accounts.value != null && (accounts.value as List).isNotEmpty; + return SolanaTokenApiResponse(value: hasTokenAccount); + } on Exception catch (e) { + return SolanaTokenApiResponse( + exception: SolanaTokenApiException( + 'Failed to check token ownership: ${e.toString()}', + originalException: e, + ), + ); + } + } + + Future?>> + fetchTokenMetadataByMint( + String mintAddress, + ) async { + try { + _checkClient(); + + // TODO: Implement proper metadata PDA derivation when solana package + // exposes findProgramAddress() utilities. + // + // The Solana Token Metadata program (metaqbxxUerdq28cj1RbAqWwTRiWLs6nshmbbuP3xqb) + // stores token metadata at a PDA derived from the mint address using: + // findProgramAddress( + // ["metadata", metadataProgram, mintPubkey], + // metadataProgram + // ) + // + // Until then, return null to allow users to enter custom token details. + + // Metadata PDA derivation not yet implemented + return SolanaTokenApiResponse?>( + value: null, + ); + } on Exception { + // On error, return null to allow user to manually enter token details + return SolanaTokenApiResponse?>( + value: null, + ); + } + } + + /// Validate if a string is a valid Solana mint address. + /// + /// A valid Solana address must: + /// - Be base58 encoded + /// - Be between 40-50 characters long + /// - Represent a valid Ed25519 public key + /// + /// Returns: true if valid, false otherwise. + bool isValidSolanaMintAddress(String address) { + try { + // Check length (Solana addresses are ~44 chars in base58). + if (address.length < 40 || address.length > 50) return false; + + // Try to parse as Ed25519 public key from base58. + Ed25519HDPublicKey.fromBase58(address); + + // Valid if parsing succeeds. + return true; + } catch (e) { + return false; + } + } + + /// Detect which token program owns a mint address. + /// + /// Queries the RPC to get the mint account info and checks which program owns it. + /// This is needed to determine whether to use standard SPL Token instructions + /// or Token-2022 (Token Extensions) instructions for transfers. + /// + /// Returns: "spl" for standard SPL Token, "token2022" for Token Extensions, or null if detection fails. + Future getTokenProgramType(String mintAddress) async { + try { + _checkClient(); + + // Query the mint account to check its owner program. + final response = await _rpcClient!.getAccountInfo( + mintAddress, + encoding: Encoding.jsonParsed, + ); + + if (response.value == null) { + return null; + } + + final owner = response.value!.owner; + + // Rough check which program owns this mint. + // + // For now all we need to know ius if it's SPL or newer. + // TODO [prio=low]: Fix via program metadata parsing or similar. + if (owner == 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA') { + return 'spl'; + } else { + if (owner.startsWith('Token')) { + return 'token2022'; + } + } + + return null; + } catch (e) { + return null; + } + } +} diff --git a/lib/services/wallets.dart b/lib/services/wallets.dart index ffdfbdc0ae..e1d38149b8 100644 --- a/lib/services/wallets.dart +++ b/lib/services/wallets.dart @@ -27,7 +27,7 @@ import '../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; import '../wallets/isar/models/wallet_info.dart'; import '../wallets/wallet/impl/epiccash_wallet.dart'; import '../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; -import '../wallets/wallet/intermediate/lib_monero_wallet.dart'; +import '../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../wallets/wallet/intermediate/lib_salvium_wallet.dart'; import '../wallets/wallet/wallet.dart'; import 'event_bus/events/wallet_added_event.dart'; @@ -126,13 +126,21 @@ class Wallets { if (info.coin is CryptonoteCurrency) { await _deleteCryptonoteWalletFilesHelper(info); } else if (info.coin is Epiccash) { - final deleteResult = await deleteEpicWallet( - walletId: walletId, - secureStore: secureStorage, - ); - Logging.instance.d( - "epic wallet: $walletId deleted with result: $deleteResult", - ); + if (wallet is! EpiccashWallet) { + Logging.instance.e( + "epic wallet: $walletId does not appear to exist???", + error: Exception(), + stackTrace: StackTrace.current, + ); + } else { + final deleteResult = await deleteEpicWallet( + wallet: wallet, + secureStore: secureStorage, + ); + Logging.instance.d( + "epic wallet: $walletId deleted with result: $deleteResult", + ); + } } else if (info.coin is Mimblewimblecoin) { final deleteResult = await deleteMimblewimblecoinWallet( walletId: walletId, @@ -201,11 +209,10 @@ class Wallets { boxName: DB.boxNameWalletsToDeleteOnStart, )) { await mainDB.isar.writeTxn( - () async => - await mainDB.isar.walletInfo - .where() - .walletIdEqualTo(walletId) - .deleteAll(), + () async => await mainDB.isar.walletInfo + .where() + .walletIdEqualTo(walletId) + .deleteAll(), ); } // clear list @@ -262,7 +269,7 @@ class Wallets { shouldAutoSyncAll || walletIdsToEnableAutoSync.contains(walletInfo.walletId); - if (wallet is LibMoneroWallet || wallet is LibSalviumWallet) { + if (wallet is CryptonoteWallet) { // walletsToInitLinearly.add(Tuple2(manager, shouldSetAutoSync)); } else { walletInitFutures.add( @@ -310,11 +317,10 @@ class Wallets { boxName: DB.boxNameWalletsToDeleteOnStart, )) { await mainDB.isar.writeTxn( - () async => - await mainDB.isar.walletInfo - .where() - .walletIdEqualTo(walletId) - .deleteAll(), + () async => await mainDB.isar.walletInfo + .where() + .walletIdEqualTo(walletId) + .deleteAll(), ); } // clear list @@ -371,7 +377,7 @@ class Wallets { nodeService: nodeService, prefs: prefs, ).then((wallet) { - if (wallet is LibMoneroWallet || wallet is LibSalviumWallet) { + if (wallet is CryptonoteWallet) { // walletsToInitLinearly.add(Tuple2(manager, shouldSetAutoSync)); walletIdCompleter.complete("dummy_ignore"); @@ -394,17 +400,15 @@ class Wallets { final asyncWalletIds = await Future.wait(walletIDInitFutures); asyncWalletIds.removeWhere((e) => e == "dummy_ignore"); - final List> walletInitFutures = - asyncWalletIds - .map( - (id) => _wallets[id]!.init().then((_) { - if (shouldAutoSyncAll || - walletIdsToEnableAutoSync.contains(id)) { - _wallets[id]!.shouldAutoSync = true; - } - }), - ) - .toList(); + final List> walletInitFutures = asyncWalletIds + .map( + (id) => _wallets[id]!.init().then((_) { + if (shouldAutoSyncAll || walletIdsToEnableAutoSync.contains(id)) { + _wallets[id]!.shouldAutoSync = true; + } + }), + ) + .toList(); if (walletInitFutures.isNotEmpty && walletsToInitLinearly.isNotEmpty) { unawaited( @@ -435,11 +439,10 @@ class Wallets { boxName: DB.boxNameWalletsToDeleteOnStart, )) { await mainDB.isar.writeTxn( - () async => - await mainDB.isar.walletInfo - .where() - .walletIdEqualTo(walletId) - .deleteAll(), + () async => await mainDB.isar.walletInfo + .where() + .walletIdEqualTo(walletId) + .deleteAll(), ); } // clear list @@ -447,15 +450,14 @@ class Wallets { boxName: DB.boxNameWalletsToDeleteOnStart, ); - final walletInfoList = - await mainDB.isar.walletInfo - .where() - .filter() - .anyOf( - AppConfig.coins.map((e) => e.identifier), - (q, element) => q.coinNameMatches(element), - ) - .findAll(); + final walletInfoList = await mainDB.isar.walletInfo + .where() + .filter() + .anyOf( + AppConfig.coins.map((e) => e.identifier), + (q, element) => q.coinNameMatches(element), + ) + .findAll(); if (isDuress) { walletInfoList.retainWhere((e) => e.isDuressVisible); @@ -509,7 +511,7 @@ class Wallets { nodeService: nodeService, prefs: prefs, ).then((wallet) { - if (wallet is LibMoneroWallet || wallet is LibSalviumWallet) { + if (wallet is CryptonoteWallet) { // walletsToInitLinearly.add(Tuple2(manager, shouldSetAutoSync)); walletIdCompleter.complete("dummy_ignore"); @@ -533,17 +535,16 @@ class Wallets { asyncWalletIds.removeWhere((e) => e == "dummy_ignore"); final List idsToRefresh = []; - final List> walletInitFutures = - asyncWalletIds - .map( - (id) => _wallets[id]!.init().then((_) { - if (shouldSyncAllOnceOnStartup || - walletIdsToSyncOnceOnStartup.contains(id)) { - idsToRefresh.add(id); - } - }), - ) - .toList(); + final List> walletInitFutures = asyncWalletIds + .map( + (id) => _wallets[id]!.init().then((_) { + if (shouldSyncAllOnceOnStartup || + walletIdsToSyncOnceOnStartup.contains(id)) { + idsToRefresh.add(id); + } + }), + ) + .toList(); Future _refreshFutures(List idsToRefresh) async { final start = DateTime.now(); @@ -620,7 +621,7 @@ class Wallets { walletIdsToEnableAutoSync.contains(wallet.walletId); if (isDesktop) { - if (wallet is LibMoneroWallet || wallet is LibSalviumWallet) { + if (wallet is CryptonoteWallet) { // walletsToInitLinearly.add(Tuple2(manager, shouldSetAutoSync)); } else { walletInitFutures.add( diff --git a/lib/themes/coin_icon_provider.dart b/lib/themes/coin_icon_provider.dart index 1deb22b4e1..a732c69c14 100644 --- a/lib/themes/coin_icon_provider.dart +++ b/lib/themes/coin_icon_provider.dart @@ -9,9 +9,10 @@ */ import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../models/isar/stack_theme.dart'; -import 'theme_providers.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; +import 'theme_providers.dart'; final coinIconProvider = Provider.family((ref, coin) { final assets = ref.watch(themeAssetsProvider); @@ -28,8 +29,6 @@ final coinIconProvider = Provider.family((ref, coin) { return assets.dogecoin; case const (Epiccash): return assets.epicCash; - case const (Mimblewimblecoin): - return assets.mimblewimblecoin; case const (Firo): return assets.firo; case const (Monero): diff --git a/lib/themes/stack_colors.dart b/lib/themes/stack_colors.dart index 29804aa0c5..ae82bf3827 100644 --- a/lib/themes/stack_colors.dart +++ b/lib/themes/stack_colors.dart @@ -45,7 +45,7 @@ class StackColors extends ThemeExtension { final Color textError; final Color textRestore; -// button background + // button background final Color buttonBackPrimary; final Color buttonBackSecondary; final Color buttonBackPrimaryDisabled; @@ -58,7 +58,7 @@ class StackColors extends ThemeExtension { final Color numpadBackDefault; final Color bottomNavBack; -// button text/element + // button text/element final Color buttonTextPrimary; final Color buttonTextSecondary; final Color buttonTextPrimaryDisabled; @@ -73,17 +73,17 @@ class StackColors extends ThemeExtension { final Color customTextButtonEnabledText; final Color customTextButtonDisabledText; -// switch background + // switch background final Color switchBGOn; final Color switchBGOff; final Color switchBGDisabled; -// switch circle + // switch circle final Color switchCircleOn; final Color switchCircleOff; final Color switchCircleDisabled; -// step indicator background + // step indicator background final Color stepIndicatorBGCheck; final Color stepIndicatorBGNumber; final Color stepIndicatorBGInactive; @@ -93,7 +93,7 @@ class StackColors extends ThemeExtension { final Color stepIndicatorIconNumber; final Color stepIndicatorIconInactive; -// checkbox + // checkbox final Color checkboxBGChecked; final Color checkboxBorderEmpty; final Color checkboxBGDisabled; @@ -101,7 +101,7 @@ class StackColors extends ThemeExtension { final Color checkboxIconDisabled; final Color checkboxTextLabel; -// snack bar + // snack bar final Color snackBarBackSuccess; final Color snackBarBackError; final Color snackBarBackInfo; @@ -109,7 +109,7 @@ class StackColors extends ThemeExtension { final Color snackBarTextError; final Color snackBarTextInfo; -// icons + // icons final Color bottomNavIconBack; final Color bottomNavIconIcon; final Color bottomNavIconIconHighlighted; @@ -122,7 +122,7 @@ class StackColors extends ThemeExtension { final Color settingsIconBack2; final Color settingsIconElement; -// text field + // text field final Color textFieldActiveBG; final Color textFieldDefaultBG; final Color textFieldErrorBG; @@ -145,12 +145,12 @@ class StackColors extends ThemeExtension { final Color textFieldErrorSearchIconRight; final Color textFieldSuccessSearchIconRight; -// settings item level2 + // settings item level2 final Color settingsItem2ActiveBG; final Color settingsItem2ActiveText; final Color settingsItem2ActiveSub; -// radio buttons + // radio buttons final Color radioButtonIconBorder; final Color radioButtonIconBorderDisabled; final Color radioButtonBorderEnabled; @@ -162,19 +162,19 @@ class StackColors extends ThemeExtension { final Color radioButtonLabelEnabled; final Color radioButtonLabelDisabled; -// info text + // info text final Color infoItemBG; final Color infoItemLabel; final Color infoItemText; final Color infoItemIcons; -// popup + // popup final Color popupBG; -// currency list + // currency list final Color currencyListItemBG; -// bottom nav + // bottom nav final Color stackWalletBG; final Color stackWalletMid; final Color stackWalletBottom; @@ -192,7 +192,7 @@ class StackColors extends ThemeExtension { final Color textConfirmTotalAmount; final Color textSelectedWordTableItem; -// rate type toggle + // rate type toggle final Color rateTypeToggleColorOn; final Color rateTypeToggleColorOff; final Color rateTypeToggleDesktopColorOn; @@ -732,7 +732,8 @@ class StackColors extends ThemeExtension { buttonBackBorderDisabled ?? this.buttonBackBorderDisabled, buttonBackBorderSecondary: buttonBackBorderSecondary ?? this.buttonBackBorderSecondary, - buttonBackBorderSecondaryDisabled: buttonBackBorderSecondaryDisabled ?? + buttonBackBorderSecondaryDisabled: + buttonBackBorderSecondaryDisabled ?? this.buttonBackBorderSecondaryDisabled, numberBackDefault: numberBackDefault ?? this.numberBackDefault, numpadBackDefault: numpadBackDefault ?? this.numpadBackDefault, @@ -824,11 +825,13 @@ class StackColors extends ThemeExtension { textFieldSuccessLabel ?? this.textFieldSuccessLabel, textFieldActiveSearchIconRight: textFieldActiveSearchIconRight ?? this.textFieldActiveSearchIconRight, - textFieldDefaultSearchIconRight: textFieldDefaultSearchIconRight ?? + textFieldDefaultSearchIconRight: + textFieldDefaultSearchIconRight ?? this.textFieldDefaultSearchIconRight, textFieldErrorSearchIconRight: textFieldErrorSearchIconRight ?? this.textFieldErrorSearchIconRight, - textFieldSuccessSearchIconRight: textFieldSuccessSearchIconRight ?? + textFieldSuccessSearchIconRight: + textFieldSuccessSearchIconRight ?? this.textFieldSuccessSearchIconRight, settingsItem2ActiveBG: settingsItem2ActiveBG ?? this.settingsItem2ActiveBG, @@ -919,26 +922,14 @@ class StackColors extends ThemeExtension { gradientBackground: other.gradientBackground, homeViewButtonBarBoxShadow: other.homeViewButtonBarBoxShadow, standardBoxShadow: other.standardBoxShadow, - background: Color.lerp( - background, - other.background, - t, - )!, + background: Color.lerp(background, other.background, t)!, backgroundAppBar: Color.lerp( backgroundAppBar, other.backgroundAppBar, t, )!, - overlay: Color.lerp( - overlay, - other.overlay, - t, - )!, - accentColorBlue: Color.lerp( - accentColorBlue, - other.accentColorBlue, - t, - )!, + overlay: Color.lerp(overlay, other.overlay, t)!, + accentColorBlue: Color.lerp(accentColorBlue, other.accentColorBlue, t)!, accentColorGreen: Color.lerp( accentColorGreen, other.accentColorGreen, @@ -949,91 +940,31 @@ class StackColors extends ThemeExtension { other.accentColorYellow, t, )!, - accentColorRed: Color.lerp( - accentColorRed, - other.accentColorRed, - t, - )!, + accentColorRed: Color.lerp(accentColorRed, other.accentColorRed, t)!, accentColorOrange: Color.lerp( accentColorOrange, other.accentColorOrange, t, )!, - accentColorDark: Color.lerp( - accentColorDark, - other.accentColorDark, - t, - )!, - shadow: Color.lerp( - shadow, - other.shadow, - t, - )!, - textDark: Color.lerp( - textDark, - other.textDark, - t, - )!, - textDark2: Color.lerp( - textDark2, - other.textDark2, - t, - )!, - textDark3: Color.lerp( - textDark3, - other.textDark3, - t, - )!, - textSubtitle1: Color.lerp( - textSubtitle1, - other.textSubtitle1, - t, - )!, - textSubtitle2: Color.lerp( - textSubtitle2, - other.textSubtitle2, - t, - )!, - textSubtitle3: Color.lerp( - textSubtitle3, - other.textSubtitle3, - t, - )!, - textSubtitle4: Color.lerp( - textSubtitle4, - other.textSubtitle4, - t, - )!, - textSubtitle5: Color.lerp( - textSubtitle5, - other.textSubtitle5, - t, - )!, - textSubtitle6: Color.lerp( - textSubtitle6, - other.textSubtitle6, - t, - )!, - textWhite: Color.lerp( - textWhite, - other.textWhite, - t, - )!, + accentColorDark: Color.lerp(accentColorDark, other.accentColorDark, t)!, + shadow: Color.lerp(shadow, other.shadow, t)!, + textDark: Color.lerp(textDark, other.textDark, t)!, + textDark2: Color.lerp(textDark2, other.textDark2, t)!, + textDark3: Color.lerp(textDark3, other.textDark3, t)!, + textSubtitle1: Color.lerp(textSubtitle1, other.textSubtitle1, t)!, + textSubtitle2: Color.lerp(textSubtitle2, other.textSubtitle2, t)!, + textSubtitle3: Color.lerp(textSubtitle3, other.textSubtitle3, t)!, + textSubtitle4: Color.lerp(textSubtitle4, other.textSubtitle4, t)!, + textSubtitle5: Color.lerp(textSubtitle5, other.textSubtitle5, t)!, + textSubtitle6: Color.lerp(textSubtitle6, other.textSubtitle6, t)!, + textWhite: Color.lerp(textWhite, other.textWhite, t)!, textFavoriteCard: Color.lerp( textFavoriteCard, other.textFavoriteCard, t, )!, - textError: Color.lerp( - textError, - other.textError, - t, - )!, - textRestore: Color.lerp( - textRestore, - other.textRestore, - t, - )!, + textError: Color.lerp(textError, other.textError, t)!, + textRestore: Color.lerp(textRestore, other.textRestore, t)!, buttonBackPrimary: Color.lerp( buttonBackPrimary, other.buttonBackPrimary, @@ -1084,11 +1015,7 @@ class StackColors extends ThemeExtension { other.numpadBackDefault, t, )!, - bottomNavBack: Color.lerp( - bottomNavBack, - other.bottomNavBack, - t, - )!, + bottomNavBack: Color.lerp(bottomNavBack, other.bottomNavBack, t)!, buttonTextPrimary: Color.lerp( buttonTextPrimary, other.buttonTextPrimary, @@ -1139,11 +1066,7 @@ class StackColors extends ThemeExtension { other.numpadTextDefault, t, )!, - bottomNavText: Color.lerp( - bottomNavText, - other.bottomNavText, - t, - )!, + bottomNavText: Color.lerp(bottomNavText, other.bottomNavText, t)!, customTextButtonEnabledText: Color.lerp( customTextButtonEnabledText, other.customTextButtonEnabledText, @@ -1154,31 +1077,15 @@ class StackColors extends ThemeExtension { other.customTextButtonDisabledText, t, )!, - switchBGOn: Color.lerp( - switchBGOn, - other.switchBGOn, - t, - )!, - switchBGOff: Color.lerp( - switchBGOff, - other.switchBGOff, - t, - )!, + switchBGOn: Color.lerp(switchBGOn, other.switchBGOn, t)!, + switchBGOff: Color.lerp(switchBGOff, other.switchBGOff, t)!, switchBGDisabled: Color.lerp( switchBGDisabled, other.switchBGDisabled, t, )!, - switchCircleOn: Color.lerp( - switchCircleOn, - other.switchCircleOn, - t, - )!, - switchCircleOff: Color.lerp( - switchCircleOff, - other.switchCircleOff, - t, - )!, + switchCircleOn: Color.lerp(switchCircleOn, other.switchCircleOn, t)!, + switchCircleOff: Color.lerp(switchCircleOff, other.switchCircleOff, t)!, switchCircleDisabled: Color.lerp( switchCircleDisabled, other.switchCircleDisabled, @@ -1304,21 +1211,13 @@ class StackColors extends ThemeExtension { other.topNavIconPrimary, t, )!, - topNavIconGreen: Color.lerp( - topNavIconGreen, - other.topNavIconGreen, - t, - )!, + topNavIconGreen: Color.lerp(topNavIconGreen, other.topNavIconGreen, t)!, topNavIconYellow: Color.lerp( topNavIconYellow, other.topNavIconYellow, t, )!, - topNavIconRed: Color.lerp( - topNavIconRed, - other.topNavIconRed, - t, - )!, + topNavIconRed: Color.lerp(topNavIconRed, other.topNavIconRed, t)!, settingsIconBack: Color.lerp( settingsIconBack, other.settingsIconBack, @@ -1509,56 +1408,24 @@ class StackColors extends ThemeExtension { other.radioButtonLabelDisabled, t, )!, - infoItemBG: Color.lerp( - infoItemBG, - other.infoItemBG, - t, - )!, - infoItemLabel: Color.lerp( - infoItemLabel, - other.infoItemLabel, - t, - )!, - infoItemText: Color.lerp( - infoItemText, - other.infoItemText, - t, - )!, - infoItemIcons: Color.lerp( - infoItemIcons, - other.infoItemIcons, - t, - )!, - popupBG: Color.lerp( - popupBG, - other.popupBG, - t, - )!, + infoItemBG: Color.lerp(infoItemBG, other.infoItemBG, t)!, + infoItemLabel: Color.lerp(infoItemLabel, other.infoItemLabel, t)!, + infoItemText: Color.lerp(infoItemText, other.infoItemText, t)!, + infoItemIcons: Color.lerp(infoItemIcons, other.infoItemIcons, t)!, + popupBG: Color.lerp(popupBG, other.popupBG, t)!, currencyListItemBG: Color.lerp( currencyListItemBG, other.currencyListItemBG, t, )!, - stackWalletBG: Color.lerp( - stackWalletBG, - other.stackWalletBG, - t, - )!, - stackWalletMid: Color.lerp( - stackWalletMid, - other.stackWalletMid, - t, - )!, + stackWalletBG: Color.lerp(stackWalletBG, other.stackWalletBG, t)!, + stackWalletMid: Color.lerp(stackWalletMid, other.stackWalletMid, t)!, stackWalletBottom: Color.lerp( stackWalletBottom, other.stackWalletBottom, t, )!, - bottomNavShadow: Color.lerp( - bottomNavShadow, - other.bottomNavShadow, - t, - )!, + bottomNavShadow: Color.lerp(bottomNavShadow, other.bottomNavShadow, t)!, favoriteStarActive: Color.lerp( favoriteStarActive, other.favoriteStarActive, @@ -1569,16 +1436,8 @@ class StackColors extends ThemeExtension { other.favoriteStarInactive, t, )!, - splash: Color.lerp( - splash, - other.splash, - t, - )!, - highlight: Color.lerp( - highlight, - other.highlight, - t, - )!, + splash: Color.lerp(splash, other.splash, t)!, + highlight: Color.lerp(highlight, other.highlight, t)!, warningForeground: Color.lerp( warningForeground, other.warningForeground, @@ -1629,26 +1488,14 @@ class StackColors extends ThemeExtension { other.rateTypeToggleDesktopColorOff, t, )!, - ethTagText: Color.lerp( - ethTagText, - other.ethTagText, - t, - )!, - ethTagBG: Color.lerp( - ethTagBG, - other.ethTagBG, - t, - )!, + ethTagText: Color.lerp(ethTagText, other.ethTagText, t)!, + ethTagBG: Color.lerp(ethTagBG, other.ethTagBG, t)!, ethWalletTagText: Color.lerp( ethWalletTagText, other.ethWalletTagText, t, )!, - ethWalletTagBG: Color.lerp( - ethWalletTagBG, - other.ethWalletTagBG, - t, - )!, + ethWalletTagBG: Color.lerp(ethWalletTagBG, other.ethWalletTagBG, t)!, tokenSummaryTextPrimary: Color.lerp( tokenSummaryTextPrimary, other.tokenSummaryTextPrimary, @@ -1659,11 +1506,7 @@ class StackColors extends ThemeExtension { other.tokenSummaryTextSecondary, t, )!, - tokenSummaryBG: Color.lerp( - tokenSummaryBG, - other.tokenSummaryBG, - t, - )!, + tokenSummaryBG: Color.lerp(tokenSummaryBG, other.tokenSummaryBG, t)!, tokenSummaryButtonBG: Color.lerp( tokenSummaryButtonBG, other.tokenSummaryButtonBG, @@ -1695,14 +1538,17 @@ class StackColors extends ThemeExtension { case "Finished": case "finished": case "Completed": + case "success": return accentColorGreen; case "Failed": case "failed": case "closed": case "expired": + case "overdue": return accentColorRed; case "Refunded": case "refunded": + case "refund": return textSubtitle2; default: return const Color(0xFFD3A90F); @@ -1711,125 +1557,95 @@ class StackColors extends ThemeExtension { ButtonStyle? getDeleteEnabledButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - textFieldErrorBG, - ), - ); + backgroundColor: MaterialStateProperty.all(textFieldErrorBG), + ); ButtonStyle? getDeleteDisabledButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - buttonBackSecondaryDisabled, - ), - ); + backgroundColor: MaterialStateProperty.all( + buttonBackSecondaryDisabled, + ), + ); ButtonStyle? getPrimaryEnabledButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - buttonBackPrimary, - ), - ); + backgroundColor: MaterialStateProperty.all(buttonBackPrimary), + ); ButtonStyle? getPrimaryDisabledButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - buttonBackPrimaryDisabled, - ), - ); + backgroundColor: MaterialStateProperty.all( + buttonBackPrimaryDisabled, + ), + ); ButtonStyle? getOutlineBlueButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - Colors.transparent, - ), - side: MaterialStateProperty.all( - BorderSide( - color: customTextButtonEnabledText, - ), - ), - ); + backgroundColor: MaterialStateProperty.all(Colors.transparent), + side: MaterialStateProperty.all( + BorderSide(color: customTextButtonEnabledText), + ), + ); ButtonStyle? getOutlineBlueButtonDisabledStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - Colors.transparent, - ), - side: MaterialStateProperty.all( - BorderSide( - color: customTextButtonDisabledText, - ), - ), - ); + backgroundColor: MaterialStateProperty.all(Colors.transparent), + side: MaterialStateProperty.all( + BorderSide(color: customTextButtonDisabledText), + ), + ); ButtonStyle? getSecondaryEnabledButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - buttonBackSecondary, - ), - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - side: BorderSide( - color: buttonBackBorderSecondary, - width: 1, - ), - borderRadius: BorderRadius.circular(10000), - ), - ), - ); + backgroundColor: MaterialStateProperty.all(buttonBackSecondary), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + side: BorderSide(color: buttonBackBorderSecondary, width: 1), + borderRadius: BorderRadius.circular(10000), + ), + ), + ); ButtonStyle? getSecondaryDisabledButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - buttonBackSecondaryDisabled, - ), - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - side: BorderSide( - color: buttonBackBorderSecondaryDisabled, - width: 1, - ), - borderRadius: BorderRadius.circular(10000), - ), + backgroundColor: MaterialStateProperty.all( + buttonBackSecondaryDisabled, + ), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + side: BorderSide( + color: buttonBackBorderSecondaryDisabled, + width: 1, ), - ); + borderRadius: BorderRadius.circular(10000), + ), + ), + ); ButtonStyle? getSmallSecondaryEnabledButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - textFieldDefaultBG, - ), - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - side: BorderSide( - color: buttonBackBorderSecondary, - width: 1, - ), - borderRadius: BorderRadius.circular(10000), - ), - ), - ); + backgroundColor: MaterialStateProperty.all(textFieldDefaultBG), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + side: BorderSide(color: buttonBackBorderSecondary, width: 1), + borderRadius: BorderRadius.circular(10000), + ), + ), + ); ButtonStyle? getDesktopMenuButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - popupBG, - ), - ); + backgroundColor: MaterialStateProperty.all(popupBG), + ); ButtonStyle? getDesktopMenuButtonStyleSelected(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - textFieldDefaultBG, - ), - ); + backgroundColor: MaterialStateProperty.all(textFieldDefaultBG), + ); ButtonStyle? getDesktopSettingsButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - background, - ), - overlayColor: MaterialStateProperty.all( - Colors.transparent, - ), - ); + backgroundColor: MaterialStateProperty.all(background), + overlayColor: MaterialStateProperty.all(Colors.transparent), + ); } diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index ff0880cec7..cb1f5f8ad6 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -23,6 +23,7 @@ class AddressUtils { 'tx_payment_id', 'recipient_name', 'tx_description', + 'op_return', // For Rosen Bridge and other OP_RETURN protocols. // TODO [prio=med]: Add more recognized params for other coins. }; @@ -268,24 +269,107 @@ class AddressUtils { if ((mimblewimblecoinAddress.startsWith("http://") || mimblewimblecoinAddress.startsWith("https://")) && mimblewimblecoinAddress.contains("@")) { - mimblewimblecoinAddress = - mimblewimblecoinAddress.replaceAll("http://", ""); - mimblewimblecoinAddress = - mimblewimblecoinAddress.replaceAll("https://", ""); + mimblewimblecoinAddress = mimblewimblecoinAddress.replaceAll( + "http://", + "", + ); + mimblewimblecoinAddress = mimblewimblecoinAddress.replaceAll( + "https://", + "", + ); } // strip mailto: prefix if (mimblewimblecoinAddress.startsWith("mailto:")) { - mimblewimblecoinAddress = - mimblewimblecoinAddress.replaceAll("mailto:", ""); + mimblewimblecoinAddress = mimblewimblecoinAddress.replaceAll( + "mailto:", + "", + ); } // strip / suffix if the address contains an @ symbol (and is thus an mwcmqs address) if (mimblewimblecoinAddress.endsWith("/") && mimblewimblecoinAddress.contains("@")) { mimblewimblecoinAddress = mimblewimblecoinAddress.substring( - 0, mimblewimblecoinAddress.length - 1); + 0, + mimblewimblecoinAddress.length - 1, + ); } return mimblewimblecoinAddress; } + + /// Formats OP_RETURN hex data for display in tooltip. + /// If data matches Rosen Bridge format, shows structured fields. + /// Otherwise returns the raw hex with a generic description. + static String formatOpReturnTooltip(String hex) { + // Rosen Bridge OP_RETURN format: + // toChain(1B) + bridgeFee(8B) + networkFee(8B) + addrLen(1B) + toAddress(var) + const minRosenLen = 36; // minimum 18 bytes + if (hex.length < minRosenLen) { + return "Raw OP_RETURN data:\n$hex"; + } + + try { + // Must match @rosen-bridge/rosen-extractor SUPPORTED_CHAINS order. + const chains = [ + 'ergo', + 'cardano', + 'bitcoin', + 'ethereum', + 'binance', + 'base', + 'doge', + 'bitcoin-runes', + 'firo', + 'handshake', + ]; + + final toChainCode = int.parse(hex.substring(0, 2), radix: 16); + if (toChainCode >= chains.length) { + return "Raw OP_RETURN data:\n$hex"; + } + + final bridgeFee = BigInt.parse( + hex.substring(2, 18), + radix: 16, + ).toString(); + final networkFee = BigInt.parse( + hex.substring(18, 34), + radix: 16, + ).toString(); + final addrLen = int.parse(hex.substring(34, 36), radix: 16); + final addrEnd = 36 + addrLen * 2; + if (hex.length < addrEnd) { + return "Raw OP_RETURN data:\n$hex"; + } + final toAddressHex = hex.substring(36, addrEnd); + + return "Rosen Bridge data\n" + " To chain: ${chains[toChainCode]}\n" + " Bridge fee: $bridgeFee\n" + " Network fee: $networkFee\n" + " To address (hex): $toAddressHex"; + } catch (_) { + return "Raw OP_RETURN data:\n$hex"; + } + } + + static int opReturnOutputVSizeFromHex(String hex) { + if (hex.length.isOdd || !RegExp(r'^[0-9a-fA-F]*$').hasMatch(hex)) { + throw const FormatException("Invalid OP_RETURN hex"); + } + + final dataBytes = hex.length ~/ 2; + if (dataBytes > 80) { + throw FormatException( + "OP_RETURN data exceeds 80 byte limit: $dataBytes bytes", + ); + } + + final pushPrefixBytes = dataBytes <= 75 ? 1 : 2; + final scriptBytes = 1 + pushPrefixBytes + dataBytes; + + // value(8) + compact script length(1, since max script is 83 bytes) + script + return 8 + 1 + scriptBytes; + } } class PaymentUriData { diff --git a/lib/utilities/amount/amount.dart b/lib/utilities/amount/amount.dart index a1a68576f2..b31d87d7a0 100644 --- a/lib/utilities/amount/amount.dart +++ b/lib/utilities/amount/amount.dart @@ -15,31 +15,24 @@ import 'package:decimal/decimal.dart'; import '../util.dart'; class Amount { - Amount({ - required BigInt rawValue, - required this.fractionDigits, - }) : assert(fractionDigits >= 0), - _value = rawValue; + const Amount({required BigInt rawValue, required this.fractionDigits}) + : assert(fractionDigits >= 0), + _value = rawValue; /// special zero case with [fractionDigits] set to 0 - static Amount get zero => Amount( - rawValue: BigInt.zero, - fractionDigits: 0, - ); + static Amount get zero => .zeroWith(fractionDigits: 0); - Amount.zeroWith({required this.fractionDigits}) - : assert(fractionDigits >= 0), - _value = BigInt.zero; + Amount.zeroWith({required int fractionDigits}) + : this(rawValue: BigInt.from(0), fractionDigits: fractionDigits); /// truncate decimal value to [fractionDigits] places - Amount.fromDecimal(Decimal amount, {required this.fractionDigits}) - : assert(fractionDigits >= 0), - _value = amount.shift(fractionDigits).toBigInt(); - - static Amount? tryParseFiatString( - String value, { - required String locale, - }) { + Amount.fromDecimal(Decimal amount, {required int fractionDigits}) + : this( + rawValue: amount.shift(fractionDigits).toBigInt(), + fractionDigits: fractionDigits, + ); + + static Amount? tryParseFiatString(String value, {required String locale}) { final parts = value.split(" "); if (parts.first.isEmpty) { @@ -98,9 +91,7 @@ class Amount { return jsonEncode(toMap()); } - String fiatString({ - required String locale, - }) { + String fiatString({required String locale}) { final wholeNumber = decimal.truncate(); // get number symbols for decimal place and group separator @@ -172,10 +163,7 @@ class Amount { "fractionDigits do not match: this=$this, other=$other", ); } - return Amount( - rawValue: raw + other.raw, - fractionDigits: fractionDigits, - ); + return Amount(rawValue: raw + other.raw, fractionDigits: fractionDigits); } Amount operator -(Amount other) { @@ -184,10 +172,7 @@ class Amount { "fractionDigits do not match: this=$this, other=$other", ); } - return Amount( - rawValue: raw - other.raw, - fractionDigits: fractionDigits, - ); + return Amount(rawValue: raw - other.raw, fractionDigits: fractionDigits); } Amount operator *(Amount other) { @@ -196,10 +181,7 @@ class Amount { "fractionDigits do not match: this=$this, other=$other", ); } - return Amount( - rawValue: raw * other.raw, - fractionDigits: fractionDigits, - ); + return Amount(rawValue: raw * other.raw, fractionDigits: fractionDigits); } // =========================================================================== @@ -226,18 +208,12 @@ class Amount { extension DecimalAmountExt on Decimal { Amount toAmount({required int fractionDigits}) { - return Amount.fromDecimal( - this, - fractionDigits: fractionDigits, - ); + return Amount.fromDecimal(this, fractionDigits: fractionDigits); } } extension IntAmountExtension on int { Amount toAmountAsRaw({required int fractionDigits}) { - return Amount( - rawValue: BigInt.from(this), - fractionDigits: fractionDigits, - ); + return Amount(rawValue: BigInt.from(this), fractionDigits: fractionDigits); } } diff --git a/lib/utilities/amount/amount_formatter.dart b/lib/utilities/amount/amount_formatter.dart index 44746b8cdb..6a6f01f7b9 100644 --- a/lib/utilities/amount/amount_formatter.dart +++ b/lib/utilities/amount/amount_formatter.dart @@ -1,28 +1,27 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../models/isar/models/ethereum/eth_contract.dart'; + +import '../../models/isar/models/contract.dart'; import '../../providers/global/locale_provider.dart'; import '../../providers/global/prefs_provider.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; import 'amount.dart'; import 'amount_unit.dart'; -import '../../wallets/crypto_currency/crypto_currency.dart'; final pAmountUnit = Provider.family( (ref, coin) => ref.watch( - prefsChangeNotifierProvider.select( - (value) => value.amountUnit(coin), - ), + prefsChangeNotifierProvider.select((value) => value.amountUnit(coin)), ), ); final pMaxDecimals = Provider.family( (ref, coin) => ref.watch( - prefsChangeNotifierProvider.select( - (value) => value.maxDecimals(coin), - ), + prefsChangeNotifierProvider.select((value) => value.maxDecimals(coin)), ), ); -final pAmountFormatter = - Provider.family((ref, coin) { +final pAmountFormatter = Provider.family(( + ref, + coin, +) { final locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), ); @@ -51,7 +50,7 @@ class AmountFormatter { String format( Amount amount, { String? overrideUnit, - EthContract? ethContract, + Contract? tokenContract, bool withUnitName = true, bool indicatePrecisionLoss = true, }) { @@ -63,19 +62,16 @@ class AmountFormatter { withUnitName: withUnitName, indicatePrecisionLoss: indicatePrecisionLoss, overrideUnit: overrideUnit, - tokenContract: ethContract, + tokenContract: tokenContract, ); } - Amount? tryParse( - String string, { - EthContract? ethContract, - }) { + Amount? tryParse(String string, {Contract? tokenContract}) { return unit.tryParse( string, locale: locale, coin: coin, - tokenContract: ethContract, + tokenContract: tokenContract, ); } } diff --git a/lib/utilities/amount/amount_unit.dart b/lib/utilities/amount/amount_unit.dart index 79e45232b3..0d96fbdeff 100644 --- a/lib/utilities/amount/amount_unit.dart +++ b/lib/utilities/amount/amount_unit.dart @@ -11,11 +11,14 @@ import 'dart:math' as math; import 'package:decimal/decimal.dart'; + +import '../../models/isar/models/contract.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; -import 'amount.dart'; -import '../util.dart'; +import '../../models/isar/models/solana/sol_contract.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/crypto_currency/intermediate/nano_currency.dart'; +import '../util.dart'; +import 'amount.dart'; // preserve index order as index is used to store value in preferences enum AmountUnit { @@ -29,8 +32,7 @@ enum AmountUnit { zepto(21), yocto(24), ronto(27), - quecto(30), - ; + quecto(30); const AmountUnit(this.shift); final int shift; @@ -169,9 +171,28 @@ extension AmountUnitExt on AmountUnit { case AmountUnit.atto: return "wei"; default: - throw ArgumentError( - "Does eth even allow more than 18 decimal places?", - ); + throw ArgumentError("Does eth even allow more than 18 decimal places?"); + } + } + + String unitForSplToken(SolContract token) { + switch (this) { + case AmountUnit.normal: + return token.symbol; + case AmountUnit.milli: + return "m${token.symbol}"; + case AmountUnit.micro: + return "µ${token.symbol}"; + case AmountUnit.nano: + case AmountUnit.pico: + case AmountUnit.femto: + case AmountUnit.atto: + case AmountUnit.zepto: + case AmountUnit.yocto: + case AmountUnit.ronto: + case AmountUnit.quecto: + // For SOL tokens, just use the symbol with the prefix if applicable. + return token.symbol; } } @@ -179,7 +200,7 @@ extension AmountUnitExt on AmountUnit { String value, { required String locale, required CryptoCurrency coin, - EthContract? tokenContract, + Contract? tokenContract, bool overrideWithDecimalPlacesFromString = false, }) { final precisionLost = value.startsWith("~"); @@ -230,7 +251,7 @@ extension AmountUnitExt on AmountUnit { bool withUnitName = true, bool indicatePrecisionLoss = true, String? overrideUnit, - EthContract? tokenContract, + Contract? tokenContract, }) { assert(maxDecimalPlaces >= 0); @@ -292,8 +313,9 @@ extension AmountUnitExt on AmountUnit { if (remainder.length > actualDecimalPlaces) { // check for loss of precision - final remainingRemainder = - BigInt.tryParse(remainder.substring(actualDecimalPlaces)); + final remainingRemainder = BigInt.tryParse( + remainder.substring(actualDecimalPlaces), + ); if (remainingRemainder != null) { didLosePrecision = remainingRemainder > BigInt.zero; } @@ -327,8 +349,10 @@ extension AmountUnitExt on AmountUnit { } // return the value with the proper unit symbol - if (tokenContract != null) { + if (tokenContract is EthContract) { overrideUnit = unitForContract(tokenContract); + } else if (tokenContract is SolContract) { + overrideUnit = unitForSplToken(tokenContract); } return "$returnValue ${overrideUnit ?? unitForCoin(coin)}"; diff --git a/lib/utilities/assets.dart b/lib/utilities/assets.dart index e75bf8eb4e..ac6693a02e 100644 --- a/lib/utilities/assets.dart +++ b/lib/utilities/assets.dart @@ -11,9 +11,13 @@ import 'package:flutter/material.dart'; import '../services/exchange/change_now/change_now_exchange.dart'; +import '../services/exchange/cyphergoat/cyphergoat_exchange.dart'; +import '../services/exchange/exolix/exolix_exchange.dart'; +import '../services/exchange/lets_exchange/lets_exchange_exchange.dart'; import '../services/exchange/nanswap/nanswap_exchange.dart'; import '../services/exchange/simpleswap/simpleswap_exchange.dart'; import '../services/exchange/trocador/trocador_exchange.dart'; +import '../services/exchange/wizard_swap/wizard_swap_exchange.dart'; abstract class Assets { static const svg = _SVG(); @@ -47,6 +51,12 @@ class _EXCHANGE { // String get majesticBankGreen => "${_path}mb_green.svg"; String get trocador => "${_path}trocador.svg"; String get nanswap => "${_path}nanswap.svg"; + String get wizard => "${_path}wizard.svg"; + + String get exolix => "${_path}exolix.png"; + String get cypherGoat => "${_path}cyphergoat.svg"; + + String get letsexchange => "${_path}letsexchange.svg"; String getIconFor({required String exchangeName}) { switch (exchangeName) { @@ -60,6 +70,14 @@ class _EXCHANGE { return trocador; case NanswapExchange.exchangeName: return nanswap; + case WizardSwapExchange.exchangeName: + return wizard; + case ExolixExchange.exchangeName: + return exolix; + case CypherGoatExchange.exchangeName: + return cypherGoat; + case LetsExchangeExchange.exchangeName: + return letsexchange; default: throw ArgumentError( "Invalid exchange name passed to " @@ -233,30 +251,9 @@ class _SVG { String get trocadorRatingC => "assets/svg/trocador_rating_c.svg"; String get trocadorRatingD => "assets/svg/trocador_rating_d.svg"; - // TODO provide proper assets - String get bitcoinTestnet => "assets/svg/coin_icons/Bitcoin.svg"; - String get bitcoincashTestnet => "assets/svg/coin_icons/Bitcoincash.svg"; - String get firoTestnet => "assets/svg/coin_icons/Firo.svg"; - String get dogecoinTestnet => "assets/svg/coin_icons/Dogecoin.svg"; - String get particlTestnet => "assets/svg/coin_icons/Particl.svg"; - - // small icons - String get bitcoin => "assets/svg/coin_icons/Bitcoin.svg"; - String get litecoin => "assets/svg/coin_icons/Litecoin.svg"; - String get bitcoincash => "assets/svg/coin_icons/Bitcoincash.svg"; - String get dogecoin => "assets/svg/coin_icons/Dogecoin.svg"; - String get epicCash => "assets/svg/coin_icons/EpicCash.svg"; - String get mimblewimblecoin => "assets/svg/coin_icons/Mimblewimblecoin.svg"; - String get ethereum => "assets/svg/coin_icons/Ethereum.svg"; - String get firo => "assets/svg/coin_icons/Firo.svg"; - String get monero => "assets/svg/coin_icons/Monero.svg"; - String get wownero => "assets/svg/coin_icons/Wownero.svg"; - String get namecoin => "assets/svg/coin_icons/Namecoin.svg"; - String get particl => "assets/svg/coin_icons/Particl.svg"; - - String get bnbIcon => "assets/svg/coin_icons/bnb_icon.svg"; - String get spark => "assets/svg/spark.svg"; + + String get sib => "assets/svg/sib.svg"; } class _PNG { diff --git a/lib/utilities/barcode_scanner_interface.dart b/lib/utilities/barcode_scanner_interface.dart index f8256f2e55..5ab338fe01 100644 --- a/lib/utilities/barcode_scanner_interface.dart +++ b/lib/utilities/barcode_scanner_interface.dart @@ -20,7 +20,7 @@ import '../widgets/stack_dialog.dart'; import 'logger.dart'; class ScanResult { - final String rawContent; + final String? rawContent; ScanResult({required this.rawContent}); } @@ -35,12 +35,12 @@ class BarcodeScannerWrapper implements BarcodeScannerInterface { @override Future scan({required BuildContext context}) async { try { - final data = await showDialog( + final data = await showDialog( context: context, builder: (context) => const QrScanner(), ); - return ScanResult(rawContent: data.toString()); + return ScanResult(rawContent: data); } catch (e) { rethrow; } @@ -61,19 +61,18 @@ Future checkCamPermDeniedMobileAndOpenAppSettings( if ((iosShow || androidShow) && context.mounted) { final trySettings = await showDialog( context: context, - builder: - (context) => StackDialog( - title: "Camera permissions required", - message: "Open settings?", - leftButton: SecondaryButton( - label: "Cancel", - onPressed: Navigator.of(context).pop, - ), - rightButton: PrimaryButton( - label: "Continue", - onPressed: () => Navigator.of(context).pop(true), - ), - ), + builder: (context) => StackDialog( + title: "Camera permissions required", + message: "Open settings?", + leftButton: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + rightButton: PrimaryButton( + label: "Continue", + onPressed: () => Navigator.of(context).pop(true), + ), + ), ); if (trySettings == true) { @@ -83,15 +82,14 @@ Future checkCamPermDeniedMobileAndOpenAppSettings( if (context.mounted) { await showDialog( context: context, - builder: - (context) => StackDialog( - title: "Could not open app settings", - message: "You will need manually go find your app settings", - rightButton: PrimaryButton( - label: "Ok", - onPressed: Navigator.of(context).pop, - ), - ), + builder: (context) => StackDialog( + title: "Could not open app settings", + message: "You will need manually go find your app settings", + rightButton: PrimaryButton( + label: "Ok", + onPressed: Navigator.of(context).pop, + ), + ), ); } } diff --git a/lib/utilities/connection_check/electrum_connection_check.dart b/lib/utilities/connection_check/electrum_connection_check.dart index 478d5e5b3a..8d845aa01c 100644 --- a/lib/utilities/connection_check/electrum_connection_check.dart +++ b/lib/utilities/connection_check/electrum_connection_check.dart @@ -49,6 +49,7 @@ Future checkElectrumServer({ port: port, useSSL: useSSL && !host.endsWith('.onion'), proxyInfo: proxyInfo, + acceptUnverified: false, ).timeout( Duration(seconds: (proxyInfo == null ? 5 : 30)), onTimeout: () => throw Exception( @@ -56,9 +57,9 @@ Future checkElectrumServer({ ), ); - await client.ping().timeout( - Duration(seconds: (proxyInfo == null ? 5 : 30)), - ); + await client + .request('server.version') + .timeout(Duration(seconds: (proxyInfo == null ? 5 : 30))); return true; } catch (e, s) { diff --git a/lib/utilities/constants.dart b/lib/utilities/constants.dart index 69205f1f0c..f7b495ee06 100644 --- a/lib/utilities/constants.dart +++ b/lib/utilities/constants.dart @@ -40,7 +40,7 @@ abstract class Constants { // Enable Logger.print statements static const bool disableLogger = false; - static const int currentDataVersion = 15; + static const int currentDataVersion = 16; static const int rescanV1 = 1; diff --git a/lib/utilities/default_epicboxes.dart b/lib/utilities/default_epicboxes.dart index a2c9b01f09..3ab7f1732c 100644 --- a/lib/utilities/default_epicboxes.dart +++ b/lib/utilities/default_epicboxes.dart @@ -13,41 +13,29 @@ import '../models/epicbox_server_model.dart'; abstract class DefaultEpicBoxes { static const String defaultName = "Default"; - static List get all => [americas, asia, europe]; - static List get defaultIds => ['americas', 'asia', 'europe']; + static List get all => [defaultEpicBoxServer, americas]; - static EpicBoxServerModel get americas => EpicBoxServerModel( - host: 'epicbox.stackwallet.com', - port: 443, - name: 'Americas', - id: 'americas', - useSSL: true, - enabled: true, - isFailover: true, - isDown: false, - ); - - static EpicBoxServerModel get asia => EpicBoxServerModel( - host: 'epicbox.hyperbig.com', - port: 443, - name: 'Asia', - id: 'asia', - useSSL: true, - enabled: true, - isFailover: true, - isDown: false, - ); + static EpicBoxServerModel get epiccashCom => EpicBoxServerModel( + host: 'epicbox.epiccash.com', + port: 443, + name: 'Official', + id: 'default_epiccashCom', + useSSL: true, + enabled: true, + isFailover: true, + isDown: false, + ); - static EpicBoxServerModel get europe => EpicBoxServerModel( - host: 'epicbox.fastepic.eu', - port: 443, - name: 'Europe', - id: 'europe', - useSSL: true, - enabled: true, - isFailover: true, - isDown: false, - ); + static EpicBoxServerModel get americas => EpicBoxServerModel( + host: 'epicbox.stackwallet.com', + port: 443, + name: 'Stack Wallet', + id: 'default_stack', + useSSL: true, + enabled: true, + isFailover: true, + isDown: false, + ); - static final defaultEpicBoxServer = americas; + static final defaultEpicBoxServer = epiccashCom; } diff --git a/lib/utilities/default_sol_tokens.dart b/lib/utilities/default_sol_tokens.dart new file mode 100644 index 0000000000..65f11bd362 --- /dev/null +++ b/lib/utilities/default_sol_tokens.dart @@ -0,0 +1,55 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import '../models/isar/models/solana/sol_contract.dart'; + +abstract class DefaultSolTokens { + static List list = [ + SolContract( + address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + name: "USD Coin", + symbol: "USDC", + decimals: 6, + logoUri: + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png", + ), + SolContract( + address: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + name: "Tether", + symbol: "USDT", + decimals: 6, + logoUri: + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB/logo.svg", + ), + SolContract( + address: "MangoCzJ36AjZyKwVj3VnYU4GTonjfVEnJmvvWaxLac", + name: "Mango", + symbol: "MNGO", + decimals: 6, + logoUri: + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/MangoCzJ36AjZyKwVj3VnYU4GTonjfVEnJmvvWaxLac/logo.png", + ), + SolContract( + address: "SRMuApVNdxXokk5GT7XD5cUUgXMBCoAz2LHeuAoKWRt", + name: "Serum", + symbol: "SRM", + decimals: 6, + logoUri: + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/SRMuApVNdxXokk5GT7XD5cUUgXMBCoAz2LHeuAoKWRt/logo.png", + ), + SolContract( + address: "orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE", + name: "Orca", + symbol: "ORCA", + decimals: 6, + logoUri: + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE/logo.png", + ), + ]; +} diff --git a/lib/utilities/dynamic_object.dart b/lib/utilities/dynamic_object.dart new file mode 100644 index 0000000000..bbb7728097 --- /dev/null +++ b/lib/utilities/dynamic_object.dart @@ -0,0 +1,25 @@ +class DynamicObjectTypeException implements Exception { + final Type actual, expected; + + DynamicObjectTypeException({required this.actual, required this.expected}); + + @override + String toString() => + "DynamicObjectException: Found $actual, expected $expected"; +} + +class DynamicObject { + final Object _value; + + DynamicObject(this._value); + + T get() { + if (_value is T) return _value as T; + throw DynamicObjectTypeException(actual: _value.runtimeType, expected: T); + } + + T? getIfMatch() { + if (_value is T) return _value as T; + return null; + } +} diff --git a/lib/utilities/electrum_seed_utils.dart b/lib/utilities/electrum_seed_utils.dart new file mode 100644 index 0000000000..337987ad89 --- /dev/null +++ b/lib/utilities/electrum_seed_utils.dart @@ -0,0 +1,1098 @@ +import 'dart:typed_data'; + +import 'package:pointycastle/export.dart'; +import 'package:unorm_dart/unorm_dart.dart'; + +import 'extensions/extensions.dart'; + +abstract class ElectrumSeedUtils { + static const kSeedPrefix = "01"; // standard + static const kSeedPrefixSegwit = "100"; // segwit + static const kSeedPrefix2fa = "101"; // 2FA standard + static const kSeedPrefix2faSegwit = "102"; // 2FA segwit + + static Uint8List electrumMnemonicToSeedBytes( + final String mnemonic, { + final String passphrase = "", + }) { + final salt = Uint8List.fromList([ + ..."electrum".toUint8ListFromUtf8, + ...normalize(passphrase).toUint8ListFromUtf8, + ]); + + final kdf = PBKDF2KeyDerivator(HMac.withDigest(SHA512Digest())) + ..init(Pbkdf2Parameters(salt, 2048, 64)); + + return kdf.process(normalize(mnemonic).toUint8ListFromUtf8); + } + + // based on https://electrum.readthedocs.io/en/latest/seedphrase.html#version-number + static String electrumMnemonicVersion( + final String mnemonic, { + final String passphrase = "", + }) { + final normalized = normalize(mnemonic).toUint8ListFromUtf8; + + final hash = _hmacHex(normalized); + + final length = int.parse(hash[0], radix: 16) + 2; + + return hash.substring(0, length); + } + + static bool isNewSeed(final String mnemonic, {String prefix = kSeedPrefix}) { + final normalized = normalize(mnemonic).toUint8ListFromUtf8; + final hash = _hmacHex(normalized); + return hash.startsWith(prefix); + } + + static String normalize(final String mnemonic) { + final characters = String.fromCharCodes( + nfkd( + mnemonic, + ).toLowerCase().runes.where((e) => !_kNonZeroCCCCodeUnits.contains(e)), + ).split(RegExp(r"\s+")).join(" ").trim().split(""); + + final buffer = StringBuffer(); + + for (int i = 0; i < characters.length; i++) { + final char = characters[i]; + final isSpace = RegExp(r"\s").hasMatch(char); + assert(char.runes.length == 1); + + if (isSpace && i > 0 && i < characters.length - 1) { + final prev = characters[i - 1]; + final next = characters[i + 1]; + if (_isCJK(prev.runes.first) && _isCJK(next.runes.first)) { + continue; + } + } + + buffer.write(char); + } + + return buffer.toString(); + } + + static String _hmacHex(Uint8List message) => + (HMac.withDigest(SHA512Digest()) + ..init(KeyParameter("Seed version".toUint8ListFromUtf8))) + .process(message) + .toHex; + + static bool _isCJK(int code) { + for (final (min, max, _) in _kCjkIntervals) { + if (min <= code && code <= max) { + return true; + } + } + return false; + } +} + +// https://www.unicode.org/reports/tr44/tr44-34.html#Canonical_Combining_Class_Values +// generated from https://www.unicode.org/Public/UCD/latest/ucd/UnicodeData.txt +const _kNonZeroCCCCodeUnits = { + 768, + 769, + 770, + 771, + 772, + 773, + 774, + 775, + 776, + 777, + 778, + 779, + 780, + 781, + 782, + 783, + 784, + 785, + 786, + 787, + 788, + 789, + 790, + 791, + 792, + 793, + 794, + 795, + 796, + 797, + 798, + 799, + 800, + 801, + 802, + 803, + 804, + 805, + 806, + 807, + 808, + 809, + 810, + 811, + 812, + 813, + 814, + 815, + 816, + 817, + 818, + 819, + 820, + 821, + 822, + 823, + 824, + 825, + 826, + 827, + 828, + 829, + 830, + 831, + 832, + 833, + 834, + 835, + 836, + 837, + 838, + 839, + 840, + 841, + 842, + 843, + 844, + 845, + 846, + 848, + 849, + 850, + 851, + 852, + 853, + 854, + 855, + 856, + 857, + 858, + 859, + 860, + 861, + 862, + 863, + 864, + 865, + 866, + 867, + 868, + 869, + 870, + 871, + 872, + 873, + 874, + 875, + 876, + 877, + 878, + 879, + 1155, + 1156, + 1157, + 1158, + 1159, + 1425, + 1426, + 1427, + 1428, + 1429, + 1430, + 1431, + 1432, + 1433, + 1434, + 1435, + 1436, + 1437, + 1438, + 1439, + 1440, + 1441, + 1442, + 1443, + 1444, + 1445, + 1446, + 1447, + 1448, + 1449, + 1450, + 1451, + 1452, + 1453, + 1454, + 1455, + 1456, + 1457, + 1458, + 1459, + 1460, + 1461, + 1462, + 1463, + 1464, + 1465, + 1466, + 1467, + 1468, + 1469, + 1471, + 1473, + 1474, + 1476, + 1477, + 1479, + 1552, + 1553, + 1554, + 1555, + 1556, + 1557, + 1558, + 1559, + 1560, + 1561, + 1562, + 1611, + 1612, + 1613, + 1614, + 1615, + 1616, + 1617, + 1618, + 1619, + 1620, + 1621, + 1622, + 1623, + 1624, + 1625, + 1626, + 1627, + 1628, + 1629, + 1630, + 1631, + 1648, + 1750, + 1751, + 1752, + 1753, + 1754, + 1755, + 1756, + 1759, + 1760, + 1761, + 1762, + 1763, + 1764, + 1767, + 1768, + 1770, + 1771, + 1772, + 1773, + 1809, + 1840, + 1841, + 1842, + 1843, + 1844, + 1845, + 1846, + 1847, + 1848, + 1849, + 1850, + 1851, + 1852, + 1853, + 1854, + 1855, + 1856, + 1857, + 1858, + 1859, + 1860, + 1861, + 1862, + 1863, + 1864, + 1865, + 1866, + 2027, + 2028, + 2029, + 2030, + 2031, + 2032, + 2033, + 2034, + 2035, + 2045, + 2070, + 2071, + 2072, + 2073, + 2075, + 2076, + 2077, + 2078, + 2079, + 2080, + 2081, + 2082, + 2083, + 2085, + 2086, + 2087, + 2089, + 2090, + 2091, + 2092, + 2093, + 2137, + 2138, + 2139, + 2199, + 2200, + 2201, + 2202, + 2203, + 2204, + 2205, + 2206, + 2207, + 2250, + 2251, + 2252, + 2253, + 2254, + 2255, + 2256, + 2257, + 2258, + 2259, + 2260, + 2261, + 2262, + 2263, + 2264, + 2265, + 2266, + 2267, + 2268, + 2269, + 2270, + 2271, + 2272, + 2273, + 2275, + 2276, + 2277, + 2278, + 2279, + 2280, + 2281, + 2282, + 2283, + 2284, + 2285, + 2286, + 2287, + 2288, + 2289, + 2290, + 2291, + 2292, + 2293, + 2294, + 2295, + 2296, + 2297, + 2298, + 2299, + 2300, + 2301, + 2302, + 2303, + 2364, + 2381, + 2385, + 2386, + 2387, + 2388, + 2492, + 2509, + 2558, + 2620, + 2637, + 2748, + 2765, + 2876, + 2893, + 3021, + 3132, + 3149, + 3157, + 3158, + 3260, + 3277, + 3387, + 3388, + 3405, + 3530, + 3640, + 3641, + 3642, + 3656, + 3657, + 3658, + 3659, + 3768, + 3769, + 3770, + 3784, + 3785, + 3786, + 3787, + 3864, + 3865, + 3893, + 3895, + 3897, + 3953, + 3954, + 3956, + 3962, + 3963, + 3964, + 3965, + 3968, + 3970, + 3971, + 3972, + 3974, + 3975, + 4038, + 4151, + 4153, + 4154, + 4237, + 4957, + 4958, + 4959, + 5908, + 5909, + 5940, + 6098, + 6109, + 6313, + 6457, + 6458, + 6459, + 6679, + 6680, + 6752, + 6773, + 6774, + 6775, + 6776, + 6777, + 6778, + 6779, + 6780, + 6783, + 6832, + 6833, + 6834, + 6835, + 6836, + 6837, + 6838, + 6839, + 6840, + 6841, + 6842, + 6843, + 6844, + 6845, + 6847, + 6848, + 6849, + 6850, + 6851, + 6852, + 6853, + 6854, + 6855, + 6856, + 6857, + 6858, + 6859, + 6860, + 6861, + 6862, + 6863, + 6864, + 6865, + 6866, + 6867, + 6868, + 6869, + 6870, + 6871, + 6872, + 6873, + 6874, + 6875, + 6876, + 6877, + 6880, + 6881, + 6882, + 6883, + 6884, + 6885, + 6886, + 6887, + 6888, + 6889, + 6890, + 6891, + 6964, + 6980, + 7019, + 7020, + 7021, + 7022, + 7023, + 7024, + 7025, + 7026, + 7027, + 7082, + 7083, + 7142, + 7154, + 7155, + 7223, + 7376, + 7377, + 7378, + 7380, + 7381, + 7382, + 7383, + 7384, + 7385, + 7386, + 7387, + 7388, + 7389, + 7390, + 7391, + 7392, + 7394, + 7395, + 7396, + 7397, + 7398, + 7399, + 7400, + 7405, + 7412, + 7416, + 7417, + 7616, + 7617, + 7618, + 7619, + 7620, + 7621, + 7622, + 7623, + 7624, + 7625, + 7626, + 7627, + 7628, + 7629, + 7630, + 7631, + 7632, + 7633, + 7634, + 7635, + 7636, + 7637, + 7638, + 7639, + 7640, + 7641, + 7642, + 7643, + 7644, + 7645, + 7646, + 7647, + 7648, + 7649, + 7650, + 7651, + 7652, + 7653, + 7654, + 7655, + 7656, + 7657, + 7658, + 7659, + 7660, + 7661, + 7662, + 7663, + 7664, + 7665, + 7666, + 7667, + 7668, + 7669, + 7670, + 7671, + 7672, + 7673, + 7674, + 7675, + 7676, + 7677, + 7678, + 7679, + 8400, + 8401, + 8402, + 8403, + 8404, + 8405, + 8406, + 8407, + 8408, + 8409, + 8410, + 8411, + 8412, + 8417, + 8421, + 8422, + 8423, + 8424, + 8425, + 8426, + 8427, + 8428, + 8429, + 8430, + 8431, + 8432, + 11503, + 11504, + 11505, + 11647, + 11744, + 11745, + 11746, + 11747, + 11748, + 11749, + 11750, + 11751, + 11752, + 11753, + 11754, + 11755, + 11756, + 11757, + 11758, + 11759, + 11760, + 11761, + 11762, + 11763, + 11764, + 11765, + 11766, + 11767, + 11768, + 11769, + 11770, + 11771, + 11772, + 11773, + 11774, + 11775, + 12330, + 12331, + 12332, + 12333, + 12334, + 12335, + 12441, + 12442, + 42607, + 42612, + 42613, + 42614, + 42615, + 42616, + 42617, + 42618, + 42619, + 42620, + 42621, + 42654, + 42655, + 42736, + 42737, + 43014, + 43052, + 43204, + 43232, + 43233, + 43234, + 43235, + 43236, + 43237, + 43238, + 43239, + 43240, + 43241, + 43242, + 43243, + 43244, + 43245, + 43246, + 43247, + 43248, + 43249, + 43307, + 43308, + 43309, + 43347, + 43443, + 43456, + 43696, + 43698, + 43699, + 43700, + 43703, + 43704, + 43710, + 43711, + 43713, + 43766, + 44013, + 64286, + 65056, + 65057, + 65058, + 65059, + 65060, + 65061, + 65062, + 65063, + 65064, + 65065, + 65066, + 65067, + 65068, + 65069, + 65070, + 65071, + 66045, + 66272, + 66422, + 66423, + 66424, + 66425, + 66426, + 68109, + 68111, + 68152, + 68153, + 68154, + 68159, + 68325, + 68326, + 68900, + 68901, + 68902, + 68903, + 68969, + 68970, + 68971, + 68972, + 68973, + 69291, + 69292, + 69370, + 69371, + 69373, + 69374, + 69375, + 69446, + 69447, + 69448, + 69449, + 69450, + 69451, + 69452, + 69453, + 69454, + 69455, + 69456, + 69506, + 69507, + 69508, + 69509, + 69702, + 69744, + 69759, + 69817, + 69818, + 69888, + 69889, + 69890, + 69939, + 69940, + 70003, + 70080, + 70090, + 70197, + 70198, + 70377, + 70378, + 70459, + 70460, + 70477, + 70502, + 70503, + 70504, + 70505, + 70506, + 70507, + 70508, + 70512, + 70513, + 70514, + 70515, + 70516, + 70606, + 70607, + 70608, + 70722, + 70726, + 70750, + 70850, + 70851, + 71103, + 71104, + 71231, + 71350, + 71351, + 71467, + 71737, + 71738, + 71997, + 71998, + 72003, + 72160, + 72244, + 72263, + 72345, + 72767, + 73026, + 73028, + 73029, + 73111, + 73537, + 73538, + 90415, + 92912, + 92913, + 92914, + 92915, + 92916, + 92976, + 92977, + 92978, + 92979, + 92980, + 92981, + 92982, + 94192, + 94193, + 113822, + 119141, + 119142, + 119143, + 119144, + 119145, + 119149, + 119150, + 119151, + 119152, + 119153, + 119154, + 119163, + 119164, + 119165, + 119166, + 119167, + 119168, + 119169, + 119170, + 119173, + 119174, + 119175, + 119176, + 119177, + 119178, + 119179, + 119210, + 119211, + 119212, + 119213, + 119362, + 119363, + 119364, + 122880, + 122881, + 122882, + 122883, + 122884, + 122885, + 122886, + 122888, + 122889, + 122890, + 122891, + 122892, + 122893, + 122894, + 122895, + 122896, + 122897, + 122898, + 122899, + 122900, + 122901, + 122902, + 122903, + 122904, + 122907, + 122908, + 122909, + 122910, + 122911, + 122912, + 122913, + 122915, + 122916, + 122918, + 122919, + 122920, + 122921, + 122922, + 123023, + 123184, + 123185, + 123186, + 123187, + 123188, + 123189, + 123190, + 123566, + 123628, + 123629, + 123630, + 123631, + 124140, + 124141, + 124142, + 124143, + 124398, + 124399, + 124643, + 124646, + 124654, + 124655, + 124661, + 125136, + 125137, + 125138, + 125139, + 125140, + 125141, + 125142, + 125252, + 125253, + 125254, + 125255, + 125256, + 125257, + 125258, +}; + +// see https://github.com/spesmilo/electrum/blob/master/electrum/mnemonic.py#L39-L70 +// which references http://www.asahi-net.or.jp/~ax2s-kmtn/ref/unicode/e_asia.html +const _kCjkIntervals = [ + (0x4E00, 0x9FFF, "CJK Unified Ideographs"), + (0x3400, 0x4DBF, "CJK Unified Ideographs Extension A"), + (0x20000, 0x2A6DF, "CJK Unified Ideographs Extension B"), + (0x2A700, 0x2B73F, "CJK Unified Ideographs Extension C"), + (0x2B740, 0x2B81F, "CJK Unified Ideographs Extension D"), + (0xF900, 0xFAFF, "CJK Compatibility Ideographs"), + (0x2F800, 0x2FA1D, "CJK Compatibility Ideographs Supplement"), + (0x3190, 0x319F, "Kanbun"), + (0x2E80, 0x2EFF, "CJK Radicals Supplement"), + (0x2F00, 0x2FDF, "CJK Radicals"), + (0x31C0, 0x31EF, "CJK Strokes"), + (0x2FF0, 0x2FFF, "Ideographic Description Characters"), + (0xE0100, 0xE01EF, "Variation Selectors Supplement"), + (0x3100, 0x312F, "Bopomofo"), + (0x31A0, 0x31BF, "Bopomofo Extended"), + (0xFF00, 0xFFEF, "Halfwidth and Fullwidth Forms"), + (0x3040, 0x309F, "Hiragana"), + (0x30A0, 0x30FF, "Katakana"), + (0x31F0, 0x31FF, "Katakana Phonetic Extensions"), + (0x1B000, 0x1B0FF, "Kana Supplement"), + (0xAC00, 0xD7AF, "Hangul Syllables"), + (0x1100, 0x11FF, "Hangul Jamo"), + (0xA960, 0xA97F, "Hangul Jamo Extended A"), + (0xD7B0, 0xD7FF, "Hangul Jamo Extended B"), + (0x3130, 0x318F, "Hangul Compatibility Jamo"), + (0xA4D0, 0xA4FF, "Lisu"), + (0x16F00, 0x16F9F, "Miao"), + (0xA000, 0xA48F, "Yi Syllables"), + (0xA490, 0xA4CF, "Yi Radicals"), +]; diff --git a/lib/utilities/enums/epic_transaction_method.dart b/lib/utilities/enums/epic_transaction_method.dart new file mode 100644 index 0000000000..4ef30afc1f --- /dev/null +++ b/lib/utilities/enums/epic_transaction_method.dart @@ -0,0 +1,48 @@ +/// Enum to represent different Epic Cash transaction methods. +enum EpicTransactionMethod { + /// Manual slate exchange (copy/paste, QR codes, files). + slatepack, + + /// Automatic transaction via Epicbox. + epicbox; + + /// Human readable name for the transaction method. + String get displayName { + switch (this) { + case EpicTransactionMethod.slatepack: + return 'Slatepack'; + case EpicTransactionMethod.epicbox: + return 'Epicbox'; + } + } + + /// Description of how the transaction method works. + String get description { + switch (this) { + case EpicTransactionMethod.slatepack: + return 'Manual exchange via text, QR codes, or files'; + case EpicTransactionMethod.epicbox: + return 'Automatic exchange via Epicbox messaging'; + } + } + + /// Whether this method requires manual intervention. + bool get isManual { + switch (this) { + case EpicTransactionMethod.slatepack: + return true; + case EpicTransactionMethod.epicbox: + return false; + } + } + + /// Whether this method works offline. + bool get worksOffline { + switch (this) { + case EpicTransactionMethod.slatepack: + return true; + case EpicTransactionMethod.epicbox: + return false; + } + } +} diff --git a/lib/utilities/firo_pro_reg_signed_message_prefix.dart b/lib/utilities/firo_pro_reg_signed_message_prefix.dart new file mode 100644 index 0000000000..0844a347ee --- /dev/null +++ b/lib/utilities/firo_pro_reg_signed_message_prefix.dart @@ -0,0 +1,18 @@ +/// Helpers for Firo ProReg collateral signatures that use Bitcoin-style +/// signed-message framing with [coinlib.MessageSignature.sign]. +/// +/// [coinlib.Network.messagePrefix] for Firo includes the Core magic byte +/// `0x16` before `"Zcoin Signed Message:\\n"`. Coinlib adds its own length +/// framing for signing, so that byte must be supplied explicitly rather than +/// inferred from accidental equality with `length - 1`. +library firo_pro_reg_signed_message_prefix; + +/// Prefix string passed to [MessageSignature.sign] for Firo/Zcoin networks. +String firoMessagePrefixForCoinlibSign(String networkMessagePrefix) { + const magic = 0x16; + final bytes = networkMessagePrefix.codeUnits; + if (bytes.isNotEmpty && bytes.first == magic) { + return String.fromCharCodes(bytes.sublist(1)); + } + return networkMessagePrefix; +} diff --git a/lib/utilities/fs.dart b/lib/utilities/fs.dart new file mode 100644 index 0000000000..fc6f087aa3 --- /dev/null +++ b/lib/utilities/fs.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart'; +import 'package:saf_stream/saf_stream.dart'; +import 'package:saf_util/saf_util.dart'; + +abstract final class FS { + static Future pickDirectory({String? initialDirectory}) async { + final String? path; + if (Platform.isAndroid) { + final dir = await SafUtil().pickDirectory( + writePermission: true, + persistablePermission: true, + initialUri: initialDirectory, + ); + + path = dir?.uri; + } else { + path = await FilePicker.platform.getDirectoryPath( + lockParentWindow: true, + initialDirectory: initialDirectory, + ); + } + + return path; + } + + static Future writeStringToFile( + String content, + String dirPath, + String fileName, + ) { + if (Platform.isAndroid && dirPath.startsWith("content://")) { + final token = ServicesBinding.rootIsolateToken!; + return compute(_androidSafWriteComputeWrapper, ( + dirPath: dirPath, + fileName: fileName, + content: content, + isoToken: token, + )); + } else { + return File(join(dirPath, fileName)).writeAsString(content, flush: true); + } + } +} + +Future _androidSafWriteComputeWrapper( + ({String dirPath, String fileName, String content, RootIsolateToken isoToken}) + args, +) async { + BackgroundIsolateBinaryMessenger.ensureInitialized(args.isoToken); + final bytes = utf8.encode(args.content); + await SafStream().writeFileBytes(args.dirPath, args.fileName, "txt", bytes); +} diff --git a/lib/utilities/if_not_already.dart b/lib/utilities/if_not_already.dart new file mode 100644 index 0000000000..8a2a0406e2 --- /dev/null +++ b/lib/utilities/if_not_already.dart @@ -0,0 +1,44 @@ +import 'dart:async'; + +class IfNotAlready { + final void Function() _function; + + bool _locked = false; + + IfNotAlready(this._function); + + void execute() { + if (_locked) return; + _locked = true; + try { + _function(); + } finally { + _locked = false; + } + } +} + +class IfNotAlreadyAsync { + final Future Function()? _function; + final Future Function(T? args)? _functionWithArgs; + + bool _locked = false; + + IfNotAlreadyAsync(this._function) : _functionWithArgs = null; + IfNotAlreadyAsync.withArgs(this._functionWithArgs) : _function = null; + + Future execute([T? args]) async { + if (!_locked) { + _locked = true; + try { + if (_function != null) { + await _function(); + } else { + await _functionWithArgs!(args); + } + } finally { + _locked = false; + } + } + } +} diff --git a/lib/utilities/logger.dart b/lib/utilities/logger.dart index b5ae5f00dc..b0b37cd55e 100644 --- a/lib/utilities/logger.dart +++ b/lib/utilities/logger.dart @@ -123,6 +123,19 @@ class Logging { StackTrace? stackTrace, bool toFile = true, // false will print to console only }) { + if (Util.isTestEnv) { + // Persistent isolates may not work correctly during tests + // just print to console instead + + // ignore: avoid_print + print( + "${level.name} [$time] ${_stringifyMessage(message)}" + ", ERROR: $error" + ", STRACE: $stackTrace", + ); + return; + } + if (Util.isTestEnv || Util.isArmLinux) { toFile = false; } diff --git a/lib/utilities/paynym_is_api.dart b/lib/utilities/paynym_is_api.dart index 9285fef7f6..9aafb7136c 100644 --- a/lib/utilities/paynym_is_api.dart +++ b/lib/utilities/paynym_is_api.dart @@ -65,10 +65,16 @@ class PaynymIsApi { // debugPrint("Paynym response code: ${response.code}"); // debugPrint("Paynym response body: ${response.body}"); - return Tuple2( - jsonDecode(response.body) as Map, - response.code, - ); + Map parsedBody; + try { + final bodyStr = response.body.trim(); + parsedBody = bodyStr.isEmpty + ? {} + : jsonDecode(bodyStr) as Map; + } catch (_) { + parsedBody = {}; + } + return Tuple2(parsedBody, response.code); } // ### `/api/v1/create` @@ -357,11 +363,16 @@ class PaynymIsApi { switch (result.item2) { case 200: message = "Payment code successfully claimed"; - value = PaynymClaim.fromMap(result.item1); + if (result.item1.isNotEmpty) { + value = PaynymClaim.fromMap(result.item1); + } break; case 400: message = "Bad request"; break; + case 401: + message = "Unauthorized token or signature"; + break; default: message = result.item1["message"] as String? ?? "Unknown error"; } diff --git a/lib/utilities/prefs.dart b/lib/utilities/prefs.dart index 09b2bbd97b..56013d64d7 100644 --- a/lib/utilities/prefs.dart +++ b/lib/utilities/prefs.dart @@ -81,6 +81,8 @@ class Prefs extends ChangeNotifier { _logsPath = await _getLogsPath(); _logLevel = await _getLogLevel(); _autoLockInfo = await _getAutoLockInfo(); + _privacyScreen = await _getPrivacyScreen(); + _disableScreenShots = await _getDisableScreenShots(); _initialized = true; } @@ -146,18 +148,25 @@ class Prefs extends ChangeNotifier { int get currentNotificationId => _currentNotificationId; - Future incrementCurrentNotificationIndex() async { + /// Bumps the shared OS-notification id counter and returns the id it + /// allocated. The bump happens synchronously before the persist is awaited, + /// so concurrent callers each get a distinct id — use the returned value, + /// not a later read of [currentNotificationId], which by the time this + /// completes may already belong to another caller. + Future incrementCurrentNotificationIndex() async { if (_currentNotificationId <= Constants.notificationsMax) { _currentNotificationId++; } else { _currentNotificationId = 0; } + final int id = _currentNotificationId; await DB.instance.put( boxName: DB.boxNamePrefs, key: "currentNotificationId", value: _currentNotificationId, ); notifyListeners(); + return id; } Future _getCurrentNotificationIndex() async { @@ -726,7 +735,7 @@ class Prefs extends ChangeNotifier { Future _getLastAutoBackup() async { return await DB.instance.get( boxName: DB.boxNamePrefs, - key: "autoBackupFileUri", + key: "lastAutoBackup", ) as DateTime?; } @@ -1383,4 +1392,52 @@ class Prefs extends ChangeNotifier { return (enabled: map["enabled"] as bool, minutes: map["minutes"] as int); } + + // mobile screen privacy + bool _privacyScreen = false; + bool get privacyScreen => _privacyScreen; + set privacyScreen(bool privacyScreen) { + if (_privacyScreen != privacyScreen) { + DB.instance.put( + boxName: DB.boxNamePrefs, + key: "privacyScreen", + value: privacyScreen, + ); + _privacyScreen = privacyScreen; + notifyListeners(); + } + } + + Future _getPrivacyScreen() async { + return await DB.instance.get( + boxName: DB.boxNamePrefs, + key: "privacyScreen", + ) + as bool? ?? + false; + } + + // android screen shot protection + bool _disableScreenShots = false; + bool get disableScreenShots => _disableScreenShots; + set disableScreenShots(bool disableScreenShots) { + if (_disableScreenShots != disableScreenShots) { + DB.instance.put( + boxName: DB.boxNamePrefs, + key: "disableScreenShots", + value: disableScreenShots, + ); + _disableScreenShots = disableScreenShots; + notifyListeners(); + } + } + + Future _getDisableScreenShots() async { + return await DB.instance.get( + boxName: DB.boxNamePrefs, + key: "disableScreenShots", + ) + as bool? ?? + false; + } } diff --git a/lib/utilities/show_loading.dart b/lib/utilities/show_loading.dart index 040bc23037..39537e37d1 100644 --- a/lib/utilities/show_loading.dart +++ b/lib/utilities/show_loading.dart @@ -16,22 +16,15 @@ import '../themes/stack_colors.dart'; import '../widgets/custom_loading_overlay.dart'; import 'logger.dart'; -Future minWaitFuture( - Future future, { - required Duration delay, -}) async { - final results = await Future.wait( - [ - future, - Future.delayed(delay), - ], - ); +Future minWaitFuture(Future future, {required Duration delay}) async { + final results = await Future.wait([future, Future.delayed(delay)]); return results.first as T; } Future showLoading({ - required Future whileFuture, + Future? whileFuture, + Future Function()? whileFutureAlt, required BuildContext context, required String message, String? subMessage, @@ -40,6 +33,12 @@ Future showLoading({ void Function(Exception)? onException, Duration? delay, }) async { + assert( + (whileFuture != null || whileFutureAlt != null) && + !(whileFuture != null && whileFutureAlt != null) && + !(whileFuture == null && whileFutureAlt == null), + ); + unawaited( showDialog( context: context, @@ -47,10 +46,9 @@ Future showLoading({ builder: (_) => WillPopScope( onWillPop: () async => false, child: Container( - color: Theme.of(context) - .extension()! - .overlay - .withOpacity(opaqueBG ? 1.0 : 0.6), + color: Theme.of( + context, + ).extension()!.overlay.withOpacity(opaqueBG ? 1.0 : 0.6), child: CustomLoadingOverlay( message: message, subMessage: subMessage, @@ -66,9 +64,12 @@ Future showLoading({ try { if (delay != null) { - result = await minWaitFuture(whileFuture, delay: delay); + result = await minWaitFuture( + whileFutureAlt?.call() ?? whileFuture!, + delay: delay, + ); } else { - result = await whileFuture; + result = await (whileFutureAlt?.call() ?? whileFuture!); } } catch (e, s) { Logging.instance.w("showLoading caught: ", error: e, stackTrace: s); diff --git a/lib/utilities/stack_file_system.dart b/lib/utilities/stack_file_system.dart index e135577392..292dda1368 100644 --- a/lib/utilities/stack_file_system.dart +++ b/lib/utilities/stack_file_system.dart @@ -238,18 +238,4 @@ abstract class StackFileSystem { return logsDir; } - - static Future wtfAndroidDocumentsPath() async { - const base = "/storage/emulated/"; - final rootDir = await applicationRootDirectory(); - final parts = rootDir.path.replaceFirst("/data/user/", "").split("/"); - if (parts.isNotEmpty) { - final id = int.tryParse(parts.first); - - if (id != null) { - return Directory(path.join(base, id.toString(), "Documents")); - } - } - throw Exception("Unsupported Android flavor"); - } } diff --git a/lib/utilities/test_epicbox_server_connection.dart b/lib/utilities/test_epicbox_server_connection.dart new file mode 100644 index 0000000000..0a2ef90ea1 --- /dev/null +++ b/lib/utilities/test_epicbox_server_connection.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +import 'logger.dart'; + +Future _testEpicBoxConnection(String host, int port, bool useSSL) async { + final client = HttpClient(); + try { + final protocol = useSSL ? 'https' : 'http'; + + client.connectionTimeout = const Duration(seconds: 5); + + final request = await client.getUrl(Uri.parse('$protocol://$host:$port')); + final response = await request.close(); + final body = await response + .transform(const SystemEncoding().decoder) + .join(); + + client.close(); + + // epicbox servers return an HTML page containing "Epicbox" + return response.statusCode == 200 && body.contains('Epicbox'); + } catch (e, s) { + Logging.instance.e( + "_testEpicBoxConnection failed on \"$host:$port\"", + error: e, + stackTrace: s, + ); + return false; + } finally { + client.close(force: true); + } +} + +Future testEpicBoxServerConnection( + EpicBoxFormData data, +) async { + if (data.host == null || data.port == null) { + return null; + } + + try { + final useSSL = data.useSSL ?? true; + if (await _testEpicBoxConnection(data.host!, data.port!, useSSL)) { + return data; + } else { + return null; + } + } catch (e, s) { + Logging.instance.w("$e\n$s", error: e, stackTrace: s); + return null; + } +} + +class EpicBoxFormData { + String? name, host; + int? port; + bool? useSSL, isFailover; + + @override + String toString() { + return "{ name: $name, host: $host, port: $port, useSSL: $useSSL }"; + } +} diff --git a/lib/utilities/test_mwcmqs_connection.dart b/lib/utilities/test_mwcmqs_connection.dart index c199afcbf3..48860df3c1 100644 --- a/lib/utilities/test_mwcmqs_connection.dart +++ b/lib/utilities/test_mwcmqs_connection.dart @@ -17,15 +17,13 @@ import '../services/tor_service.dart'; import 'logger.dart'; import 'prefs.dart'; -Future _testMwcMqsNodeConnection(Uri uri) async { +Future _testMwcMqsNodeConnection(Uri uri, {String? apiSecret}) async { final HTTP client = HTTP(); try { final headers = {'Content-Type': 'application/json'}; - if (uri.toString() == 'https://mwc713.mwc.mw/v1/version') { - const username = 'mwcmain'; - const password = '11ne3EAUtOXVKwhxm84U'; - final credentials = base64Encode(utf8.encode('$username:$password')); + if (apiSecret != null) { + final credentials = base64Encode(utf8.encode('mwcmain:$apiSecret')); headers['Authorization'] = 'Basic $credentials'; } final response = await client @@ -80,7 +78,7 @@ Future testMwcNodeConnection(NodeFormData data) async { uri = uri.replace(port: data.port); try { - if (await _testMwcMqsNodeConnection(uri)) { + if (await _testMwcMqsNodeConnection(uri, apiSecret: data.apiSecret)) { return data; } else { return null; diff --git a/lib/utilities/test_node_connection.dart b/lib/utilities/test_node_connection.dart index c6b5ee00dd..1a94e23f39 100644 --- a/lib/utilities/test_node_connection.dart +++ b/lib/utilities/test_node_connection.dart @@ -29,6 +29,31 @@ import 'test_mwcmqs_connection.dart'; import 'test_stellar_node_connection.dart'; import 'tor_plain_net_option_enum.dart'; +typedef TestNodeConnectionCallback = + Future Function({ + required BuildContext context, + required NodeFormData nodeFormData, + required CryptoCurrency cryptoCurrency, + void Function(NodeFormData)? onSuccess, + }); + +final testNodeConnectionProvider = Provider((ref) { + return ({ + required BuildContext context, + required NodeFormData nodeFormData, + required CryptoCurrency cryptoCurrency, + void Function(NodeFormData)? onSuccess, + }) { + return testNodeConnection( + context: context, + nodeFormData: nodeFormData, + cryptoCurrency: cryptoCurrency, + read: ref.read, + onSuccess: onSuccess, + ); + }; +}); + Future _xmrHelper( NodeFormData nodeFormData, BuildContext context, @@ -93,12 +118,12 @@ Future testNodeConnection({ required BuildContext context, required NodeFormData nodeFormData, required CryptoCurrency cryptoCurrency, - required WidgetRef ref, + required Reader read, void Function(NodeFormData)? onSuccess, }) async { final formData = nodeFormData; - if (ref.read(prefsChangeNotifierProvider).useTor) { + if (read(prefsChangeNotifierProvider).useTor) { if (formData.netOption! == TorPlainNetworkOption.clear) { Logging.instance.w( "This node is configured for non-TOR only but TOR is enabled", @@ -147,8 +172,8 @@ Future testNodeConnection({ try { final proxyInfo = !AppConfig.hasFeature(AppFeature.tor) ? null - : ref.read(prefsChangeNotifierProvider).useTor - ? ref.read(pTorService).getProxyInfo() + : read(prefsChangeNotifierProvider).useTor + ? read(pTorService).getProxyInfo() : null; final url = formData.host!; @@ -200,8 +225,8 @@ Future testNodeConnection({ host: formData.host!, port: formData.port!, useSSL: formData.useSSL!, - overridePrefs: ref.read(prefsChangeNotifierProvider), - overrideTorService: ref.read(pTorService), + overridePrefs: read(prefsChangeNotifierProvider), + overrideTorService: read(pTorService), ); } catch (_) { testPassed = false; @@ -236,8 +261,8 @@ Future testNodeConnection({ body: jsonEncode({"action": "version"}), proxyInfo: !AppConfig.hasFeature(AppFeature.tor) ? null - : ref.read(prefsChangeNotifierProvider).useTor - ? ref.read(pTorService).getProxyInfo() + : read(prefsChangeNotifierProvider).useTor + ? read(pTorService).getProxyInfo() : null, ); @@ -259,8 +284,8 @@ Future testNodeConnection({ formData.host!, formData.port!, formData.useSSL ?? false, - ref.read(prefsChangeNotifierProvider), - ref.read(pTorService), + read(prefsChangeNotifierProvider), + read(pTorService), ); final health = await rpcClient.getHealth(); @@ -275,7 +300,7 @@ Future testNodeConnection({ try { final client = HttpClient(); if (AppConfig.hasFeature(AppFeature.tor) && - ref.read(prefsChangeNotifierProvider).useTor) { + read(prefsChangeNotifierProvider).useTor) { final proxyInfo = TorService.sharedInstance.getProxyInfo(); final proxySettings = ProxySettings(proxyInfo.host, proxyInfo.port); SocksTCPClient.assignToHttpClient(client, [proxySettings]); diff --git a/lib/utilities/util.dart b/lib/utilities/util.dart index 5480e08048..c722832ef2 100644 --- a/lib/utilities/util.dart +++ b/lib/utilities/util.dart @@ -17,6 +17,13 @@ import 'package:flutter/material.dart'; import 'package:intl/number_symbols.dart'; import 'package:intl/number_symbols_data.dart'; +import '../app_config.dart'; +import '../wallets/wallet/impl/monero_wallet.dart'; +import '../wallets/wallet/intermediate/external_wallet.dart'; +import '../wallets/wallet/wallet.dart'; +import '../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; +import '../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; + abstract class Util { static const isArmLinux = bool.fromEnvironment("IS_ARM"); static final isTestEnv = Platform.environment["FLUTTER_TEST"] == "true"; @@ -84,7 +91,7 @@ abstract class Util { final pretty = encoder.convert(json); result = pretty; } else { - result = dynamic.toString(); + result = json.toString(); } if (debugTitle != null) { @@ -93,4 +100,29 @@ abstract class Util { log(result); } } + + // not sure of a better place to put this for now. Kind of a dirty hacked + // function anyways... + static bool isWalletCoinAndCanSendWithoutWalletOpenedIgnoringXMR( + String ticker, + List wallets, + ) { + try { + final coin = AppConfig.getCryptoCurrencyForTicker(ticker); + return wallets + .where( + (e) => + ((e is ViewOnlyOptionInterface && !e.isViewOnly) || + e is! ViewOnlyOptionInterface) && + e.info.coin == coin && + (e is MoneroWallet || + (e is! ExternalWallet || + e is MwebInterface)), // ltc mweb is external but swaps + // should not use mweb, hence the odd logic check here + ) + .isNotEmpty; + } catch (_) { + return false; + } + } } diff --git a/lib/wallets/api/tezos/tezos_rpc_api.dart b/lib/wallets/api/tezos/tezos_rpc_api.dart index 721d8b0fa6..0449de8e5d 100644 --- a/lib/wallets/api/tezos/tezos_rpc_api.dart +++ b/lib/wallets/api/tezos/tezos_rpc_api.dart @@ -46,7 +46,7 @@ abstract final class TezosRpcAPI { }) async { try { final api = - "${nodeInfo.host}:${nodeInfo.port}/chains/main/blocks/head/header/shell"; + "${nodeInfo.host}:${nodeInfo.port}/chains/main/blocks/head/header"; final response = await _client.get( url: Uri.parse(api), diff --git a/lib/wallets/crypto_currency/coins/dogecoin.dart b/lib/wallets/crypto_currency/coins/dogecoin.dart index 1d281f5bba..375ce6bf8e 100644 --- a/lib/wallets/crypto_currency/coins/dogecoin.dart +++ b/lib/wallets/crypto_currency/coins/dogecoin.dart @@ -137,7 +137,7 @@ class Dogecoin extends Bip39HDCurrency with ElectrumXCurrencyInterface { privHDPrefix: 0x02fac398, pubHDPrefix: 0x02facafd, bech32Hrp: "doge", - messagePrefix: '\x18Dogecoin Signed Message:\n', + messagePrefix: '\x19Dogecoin Signed Message:\n', minFee: BigInt.from(1), // Not used in stack wallet currently minOutput: dustLimit.raw, // Not used in stack wallet currently feePerKb: BigInt.from(1), // Not used in stack wallet currently @@ -150,7 +150,7 @@ class Dogecoin extends Bip39HDCurrency with ElectrumXCurrencyInterface { privHDPrefix: 0x04358394, pubHDPrefix: 0x043587cf, bech32Hrp: "tdge", - messagePrefix: "\x18Dogecoin Signed Message:\n", + messagePrefix: "\x19Dogecoin Signed Message:\n", minFee: BigInt.from(1), // Not used in stack wallet currently minOutput: dustLimit.raw, // Not used in stack wallet currently feePerKb: BigInt.from(1), // Not used in stack wallet currently diff --git a/lib/wallets/crypto_currency/coins/epiccash.dart b/lib/wallets/crypto_currency/coins/epiccash.dart index e60f90f6d2..97c1b18d3a 100644 --- a/lib/wallets/crypto_currency/coins/epiccash.dart +++ b/lib/wallets/crypto_currency/coins/epiccash.dart @@ -1,7 +1,10 @@ +import 'dart:convert'; + import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../models/node_model.dart'; import '../../../utilities/default_nodes.dart'; import '../../../utilities/enums/derive_path_type_enum.dart'; +import '../../../utilities/enums/epic_transaction_method.dart'; import '../../../wl_gen/interfaces/libepiccash_interface.dart'; import '../crypto_currency.dart'; import '../intermediate/bip39_currency.dart'; @@ -62,7 +65,7 @@ class Epiccash extends Bip39Currency { } } - return libEpic.validateSendAddress(address: address); + return libEpic.validateSendAddressSync(address: address); } @override @@ -70,11 +73,11 @@ class Epiccash extends Bip39Currency { switch (network) { case CryptoCurrencyNetwork.main: return NodeModel( - host: "http://epiccash.stackwallet.com", + host: "https://epic.stackwallet.com", port: 3413, name: DefaultNodes.defaultName, id: DefaultNodes.buildId(this), - useSSL: false, + useSSL: true, enabled: true, coinName: identifier, isFailover: true, @@ -128,6 +131,42 @@ class Epiccash extends Bip39Currency { } } + /// Check if data is a slate JSON. + bool isSlateJson(String data) { + try { + final parsed = jsonDecode(data); + // Check for common slate fields. + return parsed is Map && + (parsed.containsKey('id') || parsed.containsKey('slate_id')) && + (parsed.containsKey('amount') || + parsed.containsKey('participant_data')); + } catch (e) { + return false; + } + } + + /// Check if address is Epicbox format. + bool isEpicboxAddress(String address) { + return address.contains('@'); + } + + /// Check if address is HTTP format. + bool isHttpAddress(String address) { + return address.startsWith('http://') || address.startsWith('https://'); + } + + /// Detect transaction type based on address/data format. + EpicTransactionMethod getTransactionMethod(String addressOrData) { + if (isSlateJson(addressOrData)) { + return EpicTransactionMethod.slatepack; + } else if (isEpicboxAddress(addressOrData) || + isHttpAddress(addressOrData)) { + return EpicTransactionMethod.epicbox; + } else { + throw Exception("Unknown EpicTransactionMethod found!"); + } + } + @override AddressType? getAddressType(String address) { if (validateAddress(address)) { diff --git a/lib/wallets/crypto_currency/coins/fact0rn.dart b/lib/wallets/crypto_currency/coins/fact0rn.dart index 20c17fe16f..1ef113ac04 100644 --- a/lib/wallets/crypto_currency/coins/fact0rn.dart +++ b/lib/wallets/crypto_currency/coins/fact0rn.dart @@ -175,7 +175,7 @@ class Fact0rn extends Bip39HDCurrency with ElectrumXCurrencyInterface { switch (network) { case CryptoCurrencyNetwork.main: return NodeModel( - host: "electrumx1.projectfactor.io", + host: "electrumx2.projectfactor.io", port: 50002, name: DefaultNodes.defaultName, id: DefaultNodes.buildId(this), @@ -225,8 +225,7 @@ class Fact0rn extends Bip39HDCurrency with ElectrumXCurrencyInterface { Uri defaultBlockExplorer(String txid) { switch (network) { case CryptoCurrencyNetwork.main: - // "https://explorer.fact0rn.io/tx/$txid" doesn't show mempool transactions - return Uri.parse("https://factexplorer.io/tx/$txid"); + return Uri.parse("https://explorer.fact0rn.io/tx/$txid"); default: throw Exception( "Unsupported network for defaultBlockExplorer(): $network", diff --git a/lib/wallets/crypto_currency/coins/firo.dart b/lib/wallets/crypto_currency/coins/firo.dart index f432bd77bc..583dc4b8dc 100644 --- a/lib/wallets/crypto_currency/coins/firo.dart +++ b/lib/wallets/crypto_currency/coins/firo.dart @@ -107,7 +107,7 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface { privHDPrefix: 0x0488ade4, pubHDPrefix: 0x0488b21e, bech32Hrp: "bc", - messagePrefix: '\x18Zcoin Signed Message:\n', + messagePrefix: '\x16Zcoin Signed Message:\n', minFee: BigInt.from(1), // Not used in stack wallet currently minOutput: dustLimit.raw, // Not used in stack wallet currently feePerKb: BigInt.from(1), // Not used in stack wallet currently @@ -120,7 +120,7 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface { privHDPrefix: 0x04358394, pubHDPrefix: 0x043587cf, bech32Hrp: "tb", - messagePrefix: "\x18Zcoin Signed Message:\n", + messagePrefix: "\x16Zcoin Signed Message:\n", minFee: BigInt.from(1), // Not used in stack wallet currently minOutput: dustLimit.raw, // Not used in stack wallet currently feePerKb: BigInt.from(1), // Not used in stack wallet currently @@ -298,4 +298,12 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface { @override BigInt get defaultFeeRate => BigInt.from(1000); + + @override + AddressType? getAddressType(String address) { + if (validateSparkAddress(address)) { + return .spark; + } + return super.getAddressType(address); + } } diff --git a/lib/wallets/crypto_currency/coins/mimblewimblecoin.dart b/lib/wallets/crypto_currency/coins/mimblewimblecoin.dart index c9d57878a0..2ed274b35c 100644 --- a/lib/wallets/crypto_currency/coins/mimblewimblecoin.dart +++ b/lib/wallets/crypto_currency/coins/mimblewimblecoin.dart @@ -101,6 +101,7 @@ class Mimblewimblecoin extends Bip39Currency { torEnabled: true, clearnetEnabled: true, isPrimary: true, + nodeApiSecret: '11ne3EAUtOXVKwhxm84U', ); default: diff --git a/lib/wallets/crypto_currency/coins/monero.dart b/lib/wallets/crypto_currency/coins/monero.dart index 3a983cf9fe..379c47d702 100644 --- a/lib/wallets/crypto_currency/coins/monero.dart +++ b/lib/wallets/crypto_currency/coins/monero.dart @@ -52,7 +52,7 @@ class Monero extends CryptonoteCurrency { } switch (network) { case CryptoCurrencyNetwork.main: - return csMonero.validateAddress(address, 0, csCoin: CsCoin.monero); + return csMonero.validateAddress(address, 0); default: throw Exception("Unsupported network: $network"); } diff --git a/lib/wallets/crypto_currency/coins/namecoin.dart b/lib/wallets/crypto_currency/coins/namecoin.dart index 7945e8bca7..464eb0b75c 100644 --- a/lib/wallets/crypto_currency/coins/namecoin.dart +++ b/lib/wallets/crypto_currency/coins/namecoin.dart @@ -148,11 +148,10 @@ class Namecoin 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, @@ -186,7 +185,7 @@ class Namecoin extends Bip39HDCurrency with ElectrumXCurrencyInterface { privHDPrefix: 0x0488ade4, pubHDPrefix: 0x0488b21e, bech32Hrp: "nc", - messagePrefix: '\x18Namecoin Signed Message:\n', + messagePrefix: '\x19Namecoin Signed Message:\n', minFee: BigInt.from(1), // Not used in stack wallet currently minOutput: dustLimit.raw, // Not used in stack wallet currently feePerKb: BigInt.from(1), // Not used in stack wallet currently diff --git a/lib/wallets/crypto_currency/coins/particl.dart b/lib/wallets/crypto_currency/coins/particl.dart index 2b07aad7ea..a631e94e4f 100644 --- a/lib/wallets/crypto_currency/coins/particl.dart +++ b/lib/wallets/crypto_currency/coins/particl.dart @@ -233,7 +233,7 @@ class Particl extends Bip39HDCurrency with ElectrumXCurrencyInterface { } @override - int get transactionVersion => 1; + int get transactionVersion => 160; @override BigInt get defaultFeeRate => BigInt.from(20000); diff --git a/lib/wallets/crypto_currency/coins/solana.dart b/lib/wallets/crypto_currency/coins/solana.dart index 03331a922d..b4f40a86e7 100644 --- a/lib/wallets/crypto_currency/coins/solana.dart +++ b/lib/wallets/crypto_currency/coins/solana.dart @@ -41,6 +41,9 @@ class Solana extends Bip39Currency { @override String get ticker => _ticker; + @override + bool get hasTokenSupport => true; + @override NodeModel defaultNode({required bool isPrimary}) { switch (network) { diff --git a/lib/wallets/crypto_currency/coins/tezos.dart b/lib/wallets/crypto_currency/coins/tezos.dart index 179ae2ce1d..0da163bf44 100644 --- a/lib/wallets/crypto_currency/coins/tezos.dart +++ b/lib/wallets/crypto_currency/coins/tezos.dart @@ -107,8 +107,7 @@ class Tezos extends Bip39Currency { switch (network) { case CryptoCurrencyNetwork.main: return NodeModel( - // TODO: ?Change this to stack wallet one? - host: "https://mainnet.api.tez.ie", + host: "https://tezos.stackwallet.com", port: 443, name: DefaultNodes.defaultName, id: DefaultNodes.buildId(this), diff --git a/lib/wallets/crypto_currency/coins/wownero.dart b/lib/wallets/crypto_currency/coins/wownero.dart index 66d27c3e08..7d0fec49f9 100644 --- a/lib/wallets/crypto_currency/coins/wownero.dart +++ b/lib/wallets/crypto_currency/coins/wownero.dart @@ -1,7 +1,7 @@ import '../../../models/node_model.dart'; import '../../../utilities/default_nodes.dart'; import '../../../utilities/enums/derive_path_type_enum.dart'; -import '../../../wl_gen/interfaces/cs_monero_interface.dart'; +import '../../../wl_gen/interfaces/cs_wownero_interface.dart'; import '../crypto_currency.dart'; import '../intermediate/cryptonote_currency.dart'; @@ -52,7 +52,7 @@ class Wownero extends CryptonoteCurrency { } switch (network) { case CryptoCurrencyNetwork.main: - return csMonero.validateAddress(address, 0, csCoin: CsCoin.wownero); + return csWownero.validateAddress(address, 0); default: throw Exception("Unsupported network: $network"); } diff --git a/lib/wallets/crypto_currency/coins/xelis.dart b/lib/wallets/crypto_currency/coins/xelis.dart index e036c24427..d022082021 100644 --- a/lib/wallets/crypto_currency/coins/xelis.dart +++ b/lib/wallets/crypto_currency/coins/xelis.dart @@ -19,6 +19,10 @@ class Xelis extends ElectrumCurrency { _id = "xelisTestNet"; _name = "tXelis"; _ticker = "XET"; + case CryptoCurrencyNetwork.stage: + _id = "xelisStageNet"; + _name = "sXelis"; + _ticker = "XET"; default: throw Exception("Unsupported network: $network"); } @@ -79,6 +83,22 @@ class Xelis extends ElectrumCurrency { isPrimary: isPrimary, ); + case CryptoCurrencyNetwork.test: + return NodeModel( + host: "stagenet-node.xelis.io", + port: 443, + name: DefaultNodes.defaultName, + id: DefaultNodes.buildId(this), + useSSL: true, + enabled: true, + coinName: identifier, + isFailover: true, + isDown: false, + torEnabled: false, + clearnetEnabled: true, + isPrimary: isPrimary, + ); + default: throw Exception("Unsupported network: $network"); } @@ -93,7 +113,7 @@ class Xelis extends ElectrumCurrency { @override bool validateAddress(String address) { try { - return libXelis.isAddressValid(address: address); + return libXelis.isAddressValid(address: address, network: network); } catch (_) { return false; } @@ -133,7 +153,11 @@ class Xelis extends ElectrumCurrency { Uri defaultBlockExplorer(String txid) { switch (network) { case CryptoCurrencyNetwork.main: - return Uri.parse("https://explorer.xelis.io/txs/$txid"); + return Uri.parse("https://explorer.xelis.io/tx/$txid"); + case CryptoCurrencyNetwork.test: + return Uri.parse("https://testnet-explorer.xelis.io/tx/$txid"); + case CryptoCurrencyNetwork.stage: + return Uri.parse("https://stagenet-explorer.xelis.io/tx/$txid"); default: throw Exception( "Unsupported network for defaultBlockExplorer(): $network", diff --git a/lib/wallets/isar/models/token_wallet_info.dart b/lib/wallets/isar/models/token_wallet_info.dart index 842d04bf0d..767c5a2f9a 100644 --- a/lib/wallets/isar/models/token_wallet_info.dart +++ b/lib/wallets/isar/models/token_wallet_info.dart @@ -38,11 +38,15 @@ class TokenWalletInfo implements IsarId { // token balance cache Balance getCachedBalance() { if (cachedBalanceJsonString == null) { + final amount = Amount( + rawValue: BigInt.zero, + fractionDigits: tokenFractionDigits, + ); return Balance( - total: Amount.zeroWith(fractionDigits: tokenFractionDigits), - spendable: Amount.zeroWith(fractionDigits: tokenFractionDigits), - blockedTotal: Amount.zeroWith(fractionDigits: tokenFractionDigits), - pendingSpendable: Amount.zeroWith(fractionDigits: tokenFractionDigits), + total: amount, + spendable: amount, + blockedTotal: amount, + pendingSpendable: amount, ); } return Balance.fromJson(cachedBalanceJsonString!, tokenFractionDigits); diff --git a/lib/wallets/isar/models/wallet_info.dart b/lib/wallets/isar/models/wallet_info.dart index 5b2d6569c8..12329f8ceb 100644 --- a/lib/wallets/isar/models/wallet_info.dart +++ b/lib/wallets/isar/models/wallet_info.dart @@ -75,6 +75,28 @@ class WalletInfo implements IsarId { } } + @ignore + List get solanaTokenMintAddresses { + if (otherData[WalletInfoKeys.solanaTokenMintAddresses] is List) { + return List.from( + otherData[WalletInfoKeys.solanaTokenMintAddresses] as List, + ); + } else { + return []; + } + } + + @ignore + List get solanaCustomTokenMintAddresses { + if (otherData[WalletInfoKeys.solanaCustomTokenMintAddresses] is List) { + return List.from( + otherData[WalletInfoKeys.solanaCustomTokenMintAddresses] as List, + ); + } else { + return []; + } + } + /// Special case for coins such as firo lelantus @ignore Balance get cachedBalanceSecondary { @@ -114,10 +136,9 @@ class WalletInfo implements IsarId { } @ignore - Map get otherData => - otherDataJsonString == null - ? {} - : Map.from(jsonDecode(otherDataJsonString!) as Map); + Map get otherData => otherDataJsonString == null + ? {} + : Map.from(jsonDecode(otherDataJsonString!) as Map); @ignore bool get isViewOnly => @@ -143,6 +164,10 @@ class WalletInfo implements IsarId { bool get isMwebEnabled => otherData[WalletInfoKeys.mwebEnabled] as bool? ?? false; + @ignore + bool get isLegacyAddressesEnabled => + otherData[WalletInfoKeys.enableLegacyAddresses] as bool? ?? false; + //============================================================================ //============= Updaters ================================================ @@ -248,12 +273,11 @@ class WalletInfo implements IsarId { if (customIndexOverride != null) { index = customIndexOverride; } else if (flag) { - final highest = - await isar.walletInfo - .where() - .sortByFavouriteOrderIndexDesc() - .favouriteOrderIndexProperty() - .findFirst(); + final highest = await isar.walletInfo + .where() + .sortByFavouriteOrderIndexDesc() + .favouriteOrderIndexProperty() + .findFirst(); index = (highest ?? 0) + 1; } else { index = -1; @@ -336,8 +360,10 @@ class WalletInfo implements IsarId { /// Can be dangerous. Don't use unless you know the consequences Future setMnemonicVerified({required Isar isar}) async { - final meta = - await isar.walletInfoMeta.where().walletIdEqualTo(walletId).findFirst(); + final meta = await isar.walletInfoMeta + .where() + .walletIdEqualTo(walletId) + .findFirst(); if (meta == null) { await isar.writeTxn(() async { await isar.walletInfoMeta.put( @@ -396,6 +422,33 @@ class WalletInfo implements IsarId { ); } + /// Update Solana token mint addresses and update the db. + Future updateSolanaTokenMintAddresses({ + required Set newMintAddresses, + required Isar isar, + }) async { + await updateOtherData( + newEntries: { + WalletInfoKeys.solanaTokenMintAddresses: newMintAddresses.toList(), + }, + isar: isar, + ); + } + + /// Update custom Solana token mint addresses and update the db. + Future updateSolanaCustomTokenMintAddresses({ + required List newMintAddresses, + required Isar isar, + }) async { + await updateOtherData( + newEntries: { + WalletInfoKeys.solanaCustomTokenMintAddresses: newMintAddresses + .toList(), + }, + isar: isar, + ); + } + Future setMwebEnabled({ required bool newValue, required Isar isar, @@ -464,12 +517,13 @@ class WalletInfo implements IsarId { int restoreHeight = 0, String? walletIdOverride, String? otherDataJsonString, + AddressType? overrideAddressType, // added hack for spark view only wallets }) { return WalletInfo( coinName: coin.identifier, walletId: walletIdOverride ?? const Uuid().v1(), name: name, - mainAddressType: coin.defaultAddressType, + mainAddressType: overrideAddressType ?? coin.defaultAddressType, restoreHeight: restoreHeight, otherDataJsonString: otherDataJsonString, ); @@ -524,4 +578,10 @@ abstract class WalletInfoKeys { static const String mwebScanHeight = "mwebScanHeightKey"; static const String firoSparkUsedTagsCacheResetVersion = "firoSparkUsedTagsCacheResetVersionKey"; + static const String enableLegacyAddresses = "enableLegacyAddressesKey"; + static const String solanaTokenMintAddresses = "solanaTokenMintAddressesKey"; + static const String solanaCustomTokenMintAddresses = + "solanaCustomTokenMintAddressesKey"; + static const String firoMasternodeCollateralDismissed = + "firoMasternodeCollateralDismissedKey"; } diff --git a/lib/wallets/isar/models/wallet_solana_token_info.dart b/lib/wallets/isar/models/wallet_solana_token_info.dart new file mode 100644 index 0000000000..473db74119 --- /dev/null +++ b/lib/wallets/isar/models/wallet_solana_token_info.dart @@ -0,0 +1,92 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:isar_community/isar.dart'; + +import '../../../models/balance.dart'; +import '../../../models/isar/models/isar_models.dart'; +import '../../../utilities/amount/amount.dart'; +import '../isar_id_interface.dart'; + +part 'wallet_solana_token_info.g.dart'; + +@Collection(accessor: "walletSolanaTokenInfo", inheritance: false) +class WalletSolanaTokenInfo implements IsarId { + @override + Id id = Isar.autoIncrement; + + @Index( + unique: true, + replace: false, + composite: [CompositeIndex("tokenAddress")], + ) + final String walletId; + + final String tokenAddress; // Mint address. + + final int tokenFractionDigits; + + final String? cachedBalanceJsonString; + + WalletSolanaTokenInfo({ + required this.walletId, + required this.tokenAddress, + required this.tokenFractionDigits, + this.cachedBalanceJsonString, + }); + + SolContract getToken(Isar isar) => + isar.solContracts.where().addressEqualTo(tokenAddress).findFirstSync()!; + + // Token balance cache. + Balance getCachedBalance() { + if (cachedBalanceJsonString == null) { + final amount = Amount( + rawValue: BigInt.zero, + fractionDigits: tokenFractionDigits, + ); + return Balance( + total: amount, + spendable: amount, + blockedTotal: amount, + pendingSpendable: amount, + ); + } + return Balance.fromJson(cachedBalanceJsonString!, tokenFractionDigits); + } + + Future updateCachedBalance( + Balance balance, { + required Isar isar, + }) async { + // Ensure we are updating using the latest entry of this in the db. + final thisEntry = + await isar.walletSolanaTokenInfo + .where() + .walletIdTokenAddressEqualTo(walletId, tokenAddress) + .findFirst(); + if (thisEntry == null) { + throw Exception( + "Attempted to update cached token balance before object was saved in db", + ); + } else { + await isar.writeTxn(() async { + await isar.walletSolanaTokenInfo.delete(thisEntry.id); + await isar.walletSolanaTokenInfo.put( + WalletSolanaTokenInfo( + walletId: walletId, + tokenAddress: tokenAddress, + tokenFractionDigits: tokenFractionDigits, + cachedBalanceJsonString: balance.toJsonIgnoreCoin(), + )..id = thisEntry.id, + ); + }); + } + } +} diff --git a/lib/wallets/isar/models/wallet_solana_token_info.g.dart b/lib/wallets/isar/models/wallet_solana_token_info.g.dart new file mode 100644 index 0000000000..bd83700810 --- /dev/null +++ b/lib/wallets/isar/models/wallet_solana_token_info.g.dart @@ -0,0 +1,1433 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'wallet_solana_token_info.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetWalletSolanaTokenInfoCollection on Isar { + IsarCollection get walletSolanaTokenInfo => + this.collection(); +} + +const WalletSolanaTokenInfoSchema = CollectionSchema( + name: r'WalletSolanaTokenInfo', + id: 7293372558936095532, + properties: { + r'cachedBalanceJsonString': PropertySchema( + id: 0, + name: r'cachedBalanceJsonString', + type: IsarType.string, + ), + r'tokenAddress': PropertySchema( + id: 1, + name: r'tokenAddress', + type: IsarType.string, + ), + r'tokenFractionDigits': PropertySchema( + id: 2, + name: r'tokenFractionDigits', + type: IsarType.long, + ), + r'walletId': PropertySchema( + id: 3, + name: r'walletId', + type: IsarType.string, + ), + }, + + estimateSize: _walletSolanaTokenInfoEstimateSize, + serialize: _walletSolanaTokenInfoSerialize, + deserialize: _walletSolanaTokenInfoDeserialize, + deserializeProp: _walletSolanaTokenInfoDeserializeProp, + idName: r'id', + indexes: { + r'walletId_tokenAddress': IndexSchema( + id: -7747794843092592407, + name: r'walletId_tokenAddress', + unique: true, + replace: false, + properties: [ + IndexPropertySchema( + name: r'walletId', + type: IndexType.hash, + caseSensitive: true, + ), + IndexPropertySchema( + name: r'tokenAddress', + type: IndexType.hash, + caseSensitive: true, + ), + ], + ), + }, + links: {}, + embeddedSchemas: {}, + + getId: _walletSolanaTokenInfoGetId, + getLinks: _walletSolanaTokenInfoGetLinks, + attach: _walletSolanaTokenInfoAttach, + version: '3.3.0-dev.2', +); + +int _walletSolanaTokenInfoEstimateSize( + WalletSolanaTokenInfo object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + { + final value = object.cachedBalanceJsonString; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + bytesCount += 3 + object.tokenAddress.length * 3; + bytesCount += 3 + object.walletId.length * 3; + return bytesCount; +} + +void _walletSolanaTokenInfoSerialize( + WalletSolanaTokenInfo object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeString(offsets[0], object.cachedBalanceJsonString); + writer.writeString(offsets[1], object.tokenAddress); + writer.writeLong(offsets[2], object.tokenFractionDigits); + writer.writeString(offsets[3], object.walletId); +} + +WalletSolanaTokenInfo _walletSolanaTokenInfoDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = WalletSolanaTokenInfo( + cachedBalanceJsonString: reader.readStringOrNull(offsets[0]), + tokenAddress: reader.readString(offsets[1]), + tokenFractionDigits: reader.readLong(offsets[2]), + walletId: reader.readString(offsets[3]), + ); + object.id = id; + return object; +} + +P _walletSolanaTokenInfoDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readStringOrNull(offset)) as P; + case 1: + return (reader.readString(offset)) as P; + case 2: + return (reader.readLong(offset)) as P; + case 3: + return (reader.readString(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _walletSolanaTokenInfoGetId(WalletSolanaTokenInfo object) { + return object.id; +} + +List> _walletSolanaTokenInfoGetLinks( + WalletSolanaTokenInfo object, +) { + return []; +} + +void _walletSolanaTokenInfoAttach( + IsarCollection col, + Id id, + WalletSolanaTokenInfo object, +) { + object.id = id; +} + +extension WalletSolanaTokenInfoByIndex + on IsarCollection { + Future getByWalletIdTokenAddress( + String walletId, + String tokenAddress, + ) { + return getByIndex(r'walletId_tokenAddress', [walletId, tokenAddress]); + } + + WalletSolanaTokenInfo? getByWalletIdTokenAddressSync( + String walletId, + String tokenAddress, + ) { + return getByIndexSync(r'walletId_tokenAddress', [walletId, tokenAddress]); + } + + Future deleteByWalletIdTokenAddress( + String walletId, + String tokenAddress, + ) { + return deleteByIndex(r'walletId_tokenAddress', [walletId, tokenAddress]); + } + + bool deleteByWalletIdTokenAddressSync(String walletId, String tokenAddress) { + return deleteByIndexSync(r'walletId_tokenAddress', [ + walletId, + tokenAddress, + ]); + } + + Future> getAllByWalletIdTokenAddress( + List walletIdValues, + List tokenAddressValues, + ) { + final len = walletIdValues.length; + assert( + tokenAddressValues.length == len, + 'All index values must have the same length', + ); + final values = >[]; + for (var i = 0; i < len; i++) { + values.add([walletIdValues[i], tokenAddressValues[i]]); + } + + return getAllByIndex(r'walletId_tokenAddress', values); + } + + List getAllByWalletIdTokenAddressSync( + List walletIdValues, + List tokenAddressValues, + ) { + final len = walletIdValues.length; + assert( + tokenAddressValues.length == len, + 'All index values must have the same length', + ); + final values = >[]; + for (var i = 0; i < len; i++) { + values.add([walletIdValues[i], tokenAddressValues[i]]); + } + + return getAllByIndexSync(r'walletId_tokenAddress', values); + } + + Future deleteAllByWalletIdTokenAddress( + List walletIdValues, + List tokenAddressValues, + ) { + final len = walletIdValues.length; + assert( + tokenAddressValues.length == len, + 'All index values must have the same length', + ); + final values = >[]; + for (var i = 0; i < len; i++) { + values.add([walletIdValues[i], tokenAddressValues[i]]); + } + + return deleteAllByIndex(r'walletId_tokenAddress', values); + } + + int deleteAllByWalletIdTokenAddressSync( + List walletIdValues, + List tokenAddressValues, + ) { + final len = walletIdValues.length; + assert( + tokenAddressValues.length == len, + 'All index values must have the same length', + ); + final values = >[]; + for (var i = 0; i < len; i++) { + values.add([walletIdValues[i], tokenAddressValues[i]]); + } + + return deleteAllByIndexSync(r'walletId_tokenAddress', values); + } + + Future putByWalletIdTokenAddress(WalletSolanaTokenInfo object) { + return putByIndex(r'walletId_tokenAddress', object); + } + + Id putByWalletIdTokenAddressSync( + WalletSolanaTokenInfo object, { + bool saveLinks = true, + }) { + return putByIndexSync( + r'walletId_tokenAddress', + object, + saveLinks: saveLinks, + ); + } + + Future> putAllByWalletIdTokenAddress( + List objects, + ) { + return putAllByIndex(r'walletId_tokenAddress', objects); + } + + List putAllByWalletIdTokenAddressSync( + List objects, { + bool saveLinks = true, + }) { + return putAllByIndexSync( + r'walletId_tokenAddress', + objects, + saveLinks: saveLinks, + ); + } +} + +extension WalletSolanaTokenInfoQueryWhereSort + on QueryBuilder { + QueryBuilder + anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension WalletSolanaTokenInfoQueryWhere + on + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QWhereClause + > { + QueryBuilder + idEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between(lower: id, upper: id)); + }); + } + + QueryBuilder + idNotEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder + idGreaterThan(Id id, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder + idLessThan(Id id, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder + idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder + walletIdEqualToAnyTokenAddress(String walletId) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IndexWhereClause.equalTo( + indexName: r'walletId_tokenAddress', + value: [walletId], + ), + ); + }); + } + + QueryBuilder + walletIdNotEqualToAnyTokenAddress(String walletId) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [], + upper: [walletId], + includeUpper: false, + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId], + includeLower: false, + upper: [], + ), + ); + } else { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId], + includeLower: false, + upper: [], + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [], + upper: [walletId], + includeUpper: false, + ), + ); + } + }); + } + + QueryBuilder + walletIdTokenAddressEqualTo(String walletId, String tokenAddress) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IndexWhereClause.equalTo( + indexName: r'walletId_tokenAddress', + value: [walletId, tokenAddress], + ), + ); + }); + } + + QueryBuilder + walletIdEqualToTokenAddressNotEqualTo(String walletId, String tokenAddress) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId], + upper: [walletId, tokenAddress], + includeUpper: false, + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId, tokenAddress], + includeLower: false, + upper: [walletId], + ), + ); + } else { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId, tokenAddress], + includeLower: false, + upper: [walletId], + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId], + upper: [walletId, tokenAddress], + includeUpper: false, + ), + ); + } + }); + } +} + +extension WalletSolanaTokenInfoQueryFilter + on + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QFilterCondition + > { + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNull(property: r'cachedBalanceJsonString'), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNotNull(property: r'cachedBalanceJsonString'), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringEqualTo(String? value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'cachedBalanceJsonString', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'cachedBalanceJsonString', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'cachedBalanceJsonString', + value: '', + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + property: r'cachedBalanceJsonString', + value: '', + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + idEqualTo(Id value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'id', value: value), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + idGreaterThan(Id value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + idLessThan(Id value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + idBetween( + Id lower, + Id upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressEqualTo(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'tokenAddress', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'tokenAddress', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'tokenAddress', value: ''), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'tokenAddress', value: ''), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenFractionDigitsEqualTo(int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'tokenFractionDigits', value: value), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenFractionDigitsGreaterThan(int value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'tokenFractionDigits', + value: value, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenFractionDigitsLessThan(int value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'tokenFractionDigits', + value: value, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenFractionDigitsBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'tokenFractionDigits', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdEqualTo(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'walletId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'walletId', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'walletId', value: ''), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'walletId', value: ''), + ); + }); + } +} + +extension WalletSolanaTokenInfoQueryObject + on + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QFilterCondition + > {} + +extension WalletSolanaTokenInfoQueryLinks + on + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QFilterCondition + > {} + +extension WalletSolanaTokenInfoQuerySortBy + on QueryBuilder { + QueryBuilder + sortByCachedBalanceJsonString() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cachedBalanceJsonString', Sort.asc); + }); + } + + QueryBuilder + sortByCachedBalanceJsonStringDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cachedBalanceJsonString', Sort.desc); + }); + } + + QueryBuilder + sortByTokenAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenAddress', Sort.asc); + }); + } + + QueryBuilder + sortByTokenAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenAddress', Sort.desc); + }); + } + + QueryBuilder + sortByTokenFractionDigits() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenFractionDigits', Sort.asc); + }); + } + + QueryBuilder + sortByTokenFractionDigitsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenFractionDigits', Sort.desc); + }); + } + + QueryBuilder + sortByWalletId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.asc); + }); + } + + QueryBuilder + sortByWalletIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.desc); + }); + } +} + +extension WalletSolanaTokenInfoQuerySortThenBy + on QueryBuilder { + QueryBuilder + thenByCachedBalanceJsonString() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cachedBalanceJsonString', Sort.asc); + }); + } + + QueryBuilder + thenByCachedBalanceJsonStringDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cachedBalanceJsonString', Sort.desc); + }); + } + + QueryBuilder + thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder + thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } + + QueryBuilder + thenByTokenAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenAddress', Sort.asc); + }); + } + + QueryBuilder + thenByTokenAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenAddress', Sort.desc); + }); + } + + QueryBuilder + thenByTokenFractionDigits() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenFractionDigits', Sort.asc); + }); + } + + QueryBuilder + thenByTokenFractionDigitsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenFractionDigits', Sort.desc); + }); + } + + QueryBuilder + thenByWalletId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.asc); + }); + } + + QueryBuilder + thenByWalletIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.desc); + }); + } +} + +extension WalletSolanaTokenInfoQueryWhereDistinct + on QueryBuilder { + QueryBuilder + distinctByCachedBalanceJsonString({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy( + r'cachedBalanceJsonString', + caseSensitive: caseSensitive, + ); + }); + } + + QueryBuilder + distinctByTokenAddress({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'tokenAddress', caseSensitive: caseSensitive); + }); + } + + QueryBuilder + distinctByTokenFractionDigits() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'tokenFractionDigits'); + }); + } + + QueryBuilder + distinctByWalletId({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'walletId', caseSensitive: caseSensitive); + }); + } +} + +extension WalletSolanaTokenInfoQueryProperty + on + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QQueryProperty + > { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder + cachedBalanceJsonStringProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'cachedBalanceJsonString'); + }); + } + + QueryBuilder + tokenAddressProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'tokenAddress'); + }); + } + + QueryBuilder + tokenFractionDigitsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'tokenFractionDigits'); + }); + } + + QueryBuilder + walletIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'walletId'); + }); + } +} diff --git a/lib/wallets/isar/providers/solana/current_sol_token_wallet_provider.dart b/lib/wallets/isar/providers/solana/current_sol_token_wallet_provider.dart new file mode 100644 index 0000000000..ab17451220 --- /dev/null +++ b/lib/wallets/isar/providers/solana/current_sol_token_wallet_provider.dart @@ -0,0 +1,15 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../wallet/impl/sub_wallets/solana_token_wallet.dart'; + +/// State provider for the currently active Solana token wallet. +/// +/// This allows global tracking of which token wallet is being viewed/interacted-with. +final solanaTokenServiceStateProvider = + StateProvider((ref) => null); + +/// Public provider to read the current active Solana token wallet. +/// +/// Use this in UI widgets to get the active token wallet. +final pCurrentSolanaTokenWallet = + Provider((ref) => ref.watch(solanaTokenServiceStateProvider)); diff --git a/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart b/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart new file mode 100644 index 0000000000..739e231591 --- /dev/null +++ b/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart @@ -0,0 +1,108 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; + +import '../../../../models/balance.dart'; +import '../../../../models/isar/models/isar_models.dart'; +import '../../../../providers/db/main_db_provider.dart'; +import '../../../../utilities/logger.dart'; +import '../util/watcher.dart'; + +/// Provider family for Solana token wallet info. +/// +/// Watches the Isar database for changes to WalletSolanaTokenInfo. +/// Mirrors the pattern used for Ethereum token balances (TokenWalletInfo). +/// +/// Example usage: +/// final info = ref.watch( +/// pSolanaTokenWalletInfo((walletId: 'wallet1', tokenMint: 'EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h')) +/// ); +final _wstwiProvider = ChangeNotifierProvider.family< + Watcher, + ({String walletId, String tokenMint}) +>((ref, data) { + final isar = ref.watch(mainDBProvider).isar; + + final collection = isar.walletSolanaTokenInfo; + + Logging.instance.i( + "pSolanaTokenBalance: Looking up WalletSolanaTokenInfo for walletId=${data.walletId}, tokenMint=${data.tokenMint}", + ); + + WalletSolanaTokenInfo? initial = collection + .where() + .walletIdTokenAddressEqualTo(data.walletId, data.tokenMint) + .findFirstSync(); + + if (initial == null) { + Logging.instance.i( + "pSolanaTokenBalance: Creating new WalletSolanaTokenInfo entry", + ); + + // Create initial entry if not found. + final solContract = + isar.solContracts.getByAddressSync(data.tokenMint); + + initial = WalletSolanaTokenInfo( + walletId: data.walletId, + tokenAddress: data.tokenMint, + tokenFractionDigits: solContract?.decimals ?? 6, + ); + + isar.writeTxnSync(() => isar.walletSolanaTokenInfo.putSync(initial!)); + + // After insert, fetch the object again to get the assigned ID. + initial = collection + .where() + .walletIdTokenAddressEqualTo(data.walletId, data.tokenMint) + .findFirstSync()!; + + Logging.instance.i( + "pSolanaTokenBalance: Created entry with ID=${initial.id}, balance=${initial.getCachedBalance().total}", + ); + } else { + Logging.instance.i( + "pSolanaTokenBalance: Found existing entry with ID=${initial.id}, cachedBalance=${initial.getCachedBalance().total}", + ); + } + + final watcher = Watcher(initial, collection: collection); + + ref.onDispose(() => watcher.dispose()); + + return watcher; +}); + +/// Provider for Solana token wallet info from the database. +final pSolanaTokenWalletInfo = Provider.family< + WalletSolanaTokenInfo, + ({String walletId, String tokenMint}) +>((ref, data) { + return ref.watch(_wstwiProvider(data).select((value) => value.value)) + as WalletSolanaTokenInfo; +}); + +/// Provider for Solana token balance from the database. +/// +/// This provider watches the Isar database and will automatically update +/// the UI whenever the balance changes in the database. +/// +/// Example usage: +/// final balance = ref.watch( +/// pSolanaTokenBalance((walletId: 'wallet1', tokenMint: 'EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h')) +/// ); +final pSolanaTokenBalance = Provider.family< + Balance, + ({String walletId, String tokenMint}) +>((ref, data) { + final balance = ref.watch( + _wstwiProvider(data).select( + (value) => (value.value as WalletSolanaTokenInfo).getCachedBalance(), + ), + ); + + Logging.instance.i( + "pSolanaTokenBalance: Returning balance=${balance.total} for walletId=${data.walletId}, tokenMint=${data.tokenMint}", + ); + + return balance; +}); diff --git a/lib/wallets/isar/providers/solana/sol_tokens_provider.dart b/lib/wallets/isar/providers/solana/sol_tokens_provider.dart new file mode 100644 index 0000000000..fe6a711bbe --- /dev/null +++ b/lib/wallets/isar/providers/solana/sol_tokens_provider.dart @@ -0,0 +1,30 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Provides a list of Solana token mint addresses for a specific wallet. +/// +/// This provider returns the list of Solana token mint addresses +/// that the wallet has selected. Token details are not currently persisted +/// in the database - only the mint addresses are stored in WalletInfo's otherData. +/// +/// Example usage: +/// ``` +/// final tokenAddresses = ref.watch(pSolanaWalletTokenAddresses('wallet_id')); +/// ``` +/// Note: For full token details (name, symbol, decimals), these would need to be +/// fetched from the Solana token metadata or a token list API. +final pSolanaWalletTokens = Provider.family, String>( + (ref, walletId) { + // TODO: Implement token details fetching from Solana metadata or API. + // For now, just return an empty list as token details are not persisted. + return []; + }, +); diff --git a/lib/wallets/isar/providers/solana/solana_wallet_provider.dart b/lib/wallets/isar/providers/solana/solana_wallet_provider.dart new file mode 100644 index 0000000000..7a0f5db4c1 --- /dev/null +++ b/lib/wallets/isar/providers/solana/solana_wallet_provider.dart @@ -0,0 +1,26 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../wallet/impl/solana_wallet.dart'; +import '../../../../providers/global/wallets_provider.dart'; + +/// Provider that returns a Solana wallet by ID, or null if the wallet is not a SolanaWallet. +/// +/// This provides type-safe access to Solana wallets without needing runtime type checks +/// in every view. If you need to get a Solana wallet, use this provider instead of +/// manually checking the type of the wallet returned by pWallets. +/// +/// Example: +/// ```dart +/// final solanaWallet = ref.read(pSolanaWallet(walletId)); +/// if (solanaWallet == null) { +/// // Handle error: wallet is not a Solana wallet +/// return; +/// } +/// // Use solanaWallet safely, knowing it's definitely a SolanaWallet +/// ``` +final pSolanaWallet = Provider.family((ref, walletId) { + final wallets = ref.watch(pWallets); + final wallet = wallets.getWallet(walletId); + + return wallet is SolanaWallet ? wallet : null; +}); diff --git a/lib/wallets/isar/providers/wallet_info_provider.dart b/lib/wallets/isar/providers/wallet_info_provider.dart index d6469879e2..354658dbff 100644 --- a/lib/wallets/isar/providers/wallet_info_provider.dart +++ b/lib/wallets/isar/providers/wallet_info_provider.dart @@ -96,13 +96,26 @@ final pWalletReceivingAddress = Provider.family(( ); }); +/// Provider for wallet token addresses (Ethereum) or token mint addresses (Solana). +/// +/// Returns the appropriate token list based on the wallet's coin type. +/// +/// For Ethereum wallets: returns tokenContractAddresses. +/// For Solana wallets: returns solanaTokenMintAddresses + solanaCustomTokenMintAddresses combined. final pWalletTokenAddresses = Provider.family, String>(( ref, walletId, ) { - return ref.watch( - _wiProvider( - walletId, - ).select((value) => (value.value as WalletInfo).tokenContractAddresses), - ); + final walletInfo = ref.watch(pWalletInfo(walletId)); + + if (walletInfo.coin.prettyName == 'Solana') { + // Combine both default and custom token mint addresses. + final allTokens = { + ...walletInfo.solanaTokenMintAddresses, + ...walletInfo.solanaCustomTokenMintAddresses, + }; + return allTokens.toList(); + } else { + return walletInfo.tokenContractAddresses; + } }); diff --git a/lib/wallets/models/tx_data.dart b/lib/wallets/models/tx_data.dart index 5db6d94835..744d848107 100644 --- a/lib/wallets/models/tx_data.dart +++ b/lib/wallets/models/tx_data.dart @@ -1,3 +1,6 @@ +import 'dart:typed_data'; + +import 'package:solana/encoder.dart' show Instruction; import 'package:tezart/tezart.dart' as tezart; import 'package:web3dart/web3dart.dart' as web3dart; @@ -7,6 +10,7 @@ import '../../models/isar/models/isar_models.dart'; import '../../models/paynym/paynym_account_lite.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/enums/fee_rate_type_enum.dart'; +import '../../utilities/extensions/impl/uint8_list.dart'; import '../../widgets/eth_fee_form.dart'; import '../../wl_gen/interfaces/cs_monero_interface.dart' show CsPendingTransaction; @@ -66,6 +70,10 @@ class TxData { final web3dart.Transaction? web3dartTransaction; final int? nonce; final BigInt? chainId; + + // Solana token-specific. + final List? solInstructions; + // wownero and monero specific final CsPendingTransaction? pendingTransaction; @@ -79,6 +87,7 @@ class TxData { final List<({String address, Amount amount, String memo, bool isChange})>? sparkRecipients; final List? sparkMints; + final List? sparkSpends; final List? usedSparkCoins; final ({ String additionalInfo, @@ -87,6 +96,8 @@ class TxData { int validBlocks, })? sparkNameInfo; + final Uint8List? vExtraData; + final int? overrideVersion; // xelis specific final String? otherData; @@ -102,6 +113,9 @@ class TxData { final bool salviumStakeTx; + // Generic OP_RETURN data (hex string) - for Rosen Bridge and other protocols + final String? opReturnData; + TxData({ this.feeRateType, this.feeRateAmount, @@ -125,17 +139,22 @@ class TxData { this.web3dartTransaction, this.nonce, this.chainId, + this.solInstructions, this.pendingTransaction, this.pendingSalviumTransaction, this.tezosOperationsList, this.sparkRecipients, this.otherData, this.sparkMints, + this.sparkSpends, this.usedSparkCoins, this.tempTx, this.ignoreCachedBalanceChecks = false, this.opNameState, this.sparkNameInfo, + this.vExtraData, + this.overrideVersion, + this.opReturnData, this.type = TxType.regular, this.salviumStakeTx = false, }); @@ -250,6 +269,7 @@ class TxData { String? noteOnChain, String? memo, String? otherData, + String? opReturnData, Set? utxos, List? usedUTXOs, List? recipients, @@ -261,6 +281,7 @@ class TxData { web3dart.Transaction? web3dartTransaction, int? nonce, BigInt? chainId, + List? solInstructions, CsPendingTransaction? pendingTransaction, CsPendingTransaction? pendingSalviumTransaction, int? jMintValue, @@ -273,6 +294,7 @@ class TxData { List<({String address, Amount amount, String memo, bool isChange})>? sparkRecipients, List? sparkMints, + List? sparkSpends, List? usedSparkCoins, TransactionV2? tempTx, bool? ignoreCachedBalanceChecks, @@ -284,6 +306,8 @@ class TxData { int validBlocks, })? sparkNameInfo, + Uint8List? vExtraData, + int? overrideVersion, TxType? type, }) { return TxData( @@ -310,18 +334,23 @@ class TxData { web3dartTransaction: web3dartTransaction ?? this.web3dartTransaction, nonce: nonce ?? this.nonce, chainId: chainId ?? this.chainId, + solInstructions: solInstructions ?? this.solInstructions, pendingTransaction: pendingTransaction ?? this.pendingTransaction, pendingSalviumTransaction: pendingSalviumTransaction ?? this.pendingSalviumTransaction, tezosOperationsList: tezosOperationsList ?? this.tezosOperationsList, sparkRecipients: sparkRecipients ?? this.sparkRecipients, sparkMints: sparkMints ?? this.sparkMints, + sparkSpends: sparkSpends ?? this.sparkSpends, usedSparkCoins: usedSparkCoins ?? this.usedSparkCoins, tempTx: tempTx ?? this.tempTx, ignoreCachedBalanceChecks: ignoreCachedBalanceChecks ?? this.ignoreCachedBalanceChecks, opNameState: opNameState ?? this.opNameState, sparkNameInfo: sparkNameInfo ?? this.sparkNameInfo, + vExtraData: vExtraData ?? this.vExtraData, + overrideVersion: overrideVersion ?? this.overrideVersion, + opReturnData: opReturnData ?? this.opReturnData, type: type ?? this.type, ); } @@ -350,17 +379,22 @@ class TxData { 'web3dartTransaction: $web3dartTransaction, ' 'nonce: $nonce, ' 'chainId: $chainId, ' + 'solInstructions: $solInstructions, ' 'pendingTransaction: $pendingTransaction, ' 'pendingSalviumTransaction: $pendingSalviumTransaction, ' 'tezosOperationsList: $tezosOperationsList, ' 'sparkRecipients: $sparkRecipients, ' 'sparkMints: $sparkMints, ' + 'sparkSpends: $sparkSpends, ' 'usedSparkCoins: $usedSparkCoins, ' 'otherData: $otherData, ' 'tempTx: $tempTx, ' 'ignoreCachedBalanceChecks: $ignoreCachedBalanceChecks, ' 'opNameState: $opNameState, ' 'sparkNameInfo: $sparkNameInfo, ' + 'vExtraData: ${vExtraData?.toHex}, ' + 'overrideVersion: $overrideVersion, ' + 'opReturnData: $opReturnData, ' 'type: $type, ' '}'; } diff --git a/lib/wallets/wallet/impl/bitcoin_wallet.dart b/lib/wallets/wallet/impl/bitcoin_wallet.dart index 6361ec135d..dc1cf5df40 100644 --- a/lib/wallets/wallet/impl/bitcoin_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_wallet.dart @@ -37,18 +37,17 @@ class BitcoinWallet extends Bip39HDWallet @override Future> fetchAddressesForElectrumXScan() async { - final allAddresses = - await mainDB - .getAddresses(walletId) - .filter() - .not() - .group( - (q) => q - .typeEqualTo(AddressType.nonWallet) - .or() - .subTypeEqualTo(AddressSubType.nonWallet), - ) - .findAll(); + final allAddresses = await mainDB + .getAddresses(walletId) + .filter() + .not() + .group( + (q) => q + .typeEqualTo(AddressType.nonWallet) + .or() + .subTypeEqualTo(AddressSubType.nonWallet), + ) + .findAll(); return allAddresses; } @@ -71,7 +70,7 @@ class BitcoinWallet extends Bip39HDWallet @override int estimateTxFee({required int vSize, required BigInt feeRatePerKB}) { - return vSize * (feeRatePerKB.toInt() / 1000).ceil(); + return (feeRatePerKB * BigInt.from(vSize) ~/ BigInt.from(1000)).toInt(); } // diff --git a/lib/wallets/wallet/impl/bitcoincash_wallet.dart b/lib/wallets/wallet/impl/bitcoincash_wallet.dart index 5edcbf9f75..4191052bc2 100644 --- a/lib/wallets/wallet/impl/bitcoincash_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoincash_wallet.dart @@ -67,20 +67,19 @@ class BitcoincashWallet @override Future> fetchAddressesForElectrumXScan() async { - final allAddresses = - await mainDB - .getAddresses(walletId) - .filter() - .not() - .typeEqualTo(AddressType.nonWallet) - .and() - .group( - (q) => q - .subTypeEqualTo(AddressSubType.receiving) - .or() - .subTypeEqualTo(AddressSubType.change), - ) - .findAll(); + final allAddresses = await mainDB + .getAddresses(walletId) + .filter() + .not() + .typeEqualTo(AddressType.nonWallet) + .and() + .group( + (q) => q + .subTypeEqualTo(AddressSubType.receiving) + .or() + .subTypeEqualTo(AddressSubType.change), + ) + .findAll(); return allAddresses; } @@ -103,17 +102,15 @@ class BitcoincashWallet final List

allAddressesOld = await fetchAddressesForElectrumXScan(); - final Set receivingAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.receiving) - .map((e) => convertAddressString(e.value)) - .toSet(); + final Set receivingAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.receiving) + .map((e) => convertAddressString(e.value)) + .toSet(); - final Set changeAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.change) - .map((e) => convertAddressString(e.value)) - .toSet(); + final Set changeAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.change) + .map((e) => convertAddressString(e.value)) + .toSet(); final allAddressesSet = {...receivingAddresses, ...changeAddresses}; @@ -389,7 +386,7 @@ class BitcoincashWallet @override int estimateTxFee({required int vSize, required BigInt feeRatePerKB}) { - return vSize * (feeRatePerKB.toInt() / 1000).ceil(); + return (feeRatePerKB * BigInt.from(vSize) ~/ BigInt.from(1000)).toInt(); } @override diff --git a/lib/wallets/wallet/impl/dash_wallet.dart b/lib/wallets/wallet/impl/dash_wallet.dart index 9d39bd26f7..a00faf77c5 100644 --- a/lib/wallets/wallet/impl/dash_wallet.dart +++ b/lib/wallets/wallet/impl/dash_wallet.dart @@ -36,18 +36,17 @@ class DashWallet extends Bip39HDWallet @override Future> fetchAddressesForElectrumXScan() async { - final allAddresses = - await mainDB - .getAddresses(walletId) - .filter() - .not() - .group( - (q) => q - .typeEqualTo(AddressType.nonWallet) - .or() - .subTypeEqualTo(AddressSubType.nonWallet), - ) - .findAll(); + final allAddresses = await mainDB + .getAddresses(walletId) + .filter() + .not() + .group( + (q) => q + .typeEqualTo(AddressType.nonWallet) + .or() + .subTypeEqualTo(AddressSubType.nonWallet), + ) + .findAll(); return allAddresses; } @@ -60,16 +59,14 @@ class DashWallet extends Bip39HDWallet await fetchAddressesForElectrumXScan(); // Separate receiving and change addresses. - final Set receivingAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.receiving) - .map((e) => e.value) - .toSet(); - final Set changeAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.change) - .map((e) => e.value) - .toSet(); + final Set receivingAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.receiving) + .map((e) => e.value) + .toSet(); + final Set changeAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.change) + .map((e) => e.value) + .toSet(); // Remove duplicates. final allAddressesSet = {...receivingAddresses, ...changeAddresses}; @@ -83,11 +80,10 @@ class DashWallet extends Bip39HDWallet final List> allTransactions = []; for (final txHash in allTxHashes) { // Check for duplicates by searching for tx by tx_hash in db. - final storedTx = - await mainDB.isar.transactionV2s - .where() - .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) - .findFirst(); + final storedTx = await mainDB.isar.transactionV2s + .where() + .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) + .findFirst(); if (storedTx == null || storedTx.height == null || @@ -319,6 +315,6 @@ class DashWallet extends Bip39HDWallet @override int estimateTxFee({required int vSize, required BigInt feeRatePerKB}) { - return vSize * (feeRatePerKB.toInt() / 1000).ceil(); + return (feeRatePerKB * BigInt.from(vSize) ~/ BigInt.from(1000)).toInt(); } } diff --git a/lib/wallets/wallet/impl/dogecoin_wallet.dart b/lib/wallets/wallet/impl/dogecoin_wallet.dart index 01a1eed402..444b0bafaf 100644 --- a/lib/wallets/wallet/impl/dogecoin_wallet.dart +++ b/lib/wallets/wallet/impl/dogecoin_wallet.dart @@ -38,18 +38,17 @@ class DogecoinWallet @override Future> fetchAddressesForElectrumXScan() async { - final allAddresses = - await mainDB - .getAddresses(walletId) - .filter() - .not() - .group( - (q) => q - .typeEqualTo(AddressType.nonWallet) - .or() - .subTypeEqualTo(AddressSubType.nonWallet), - ) - .findAll(); + final allAddresses = await mainDB + .getAddresses(walletId) + .filter() + .not() + .group( + (q) => q + .typeEqualTo(AddressType.nonWallet) + .or() + .subTypeEqualTo(AddressSubType.nonWallet), + ) + .findAll(); return allAddresses; } @@ -62,16 +61,14 @@ class DogecoinWallet await fetchAddressesForElectrumXScan(); // Separate receiving and change addresses. - final Set receivingAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.receiving) - .map((e) => e.value) - .toSet(); - final Set changeAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.change) - .map((e) => e.value) - .toSet(); + final Set receivingAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.receiving) + .map((e) => e.value) + .toSet(); + final Set changeAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.change) + .map((e) => e.value) + .toSet(); // Remove duplicates. final allAddressesSet = {...receivingAddresses, ...changeAddresses}; @@ -85,11 +82,10 @@ class DogecoinWallet final List> allTransactions = []; for (final txHash in allTxHashes) { // Check for duplicates by searching for tx by tx_hash in db. - final storedTx = - await mainDB.isar.transactionV2s - .where() - .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) - .findFirst(); + final storedTx = await mainDB.isar.transactionV2s + .where() + .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) + .findFirst(); if (storedTx == null || storedTx.height == null || @@ -323,6 +319,6 @@ class DogecoinWallet @override int estimateTxFee({required int vSize, required BigInt feeRatePerKB}) { - return vSize * (feeRatePerKB.toInt() / 1000).ceil(); + return (feeRatePerKB * BigInt.from(vSize) ~/ BigInt.from(1000)).toInt(); } } diff --git a/lib/wallets/wallet/impl/ecash_wallet.dart b/lib/wallets/wallet/impl/ecash_wallet.dart index 4a72b2b945..9e83afb70f 100644 --- a/lib/wallets/wallet/impl/ecash_wallet.dart +++ b/lib/wallets/wallet/impl/ecash_wallet.dart @@ -55,16 +55,15 @@ class EcashWallet extends Bip39HDWallet @override Future> fetchAddressesForElectrumXScan() async { - final allAddresses = - await mainDB - .getAddresses(walletId) - .filter() - .not() - .typeEqualTo(AddressType.nonWallet) - .and() - .not() - .subTypeEqualTo(AddressSubType.nonWallet) - .findAll(); + final allAddresses = await mainDB + .getAddresses(walletId) + .filter() + .not() + .typeEqualTo(AddressType.nonWallet) + .and() + .not() + .subTypeEqualTo(AddressSubType.nonWallet) + .findAll(); return allAddresses; } @@ -87,17 +86,15 @@ class EcashWallet extends Bip39HDWallet final List
allAddressesOld = await fetchAddressesForElectrumXScan(); - final Set receivingAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.receiving) - .map((e) => convertAddressString(e.value)) - .toSet(); + final Set receivingAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.receiving) + .map((e) => convertAddressString(e.value)) + .toSet(); - final Set changeAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.change) - .map((e) => convertAddressString(e.value)) - .toSet(); + final Set changeAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.change) + .map((e) => convertAddressString(e.value)) + .toSet(); final allAddressesSet = {...receivingAddresses, ...changeAddresses}; @@ -108,11 +105,10 @@ class EcashWallet extends Bip39HDWallet final List> allTransactions = []; for (final txHash in allTxHashes) { - final storedTx = - await mainDB.isar.transactionV2s - .where() - .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) - .findFirst(); + final storedTx = await mainDB.isar.transactionV2s + .where() + .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) + .findFirst(); if (storedTx == null || storedTx.height == null || @@ -360,7 +356,7 @@ class EcashWallet extends Bip39HDWallet @override int estimateTxFee({required int vSize, required BigInt feeRatePerKB}) { - return vSize * (feeRatePerKB.toInt() / 1000).ceil(); + return (feeRatePerKB * BigInt.from(vSize) ~/ BigInt.from(1000)).toInt(); } @override diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart index a734626f42..3746a4836e 100644 --- a/lib/wallets/wallet/impl/epiccash_wallet.dart +++ b/lib/wallets/wallet/impl/epiccash_wallet.dart @@ -8,14 +8,17 @@ import 'package:mutex/mutex.dart'; import 'package:stack_wallet_backup/generate_password.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +import '../../../exceptions/main_db/main_db_exception.dart'; import '../../../exceptions/wallet/node_tor_mismatch_config_exception.dart'; import '../../../models/balance.dart'; +import '../../../models/epic_slatepack_models.dart'; import '../../../models/epicbox_config_model.dart'; import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../models/isar/models/blockchain_data/transaction.dart'; import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import '../../../models/isar/models/transaction_note.dart'; import '../../../models/node_model.dart'; import '../../../models/paymint/fee_object_model.dart'; import '../../../pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart'; @@ -26,6 +29,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/default_epicboxes.dart'; +import '../../../utilities/dynamic_object.dart'; import '../../../utilities/flutter_secure_storage_interface.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/stack_file_system.dart'; @@ -47,6 +51,8 @@ class EpiccashWallet extends Bip39Wallet { NodeModel? _epicNode; Timer? timer; + DynamicObject? _wallet; + double highestPercent = 0; Future get getSyncPercent async { final int lastScannedBlock = info.epicData?.lastScannedBlock ?? 0; @@ -67,6 +73,46 @@ class EpiccashWallet extends Bip39Wallet { return restorePercent < 0 ? 0.0 : restorePercent; } + /// Opens and initializes the Epic wallet instance. + /// Should only be called once during wallet initialization. + // Future open() async { + // if (_wallet != null) { + // Logging.instance.d("Wallet already open, ensuring listener"); + // if (!await libEpic.isEpicboxListenerRunning(wallet: _wallet!)) { + // await _listenToEpicbox(); + // } + // return; + // } + // + // try { + // final config = await _getRealConfig(); + // final password = await secureStorageInterface.read( + // key: '${walletId}_password', + // ); + // if (password == null) { + // throw Exception('Wallet password not found'); + // } + // + // final epicboxConfig = await getEpicBoxConfig(); + // + // _wallet = await libEpic.openWallet( + // config: config, + // password: password, + // epicboxConfig: epicboxConfig.toString(), + // ); + // + // await _listenToEpicbox(); + // + // Logging.instance.d( + // "Epic wallet opened successfully with persistent isolate", + // ); + // } catch (e, s) { + // Logging.instance.e("Failed to open Epic wallet", error: e, stackTrace: s); + // _wallet = null; + // rethrow; + // } + // } + Future updateEpicboxConfig(String host, int port) async { final String stringConfig = jsonEncode({ "epicbox_domain": host, @@ -75,9 +121,12 @@ class EpiccashWallet extends Bip39Wallet { "epicbox_address_index": 0, }); await secureStorageInterface.write( - key: '${walletId}_epicboxConfig', + key: '${walletId}_epicboxConfigNewNewNew', value: stringConfig, ); + libEpic.updateEpicboxConfig(wallet: _wallet!, epicBoxConfig: stringConfig); + + await _generateAndStoreReceivingAddressForIndex(0); // TODO: refresh anything that needs to be refreshed/updated due to epicbox info changed } @@ -85,12 +134,12 @@ class EpiccashWallet extends Bip39Wallet { Future cancelPendingTransactionAndPost(String txSlateId) async { try { _hackedCheckTorNodePrefs(); - final String wallet = (await secureStorageInterface.read( - key: '${walletId}_wallet', - ))!; + if (_wallet == null) { + throw Exception('Wallet not initialized'); + } final result = await libEpic.cancelTransaction( - wallet: wallet, + wallet: _wallet!, transactionId: txSlateId, ); Logging.instance.d("cancel $txSlateId result: $result"); @@ -102,41 +151,297 @@ class EpiccashWallet extends Bip39Wallet { } Future getEpicBoxConfig() async { - final EpicBoxConfigModel _epicBoxConfig = EpicBoxConfigModel.fromServer( - DefaultEpicBoxes.defaultEpicBoxServer, + // check for user-configured epicbox first + final storedConfig = await secureStorageInterface.read( + key: '${walletId}_epicboxConfigNewNewNew', ); + if (storedConfig != null && storedConfig.isNotEmpty) { + try { + return EpicBoxConfigModel.fromString(storedConfig); + } catch (e, s) { + Logging.instance.e( + "Failed to parse stored epicbox config $storedConfig." + " Falling back to default.", + error: e, + stackTrace: s, + ); + } + } else { + Logging.instance.i("No stored epic box config. Falling back to default."); + } - //Get the default Epicbox server and check if it's conected - // bool isEpicboxConnected = await _testEpicboxServer( - // DefaultEpicBoxes.defaultEpicBoxServer.host, DefaultEpicBoxes.defaultEpicBoxServer.port ?? 443); - - // if (isEpicboxConnected) { - //Use default server for as Epicbox config - - // } - // else { - // //Use Europe config - // _epicBoxConfig = EpicBoxConfigModel.fromServer(DefaultEpicBoxes.europe); - // } - // // example of selecting another random server from the default list - // // alternative servers: copy list of all default EB servers but remove the default default - // // List alternativeServers = DefaultEpicBoxes.all; - // // alternativeServers.removeWhere((opt) => opt.name == DefaultEpicBoxes.defaultEpicBoxServer.name); - // // alternativeServers.shuffle(); // randomize which server is used - // // _epicBoxConfig = EpicBoxConfigModel.fromServer(alternativeServers.first); - // - // // TODO test this connection before returning it - // } - - return _epicBoxConfig; + // fall back to default + return EpicBoxConfigModel.fromServer(DefaultEpicBoxes.defaultEpicBoxServer); } - // ================= Private ================================================= + Future updateRestoreHeight(int height) async { + final epicData = info.epicData!.copyWith(restoreHeight: height); + + await info.updateExtraEpiccashWalletInfo( + epicData: epicData, + isar: mainDB.isar, + ); + } + + // ================= Slatepack Operations =================================== + + /// Create a slatepack for sending Epic Cash. + Future createSlatepack({ + required Amount amount, + String? recipientAddress, + String? message, + int? minimumConfirmations, + }) async { + try { + _hackedCheckTorNodePrefs(); + if (_wallet == null) { + throw Exception('Wallet not opened. Call open() first.'); + } + // Create transaction with returnSlate: true for slatepack mode. + final result = await libEpic.createTransaction( + wallet: _wallet!, + amount: amount.raw.toInt(), + address: 'slate', // Not used in slate mode. + secretKeyIndex: 0, + minimumConfirmations: + minimumConfirmations ?? cryptoCurrency.minConfirms, + note: message ?? '', + returnSlate: true, + ); + + return EpicSlatepackResult( + success: true, + slatepack: result.slateJson, + slateJson: result.slateJson, + wasEncrypted: false, + recipientAddress: recipientAddress, + ); + } catch (e, s) { + Logging.instance.e('Failed to create slatepack: $e\n$s'); + return EpicSlatepackResult(success: false, error: e.toString()); + } + } + + /// Decode a slatepack/slate JSON. + Future decodeSlatepack(String slateJson) async { + try { + // For Epic Cash, slates are already JSON, so we parse directly. + // Validate that the JSON is valid. + jsonDecode(slateJson); + + return EpicSlatepackDecodeResult( + success: true, + slateJson: slateJson, + wasEncrypted: false, + senderAddress: null, + recipientAddress: null, + ); + } catch (e, s) { + Logging.instance.e('Failed to decode slatepack: $e\n$s'); + return EpicSlatepackDecodeResult(success: false, error: e.toString()); + } + } + + /// Full decode of a slatepack including type analysis. + Future<({EpicSlatepackDecodeResult result, String type, String raw})?> + fullDecodeSlatepack(String slateJson) async { + // Add delay for showloading exception catching hack fix. + await Future.delayed(const Duration(seconds: 1)); + + if (slateJson.isEmpty) { + return null; + } + + // Attempt to decode. + final decoded = await decodeSlatepack(slateJson); + + if (decoded.success) { + final analysis = await analyzeSlatepack(slateJson); + + final String slatepackType = switch (analysis.status) { + 'S1' => "S1 (Initial Send)", + 'S2' => "S2 (Response)", + 'S3' => "S3 (Finalized)", + _ => "Unknown", + }; + + return (result: decoded, type: slatepackType, raw: slateJson); + } else { + throw Exception(decoded.error ?? "Failed to decode slatepack"); + } + } + + /// Receive a slatepack and return response slate JSON. + Future receiveSlatepack(String slateJson) async { + try { + _hackedCheckTorNodePrefs(); + if (_wallet == null) { + throw Exception('Wallet not opened. Call open() first.'); + } + + // Receive and get updated slate JSON. + final received = await libEpic.txReceive( + wallet: _wallet!, + slateJson: slateJson, + ); + + return EpicReceiveResult( + success: true, + slateId: received.slateId, + commitId: received.commitId, + responseSlatepack: received.slateJson, + wasEncrypted: false, + recipientAddress: null, + ); + } catch (e, s) { + Logging.instance.e('Failed to receive slatepack: $e\n$s'); + return EpicReceiveResult(success: false, error: e.toString()); + } + } + + /// Finalize a slatepack (sender step 3). + Future finalizeSlatepack(String slateJson) async { + try { + _hackedCheckTorNodePrefs(); + if (_wallet == null) { + throw Exception('Wallet not opened. Call open() first.'); + } + + // Finalize transaction. + final finalized = await libEpic.txFinalize( + wallet: _wallet!, + slateJson: slateJson, + ); + + return EpicFinalizeResult( + success: true, + slateId: finalized.slateId, + commitId: finalized.commitId, + ); + } catch (e, s) { + Logging.instance.e('Failed to finalize slatepack: $e\n$s'); + return EpicFinalizeResult(success: false, error: e.toString()); + } + } + + /// Analyze a slatepack and determine transaction type and metadata. + Future< + ({ + String type, + String status, + String? amount, + bool wasEncrypted, + String? senderAddress, + String? recipientAddress, + String slateId, + }) + > + analyzeSlatepack(String slateJson) async { + try { + // Parse the slate JSON to extract metadata. + final slateData = jsonDecode(slateJson); + final String slateId = "${slateData['id'] ?? ''}"; + final String? amountStr = slateData['amount']?.toString(); + + Logging.instance.d('Analyzed slatepack with ID: $slateId'); + + // Determine slate status from the slate structure. + String status = 'Unknown'; + String type = 'Unknown'; + + // Check participant data to determine slate status. + final List? participants = + slateData['participant_data'] as List?; + if (participants != null && participants.isNotEmpty) { + // Count how many participants have signatures. + int signedParticipants = 0; + for (final participant in participants) { + if (participant['part_sig'] != null) { + signedParticipants++; + } + } + + // Determine status based on signatures and participant count. + if (signedParticipants == 0) { + status = 'S1'; + type = 'Outgoing'; // Initial send slate - this is outgoing. + } else if (signedParticipants == 1) { + status = 'S2'; + type = 'Incoming'; // Response slate - this means we're receiving. + } else if (signedParticipants >= participants.length) { + status = 'S3'; + type = + 'Outgoing'; // Finalized slate - completed outgoing transaction. + } + } + + // Fallback: check for explicit 'sta' field (some slates may have this). + if (status == 'Unknown' && slateData['sta'] != null) { + status = "${slateData['sta']}"; + if (status == 'S1') { + type = 'Outgoing'; + } else if (status == 'S2') { + type = 'Incoming'; + } else if (status == 'S3') { + type = 'Outgoing'; + } + } - Future _getConfig() async { - if (_epicNode == null) { - await updateNode(); + return ( + type: type, + status: status, + amount: amountStr, + wasEncrypted: false, + senderAddress: null, + recipientAddress: null, + slateId: slateId, + ); + } catch (e) { + // If we can't decode it, return unknown. + return ( + type: 'Unknown', + status: 'Unknown', + amount: null, + wasEncrypted: false, + senderAddress: null, + recipientAddress: null, + slateId: '', + ); } + } + + /// Check if data is a slate JSON. + bool isSlateJson(String data) { + try { + final parsed = jsonDecode(data); + // Check for common slate fields. + return parsed is Map && + (parsed.containsKey('id') || parsed.containsKey('slate_id')) && + (parsed.containsKey('amount') || + parsed.containsKey('participant_data')); + } catch (e) { + return false; + } + } + + /// Check if address is Epicbox format. + bool isEpicboxAddress(String address) { + return address.contains('@'); + } + + /// Check if address is HTTP format. + bool isHttpAddress(String address) { + return address.startsWith('http://') || address.startsWith('https://'); + } + + // ================= Private ================================================= + + Future _hasConfig() async => + (await secureStorageInterface.read(key: '${walletId}_config')) != null; + + Future _buildConfig() async { + _epicNode ??= getCurrentNode(); + final NodeModel node = _epicNode!; final String nodeAddress = node.host; final int port = node.port; @@ -158,6 +463,7 @@ class EpiccashWallet extends Bip39Wallet { "", ); final String stringConfig = jsonEncode(config); + return stringConfig; } @@ -173,16 +479,16 @@ class EpiccashWallet extends Bip39Wallet { int satoshiAmount, { bool ifErrorEstimateFee = false, }) async { - final wallet = await secureStorageInterface.read(key: '${walletId}_wallet'); + if (_wallet == null) { + throw Exception('Wallet not opened. Call open() first.'); + } try { _hackedCheckTorNodePrefs(); - final available = info.cachedBalance.spendable.raw.toInt(); final transactionFees = await libEpic.getTransactionFees( - wallet: wallet!, + wallet: _wallet!, amount: satoshiAmount, minimumConfirmations: cryptoCurrency.minConfirms, - available: available, ); int realFee = 0; @@ -204,13 +510,15 @@ class EpiccashWallet extends Bip39Wallet { Future _startSync() async { _hackedCheckTorNodePrefs(); Logging.instance.d("request start sync"); - final wallet = await secureStorageInterface.read(key: '${walletId}_wallet'); + if (_wallet == null) { + throw Exception('Wallet not opened. Call open() first.'); + } const int refreshFromNode = 1; if (!syncMutex.isLocked) { await syncMutex.protect(() async { - // How does getWalletBalances start syncing???? + // How does getWalletBalances start syncing?????????!!!!! await libEpic.getWalletBalances( - wallet: wallet!, + wallet: _wallet!, refreshFromNode: refreshFromNode, minimumConfirmations: 10, ); @@ -230,13 +538,15 @@ class EpiccashWallet extends Bip39Wallet { > _allWalletBalances() async { _hackedCheckTorNodePrefs(); - final wallet = await secureStorageInterface.read(key: '${walletId}_wallet'); + if (_wallet == null) { + throw Exception('Wallet not opened. Call open() first.'); + } const refreshFromNode = 0; - return await libEpic.getWalletBalances( - wallet: wallet!, + return (await libEpic.getWalletBalances( + wallet: _wallet!, refreshFromNode: refreshFromNode, minimumConfirmations: cryptoCurrency.minConfirms, - ); + )); } Future _testEpicboxServer(EpicBoxConfigModel epicboxConfig) async { @@ -258,7 +568,7 @@ class EpiccashWallet extends Bip39Wallet { return response is String && response.contains("Challenge"); } catch (e, s) { Logging.instance.w( - "_testEpicBoxConnection failed on \"$host:$port\"", + "_testEpicboxServer failed on \"$host:$port\"", error: e, stackTrace: s, ); @@ -296,7 +606,8 @@ class EpiccashWallet extends Bip39Wallet { try { final int receivingIndex = info.epicData!.receivingIndex; // TODO: go through pendingarray and processed array and choose the index - // of the last one that has not been processed, or the index after the one most recently processed; + // of the last one that has not been processed, or the index after the + // one most recently processed; return receivingIndex; } catch (e, s) { Logging.instance.e("$e $s", error: e, stackTrace: s); @@ -304,8 +615,33 @@ class EpiccashWallet extends Bip39Wallet { } } + Future _updateAddressInDB(Address address) async { + try { + final storedAddress = await getCurrentReceivingAddress(); + await mainDB.isar.writeTxn(() async { + if (storedAddress == null) { + await mainDB.isar.addresses.put(address); + } else { + address.id = storedAddress.id; + await storedAddress.transactions.load(); + final txns = storedAddress.transactions.toList(); + await mainDB.isar.addresses.delete(storedAddress.id); + await mainDB.isar.addresses.put(address); + address.transactions.addAll(txns); + await address.transactions.save(); + } + }); + } catch (e) { + throw MainDBException("failed _updateAddressInDB: $address", e); + } + } + /// Only index 0 is currently used in stack wallet. Future
_generateAndStoreReceivingAddressForIndex(int index) async { + if (_wallet == null) { + throw Exception('Wallet not opened. Call open() first.'); + } + // Since only 0 is a valid index in stack wallet at this time, lets just // throw is not zero if (index != 0) { @@ -313,27 +649,10 @@ class EpiccashWallet extends Bip39Wallet { } final epicBoxConfig = await getEpicBoxConfig(); - final address = await thisWalletAddress(index, epicBoxConfig); - - if (info.cachedReceivingAddress != address.value) { - await info.updateReceivingAddress( - newAddress: address.value, - isar: mainDB.isar, - ); - } - return address; - } - - Future
thisWalletAddress( - int index, - EpicBoxConfigModel epicboxConfig, - ) async { - final wallet = await secureStorageInterface.read(key: '${walletId}_wallet'); - final walletAddress = await libEpic.getAddressInfo( - wallet: wallet!, + wallet: _wallet!, index: index, - epicboxConfig: epicboxConfig.toString(), + epicboxConfig: epicBoxConfig.toString(), ); Logging.instance.d("WALLET_ADDRESS_IS $walletAddress"); @@ -347,18 +666,19 @@ class EpiccashWallet extends Bip39Wallet { subType: AddressSubType.receiving, publicKey: [], // ?? ); - await mainDB.updateOrPutAddresses([address]); + await _updateAddressInDB(address); + if (info.cachedReceivingAddress != address.value) { + await info.updateReceivingAddress( + newAddress: address.value, + isar: mainDB.isar, + ); + } + return address; } Future _startScans() async { try { - //First stop the current listener - libEpic.stopEpicboxListener(); - final wallet = await secureStorageInterface.read( - key: '${walletId}_wallet', - ); - // max number of blocks to scan per loop iteration const scanChunkSize = 10000; @@ -370,6 +690,15 @@ class EpiccashWallet extends Bip39Wallet { int chainHeight = await this.chainHeight; int lastScannedBlock = info.epicData!.lastScannedBlock; + // Only stop the listener if we actually have blocks to scan. + // This avoids unnecessary reconnections during periodic refresh + // when the wallet is already synced to the tip. + final needsScanning = lastScannedBlock < chainHeight; + if (needsScanning) { + // Stop listener during active scanning to avoid potential conflicts + await libEpic.stopEpicboxListener(wallet: _wallet!); + } + // loop while scanning in chain in chunks (of blocks?) while (lastScannedBlock < chainHeight) { Logging.instance.d( @@ -377,7 +706,7 @@ class EpiccashWallet extends Bip39Wallet { ); final int nextScannedBlock = await libEpic.scanOutputs( - wallet: wallet!, + wallet: _wallet!, startHeight: lastScannedBlock, numberOfBlocks: scanChunkSize, ); @@ -397,8 +726,16 @@ class EpiccashWallet extends Bip39Wallet { } Logging.instance.d("_startScans successfully at the tip"); - //Once scanner completes restart listener - await _listenToEpicbox(); + + // Ensure listener is running after refresh. + // Use health check to verify the Rust listener task is actually alive, + // not just that we have a pointer (which could be stale). + if (!await libEpic.isEpicboxListenerRunning(wallet: _wallet!)) { + Logging.instance.d("Listener not running, starting it..."); + await _listenToEpicbox(); + } else { + Logging.instance.d("Listener already running, no restart needed"); + } } catch (e, s) { Logging.instance.e("_startScans failed: ", error: e, stackTrace: s); rethrow; @@ -407,27 +744,15 @@ class EpiccashWallet extends Bip39Wallet { Future _listenToEpicbox() async { Logging.instance.d("STARTING WALLET LISTENER ...."); - final wallet = await secureStorageInterface.read(key: '${walletId}_wallet'); final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig(); - libEpic.startEpicboxListener( - wallet: wallet!, - epicboxConfig: epicboxConfig.toString(), - ); - } - - // As opposed to fake config? - Future _getRealConfig() async { - String? config = await secureStorageInterface.read( - key: '${walletId}_config', - ); - if (Platform.isIOS) { - final walletDir = await _currentWalletDirPath(); - final editConfig = jsonDecode(config as String); - - editConfig["wallet_dir"] = walletDir; - config = jsonEncode(editConfig); + if (_wallet == null) { + throw Exception('Wallet not opened. Call open() first.'); } - return config!; + libEpic.updateEpicboxConfig( + wallet: _wallet!, + epicBoxConfig: epicboxConfig.toString(), + ); + await libEpic.startEpicboxListener(wallet: _wallet!); } // TODO: make more robust estimate of date maybe using https://explorer.epic.tech/api-index @@ -444,6 +769,48 @@ class EpiccashWallet extends Bip39Wallet { return height; } + static const _mid = "_:'", _end = "':"; + + /// eeehhhhhhhhhhhhhhh + bool _fuzzyEquals(TransactionV2 a, TransactionV2 b) { + final isAmountReceivedMatches = + a.getAmountReceivedInThisWallet( + fractionDigits: cryptoCurrency.fractionDigits, + ) == + b.getAmountReceivedInThisWallet( + fractionDigits: cryptoCurrency.fractionDigits, + ); + + final isFeeMatches = + a.getFee(fractionDigits: cryptoCurrency.fractionDigits) == + b.getFee(fractionDigits: cryptoCurrency.fractionDigits); + + final isAmountSentMatches = + a.getAmountSentFromThisWallet( + fractionDigits: cryptoCurrency.fractionDigits, + subtractFee: false, + ) == + b.getAmountSentFromThisWallet( + fractionDigits: cryptoCurrency.fractionDigits, + subtractFee: false, + ); + + final isHeightMatches = a.height == b.height; + final isTxTypeMatches = a.type == b.type && a.subType == b.subType; + final isSlateIdMatches = a.slateId == b.slateId; + + if (isHeightMatches && + isTxTypeMatches && + isFeeMatches && + isSlateIdMatches && + isAmountSentMatches && + isAmountReceivedMatches) { + return true; + } + + return false; + } + // ============== Overrides ================================================== @override @@ -465,50 +832,43 @@ class EpiccashWallet extends Bip39Wallet { @override Future init({bool? isRestore}) async { if (isRestore != true) { - String? encodedWallet = await secureStorageInterface.read( - key: "${walletId}_wallet", - ); + final existingWalletConfig = await _hasConfig(); // check if should create a new wallet - if (encodedWallet == null) { + if (!existingWalletConfig) { await updateNode(); final mnemonicString = await getMnemonic(); final String password = generatePassword(); - final String stringConfig = await _getConfig(); final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig(); + final String stringConfig = await _buildConfig(); + + // no need to save the config, just a string flag to know we have a + // wallet created await secureStorageInterface.write( key: '${walletId}_config', - value: stringConfig, + value: "true", ); + await secureStorageInterface.write( key: '${walletId}_password', value: password, ); await secureStorageInterface.write( - key: '${walletId}_epicboxConfig', + key: '${walletId}_epicboxConfigNewNewNew', value: epicboxConfig.toString(), ); final String name = walletId; - await libEpic.initializeNewWallet( + _wallet = await libEpic.initializeNewWallet( config: stringConfig, mnemonic: mnemonicString, password: password, name: name, - ); - - //Open wallet - encodedWallet = await libEpic.openWallet( - config: stringConfig, - password: password, - ); - await secureStorageInterface.write( - key: '${walletId}_wallet', - value: encodedWallet, - ); + epicBoxConfig: epicboxConfig.toString(), + ); // Spawns worker isolate //Store Epic box address info await _generateAndStoreReceivingAddressForIndex(0); @@ -532,27 +892,31 @@ class EpiccashWallet extends Bip39Wallet { epicData: epicData, isar: mainDB.isar, ); + + await _listenToEpicbox(); } else { try { Logging.instance.d( "initializeExisting() ${cryptoCurrency.prettyName} wallet", ); - final config = await _getRealConfig(); final password = await secureStorageInterface.read( key: '${walletId}_password', ); + final epicboxConfig = await getEpicBoxConfig(); - final walletOpen = await libEpic.openWallet( - config: config, + _wallet = await libEpic.openWallet( + config: await _buildConfig(), password: password!, - ); - await secureStorageInterface.write( - key: '${walletId}_wallet', - value: walletOpen, - ); + epicboxConfig: epicboxConfig.toString(), + ); // Spawns worker isolate await updateNode(); + + // ensure address is up to date with epic box uri + await _generateAndStoreReceivingAddressForIndex(0); + + await _listenToEpicbox(); } catch (e, s) { // do nothing, still allow user into wallet Logging.instance.w( @@ -571,9 +935,6 @@ class EpiccashWallet extends Bip39Wallet { Future confirmSend({required TxData txData}) async { try { _hackedCheckTorNodePrefs(); - final wallet = await secureStorageInterface.read( - key: '${walletId}_wallet', - ); final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig(); // TODO determine whether it is worth sending change to a change address. @@ -588,34 +949,41 @@ class EpiccashWallet extends Bip39Wallet { } } - ({String commitId, String slateId}) transaction; + ({String commitId, String slateId, String slateJson}) transaction; if (receiverAddress.startsWith("http://") || receiverAddress.startsWith("https://")) { - transaction = await libEpic.txHttpSend( - wallet: wallet!, + final httpResult = await libEpic.txHttpSend( + wallet: _wallet!, selectionStrategyIsAll: 0, minimumConfirmations: cryptoCurrency.minConfirms, message: txData.noteOnChain ?? "", amount: txData.recipients!.first.amount.raw.toInt(), address: txData.recipients!.first.address, ); + transaction = ( + commitId: httpResult.commitId, + slateId: httpResult.slateId, + slateJson: '', + ); } else { - transaction = await libEpic.createTransaction( - wallet: wallet!, + transaction = (await libEpic.createTransaction( + wallet: _wallet!, amount: txData.recipients!.first.amount.raw.toInt(), address: txData.recipients!.first.address, secretKeyIndex: 0, - epicboxConfig: epicboxConfig.toString(), minimumConfirmations: cryptoCurrency.minConfirms, note: txData.noteOnChain!, - ); + )); } final Map txAddressInfo = {}; txAddressInfo['from'] = (await getCurrentReceivingAddress())!.value; txAddressInfo['to'] = txData.recipients!.first.address; - await _putSendToAddresses(transaction, txAddressInfo); + await _putSendToAddresses(( + commitId: transaction.commitId, + slateId: transaction.slateId, + ), txAddressInfo); return txData.copyWith(txid: transaction.slateId); } catch (e, s) { @@ -663,8 +1031,67 @@ class EpiccashWallet extends Bip39Wallet { _hackedCheckTorNodePrefs(); await refreshMutex.protect(() async { if (isRescan) { - // clear blockchain info - await mainDB.deleteWalletBlockchainData(walletId); + // keep old transactions but id them somehow + // with the current db, there is no other way besides editing the + // unique key (txid+walletId). Since we cannot change the wallet id we + // must therefore hack some stupid stuff into the txid... + final currentTxns1 = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .findAll(); + + final List currentTxns = []; + + for (final current in currentTxns1) { + if (currentTxns.where((e) => _fuzzyEquals(e, current)).isNotEmpty) { + Logging.instance.f("DELETING: $current"); + await mainDB.isar.writeTxn(() async { + await mainDB.isar.transactionV2s.delete(current.id); + }); + } else { + currentTxns.add(current); + } + } + + for (final current in currentTxns) { + // check notes first + final note = await mainDB.isar.transactionNotes + .where() + .txidWalletIdEqualTo(current.slateId ?? current.txid, walletId) + .findFirst(); + + // now handle transaction + final firstTime = + !(current.txid.contains(_mid) && current.txid.endsWith(_end)); + + final String txid; + if (firstTime) { + txid = "${current.txid}${_mid}0$_end"; + } else { + // this should always be 2 parts if we've gotten this far + final parts = current.txid.split(_mid); + final rescanCount = + int.parse(parts.last.replaceFirst(_end, "")) + 1; + txid = "${parts.first}$_mid$rescanCount$_end"; + } + + // finally update in db + await mainDB.isar.writeTxn(() async { + final updated = current.copyWith(txid: txid); + if (note != null) { + final updatedNote = TransactionNote( + walletId: walletId, + txid: current.slateId ?? txid, + value: note.value, + ); + await mainDB.isar.transactionNotes.delete(note.id); + await mainDB.isar.transactionNotes.put(updatedNote); + } + + await mainDB.isar.transactionV2s.delete(current.id); + await mainDB.isar.transactionV2s.put(updated); + }); + } await info.updateExtraEpiccashWalletInfo( epicData: info.epicData!.copyWith( @@ -673,17 +1100,50 @@ class EpiccashWallet extends Bip39Wallet { isar: mainDB.isar, ); - unawaited(refresh(doScan: true)); + final password = await secureStorageInterface.read( + key: '${walletId}_password', + ); + final epicboxConfig = await getEpicBoxConfig(); + + // maybe there is some way to tel epic-wallet rust to fully rescan... + final result = await deleteEpicWallet( + wallet: this, + secureStore: secureStorageInterface, + ); + Logging.instance.w("Epic rescan temporary delete result: $result"); + + // Close old wallet before recovery + if (_wallet != null) { + await libEpic.close(wallet: _wallet!); + _wallet = null; + } + + _wallet = await libEpic.recoverWallet( + config: await _buildConfig(), + password: password!, + mnemonic: await getMnemonic(), + name: info.walletId, + epicBoxConfig: epicboxConfig.toString(), + ); + + await _generateAndStoreReceivingAddressForIndex( + info.epicData?.receivingIndex ?? 0, + ); + + await _listenToEpicbox(); + + highestPercent = 0; } else { await updateNode(); final String password = generatePassword(); - final String stringConfig = await _getConfig(); final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig(); + // no need to save the config, just a string flag to know we have a + // wallet created await secureStorageInterface.write( key: '${walletId}_config', - value: stringConfig, + value: "true", ); await secureStorageInterface.write( key: '${walletId}_password', @@ -691,17 +1151,26 @@ class EpiccashWallet extends Bip39Wallet { ); await secureStorageInterface.write( - key: '${walletId}_epicboxConfig', + key: '${walletId}_epicboxConfigNewNewNew', value: epicboxConfig.toString(), ); - await libEpic.recoverWallet( - config: stringConfig, + // Close old wallet before recovery + if (_wallet != null) { + await libEpic.close(wallet: _wallet!); + _wallet = null; + } + + _wallet = await libEpic.recoverWallet( + config: await _buildConfig(), password: password, mnemonic: await getMnemonic(), name: info.walletId, + epicBoxConfig: epicboxConfig.toString(), ); + await _listenToEpicbox(); + final epicData = ExtraEpiccashWalletInfo( receivingIndex: 0, changeIndex: 0, @@ -717,22 +1186,13 @@ class EpiccashWallet extends Bip39Wallet { isar: mainDB.isar, ); - //Open Wallet - final walletOpen = await libEpic.openWallet( - config: stringConfig, - password: password, - ); - await secureStorageInterface.write( - key: '${walletId}_wallet', - value: walletOpen, - ); - await _generateAndStoreReceivingAddressForIndex( epicData.receivingIndex, ); } - unawaited(refresh(doScan: false)); }); + + unawaited(refresh(doScan: isRescan)); } catch (e, s) { Logging.instance.e( "Exception rethrown from electrumx_mixin recover(): ", @@ -771,9 +1231,9 @@ class EpiccashWallet extends Bip39Wallet { // await epicUpdateCreationHeight(await chainHeight); // } - // this will always be zero???? - final int curAdd = await _getCurrentIndex(); - await _generateAndStoreReceivingAddressForIndex(curAdd); + if (_wallet == null) { + throw Exception('Wallet not opened. Call open() first.'); + } if (doScan) { await _startScans(); @@ -830,7 +1290,8 @@ class EpiccashWallet extends Bip39Wallet { // 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 + // TODO: [prio=med] some kind of quick check if wallet needs to + // refresh to replace the old refreshIfThereIsNewData call // if (await refreshIfThereIsNewData()) { unawaited(refresh()); @@ -901,9 +1362,6 @@ class EpiccashWallet extends Bip39Wallet { Future updateTransactions() async { try { _hackedCheckTorNodePrefs(); - final wallet = await secureStorageInterface.read( - key: '${walletId}_wallet', - ); const refreshFromNode = 1; final myAddresses = await mainDB @@ -919,7 +1377,7 @@ class EpiccashWallet extends Bip39Wallet { final myAddressesSet = myAddresses.toSet(); final transactions = await libEpic.getTransactions( - wallet: wallet!, + wallet: _wallet!, refreshFromNode: refreshFromNode, ); @@ -951,7 +1409,7 @@ class EpiccashWallet extends Bip39Wallet { OutputV2 output = OutputV2.isarCantDoRequiredInDefaultConstructor( scriptPubKeyHex: "00", valueStringSats: credit.toString(), - addresses: [if (addressFrom != null) addressFrom], + addresses: [if (addressTo != null) addressTo], walletOwns: true, ); final InputV2 input = InputV2.isarCantDoRequiredInDefaultConstructor( @@ -959,7 +1417,7 @@ class EpiccashWallet extends Bip39Wallet { scriptSigAsm: null, sequence: null, outpoint: null, - addresses: [if (addressTo != null) addressTo], + addresses: [if (addressFrom != null) addressFrom], valueStringSats: debit.toString(), witness: null, innerRedeemScriptAsm: null, @@ -977,7 +1435,8 @@ class EpiccashWallet extends Bip39Wallet { output = output.copyWith( addresses: [ myAddressesSet - .first, // Must be changed if we ever do more than a single wallet address!!! + .first, // Must be changed if we ever do more than a single + // wallet address!!! ], walletOwns: true, ); @@ -1018,15 +1477,70 @@ class EpiccashWallet extends Bip39Wallet { otherData: jsonEncode(otherData), ); - txns.add(txn); + if (txns.where((e) => _fuzzyEquals(e, txn)).isEmpty) { + txns.add(txn); + } } + final existingTxns = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .findAll(); + await mainDB.isar.writeTxn(() async { - await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .deleteAll(); - await mainDB.isar.transactionV2s.putAll(txns); + for (final tx in txns) { + final existingMatches = existingTxns.where( + (e) => _fuzzyEquals(e, tx), + ); + TransactionNote? note; + if (existingMatches.isNotEmpty) { + // there should only ever be one. If more then something is\ + // wrong somewhere, probably + if (existingMatches.length > 1) { + Logging.instance.w( + "existingMatches length: ${existingMatches.length}", + ); + } + for (final match in existingMatches) { + if (await mainDB.isar.transactionV2s + .where() + .txidWalletIdEqualTo(match.txid, walletId) + .idProperty() + .findFirst() != + null) { + note = await mainDB.isar.transactionNotes + .where() + .txidWalletIdEqualTo(match.slateId ?? match.txid, walletId) + .findFirst(); + + await mainDB.isar.transactionV2s.delete(match.id); + } + } + } + + final id = await mainDB.isar.transactionV2s + .where() + .txidWalletIdEqualTo(tx.txid, walletId) + .idProperty() + .findFirst(); + + if (id != null) { + await mainDB.isar.transactionV2s.delete(id); + } + + if (note != null) { + await mainDB.isar.transactionNotes.delete(note.id); + await mainDB.isar.transactionNotes.put( + TransactionNote( + walletId: walletId, + txid: tx.slateId ?? tx.txid, + value: note.value, + ), + ); + } + + await mainDB.isar.transactionV2s.put(tx); + } }); } catch (e, s) { Logging.instance.e( @@ -1048,12 +1562,9 @@ class EpiccashWallet extends Bip39Wallet { Future updateNode() async { _epicNode = getCurrentNode(); - // TODO: [prio=low] move this out of secure storage if secure storage not needed - final String stringConfig = await _getConfig(); - await secureStorageInterface.write( - key: '${walletId}_config', - value: stringConfig, - ); + if (_wallet != null) { + libEpic.updateConfig(wallet: _wallet!, config: await _buildConfig()); + } // unawaited(refresh()); } @@ -1085,7 +1596,7 @@ class EpiccashWallet extends Bip39Wallet { @override Future updateChainHeight() async { _hackedCheckTorNodePrefs(); - final config = await _getRealConfig(); + final config = await _buildConfig(); final latestHeight = await libEpic.getChainHeight(config: config); await info.updateCachedChainHeight( newHeight: latestHeight, @@ -1096,7 +1607,8 @@ class EpiccashWallet extends Bip39Wallet { @override Future estimateFeeFor(Amount amount, BigInt feeRate) async { _hackedCheckTorNodePrefs(); - // setting ifErrorEstimateFee doesn't do anything as its not used in the nativeFee function????? + // setting ifErrorEstimateFee doesn't do anything as its not used in the + // nativeFee function????? final int currentFee = await _nativeFee( amount.raw.toInt(), ifErrorEstimateFee: true, @@ -1129,9 +1641,10 @@ class EpiccashWallet extends Bip39Wallet { @override Future exit() async { - libEpic.stopEpicboxListener(); + if (_wallet != null) await libEpic.stopEpicboxListener(wallet: _wallet!); timer?.cancel(); timer = null; + await super.exit(); Logging.instance.d("EpicCash_wallet exit finished"); } @@ -1160,32 +1673,21 @@ class EpiccashWallet extends Bip39Wallet { } Future deleteEpicWallet({ - required String walletId, + required EpiccashWallet wallet, required SecureStorageInterface secureStore, }) async { - final wallet = await secureStore.read(key: '${walletId}_wallet'); - String? config = await secureStore.read(key: '${walletId}_config'); - if (Platform.isIOS) { - final Directory appDir = await StackFileSystem.applicationRootDirectory(); - - final path = "${appDir.path}/epiccash"; - final String name = walletId.trim(); - final walletDir = '$path/$name'; - - final editConfig = jsonDecode(config as String); - - editConfig["wallet_dir"] = walletDir; - config = jsonEncode(editConfig); - } + final config = await wallet._hasConfig() ? await wallet._buildConfig() : null; - if (wallet == null) { - return "Tried to delete non existent epic wallet file with walletId=$walletId"; + if (config == null) { + return "Tried to delete non existent epic wallet file with" + " walletId=${wallet.walletId}"; } else { try { - return libEpic.deleteWallet(wallet: wallet, config: config!); + if (wallet._wallet != null) await libEpic.close(wallet: wallet._wallet!); + return libEpic.deleteWallet(config: config); } catch (e, s) { Logging.instance.e("$e\n$s", error: e, stackTrace: s); - return "deleteEpicWallet($walletId) failed..."; + return "deleteEpicWallet(${wallet.walletId}) failed..."; } } } diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index 743d380e16..354d7fea55 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -5,6 +5,7 @@ import 'package:decimal/decimal.dart'; import 'package:ethereum_addresses/ethereum_addresses.dart'; import 'package:http/http.dart'; import 'package:isar_community/isar.dart'; +import 'package:wallet/wallet.dart' as eth_wallet; import 'package:web3dart/json_rpc.dart' show RPCError; import 'package:web3dart/web3dart.dart' as web3; @@ -20,7 +21,6 @@ import '../../../services/ethereum/ethereum_api.dart'; import '../../../services/event_bus/events/global/updated_in_background_event.dart'; import '../../../services/event_bus/global_event_bus.dart'; import '../../../utilities/amount/amount.dart'; -import '../../../utilities/enums/fee_rate_type_enum.dart'; import '../../../utilities/eth_commons.dart'; import '../../../utilities/logger.dart'; import '../../crypto_currency/crypto_currency.dart'; @@ -133,10 +133,9 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { inputs: List.unmodifiable(inputs), outputs: List.unmodifiable(outputs), version: -1, - type: - addressTo == myAddress - ? TransactionType.sentToSelf - : TransactionType.outgoing, + type: addressTo == myAddress + ? TransactionType.sentToSelf + : TransactionType.outgoing, subType: TransactionSubType.none, otherData: jsonEncode(otherData), ); @@ -175,7 +174,7 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { final address = Address( walletId: walletId, - value: _credentials!.address.hexEip55, + value: _credentials!.address.eip55With0x, publicKey: [], // maybe store address bytes here? seems a waste of space though derivationIndex: 0, @@ -217,8 +216,10 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { final client = getEthClient(); final addressHex = (await getCurrentReceivingAddress())!.value; - final address = web3.EthereumAddress.fromHex(addressHex); - final web3.EtherAmount ethBalance = await client.getBalance(address); + final address = eth_wallet.EthereumAddress.fromHex(addressHex); + final eth_wallet.EtherAmount ethBalance = await client.getBalance( + address, + ); final balance = Balance( total: Amount( rawValue: ethBalance.getInWei, @@ -429,9 +430,9 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { return false; } - Future getMyWeb3Address() async { + Future getMyWeb3Address() async { final myAddress = (await getCurrentReceivingAddress())!.value; - final myWeb3Address = web3.EthereumAddress.fromHex(myAddress); + final myWeb3Address = eth_wallet.EthereumAddress.fromHex(myAddress); return myWeb3Address; } @@ -446,11 +447,11 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { > internalSharedPrepareSend({ required TxData txData, - required web3.EthereumAddress myWeb3Address, + required eth_wallet.EthereumAddress myWeb3Address, }) async { - if (txData.feeRateType == null) throw Exception("Missing fee rate type."); - if (txData.feeRateType == FeeRateType.custom && - txData.ethEIP1559Fee == null) { + final feeRateType = txData.feeRateType; + if (feeRateType == null) throw Exception("Missing fee rate type."); + if (feeRateType == .custom && txData.ethEIP1559Fee == null) { throw Exception("Missing custom EIP-1559 values."); } @@ -466,37 +467,25 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { ); final feeObject = await fees; - final baseFee = feeObject.suggestBaseFee; - BigInt maxBaseFee = baseFee; - BigInt priorityFee; - - switch (txData.feeRateType!) { - case FeeRateType.fast: - priorityFee = feeObject.fast - baseFee; - if (priorityFee.isNegative) priorityFee = BigInt.zero; - break; - - case FeeRateType.average: - priorityFee = feeObject.medium - baseFee; - if (priorityFee.isNegative) priorityFee = BigInt.zero; - break; - - case FeeRateType.slow: - priorityFee = feeObject.slow - baseFee; - if (priorityFee.isNegative) priorityFee = BigInt.zero; - break; - - case FeeRateType.custom: - priorityFee = txData.ethEIP1559Fee!.priorityFeeWei; - maxBaseFee = txData.ethEIP1559Fee!.maxBaseFeeWei; - break; - } + 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, + }; + final BigInt priorityFee = rawPriority.isNegative + ? BigInt.zero + : rawPriority; if (baseFee > maxBaseFee) { - throw Exception("Base cannot be greater than max base fee"); - } - if (priorityFee > maxBaseFee) { - throw Exception("Priority fee cannot be greater than max base fee"); + throw Exception("Max base fee is below the current network base fee."); } return ( @@ -527,16 +516,16 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { } final tx = web3.Transaction( - to: web3.EthereumAddress.fromHex(address), + to: eth_wallet.EthereumAddress.fromHex(address), maxGas: txData.ethEIP1559Fee?.gasLimit ?? kEthereumMinGasLimit, - value: web3.EtherAmount.inWei(amount.raw), + value: eth_wallet.EtherAmount.inWei(amount.raw), nonce: prep.nonce, - maxFeePerGas: web3.EtherAmount.fromBigInt( - web3.EtherUnit.wei, + maxFeePerGas: eth_wallet.EtherAmount.fromBigInt( + eth_wallet.EtherUnit.wei, prep.maxBaseFee, ), - maxPriorityFeePerGas: web3.EtherAmount.fromBigInt( - web3.EtherUnit.wei, + maxPriorityFeePerGas: eth_wallet.EtherAmount.fromBigInt( + eth_wallet.EtherUnit.wei, prep.priorityFee, ), ); diff --git a/lib/wallets/wallet/impl/fact0rn_wallet.dart b/lib/wallets/wallet/impl/fact0rn_wallet.dart index 0f6a93d0d9..3ddd053db2 100644 --- a/lib/wallets/wallet/impl/fact0rn_wallet.dart +++ b/lib/wallets/wallet/impl/fact0rn_wallet.dart @@ -35,18 +35,17 @@ class Fact0rnWallet @override Future> fetchAddressesForElectrumXScan() async { - final allAddresses = - await mainDB - .getAddresses(walletId) - .filter() - .not() - .group( - (q) => q - .typeEqualTo(AddressType.nonWallet) - .or() - .subTypeEqualTo(AddressSubType.nonWallet), - ) - .findAll(); + final allAddresses = await mainDB + .getAddresses(walletId) + .filter() + .not() + .group( + (q) => q + .typeEqualTo(AddressType.nonWallet) + .or() + .subTypeEqualTo(AddressSubType.nonWallet), + ) + .findAll(); return allAddresses; } @@ -59,16 +58,14 @@ class Fact0rnWallet await fetchAddressesForElectrumXScan(); // Separate receiving and change addresses. - final Set receivingAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.receiving) - .map((e) => e.value) - .toSet(); - final Set changeAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.change) - .map((e) => e.value) - .toSet(); + final Set receivingAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.receiving) + .map((e) => e.value) + .toSet(); + final Set changeAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.change) + .map((e) => e.value) + .toSet(); // Remove duplicates. final allAddressesSet = {...receivingAddresses, ...changeAddresses}; @@ -82,11 +79,10 @@ class Fact0rnWallet final List> allTransactions = []; for (final txHash in allTxHashes) { // Check for duplicates by searching for tx by tx_hash in db. - final storedTx = - await mainDB.isar.transactionV2s - .where() - .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) - .findFirst(); + final storedTx = await mainDB.isar.transactionV2s + .where() + .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) + .findFirst(); if (storedTx == null || storedTx.height == null || @@ -321,6 +317,6 @@ class Fact0rnWallet @override int estimateTxFee({required int vSize, required BigInt feeRatePerKB}) { - return vSize * (feeRatePerKB.toInt() / 1000).ceil(); + return (feeRatePerKB * BigInt.from(vSize) ~/ BigInt.from(1000)).toInt(); } } diff --git a/lib/wallets/wallet/impl/firo_transaction_type.dart b/lib/wallets/wallet/impl/firo_transaction_type.dart new file mode 100644 index 0000000000..501ae00260 --- /dev/null +++ b/lib/wallets/wallet/impl/firo_transaction_type.dart @@ -0,0 +1,4 @@ +bool isSparkSpendTransaction(Map transaction) { + final type = transaction['type']; + return transaction['version'] == 3 && (type == 9 || type == 11); +} diff --git a/lib/wallets/wallet/impl/firo_wallet.dart b/lib/wallets/wallet/impl/firo_wallet.dart index e55052e903..ea27514573 100644 --- a/lib/wallets/wallet/impl/firo_wallet.dart +++ b/lib/wallets/wallet/impl/firo_wallet.dart @@ -1,17 +1,23 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:math'; +import 'dart:typed_data'; +import 'package:coinlib_flutter/coinlib_flutter.dart' + show MessageSignature, base58Decode, P2PKH; +import 'package:crypto/crypto.dart' as crypto; import 'package:decimal/decimal.dart'; import 'package:isar_community/isar.dart'; import '../../../db/sqlite/firo_cache.dart'; +import '../../../models/input.dart'; import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../models/isar/models/isar_models.dart'; +import '../../../models/keys/view_only_wallet_data.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/extensions/extensions.dart'; +import '../../../utilities/firo_pro_reg_signed_message_prefix.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/util.dart'; import '../../crypto_currency/crypto_currency.dart'; @@ -24,8 +30,74 @@ import '../wallet_mixin_interfaces/coin_control_interface.dart'; import '../wallet_mixin_interfaces/electrumx_interface.dart'; import '../wallet_mixin_interfaces/extended_keys_interface.dart'; import '../wallet_mixin_interfaces/spark_interface.dart'; +import 'firo_transaction_type.dart'; + +class MasternodeInfo { + final String proTxHash; + final String collateralHash; + final int collateralIndex; + final String collateralAddress; + final double operatorReward; + final String serviceAddr; + final int servicePort; + final int registeredHeight; + final int lastPaidHeight; + final int posePenalty; + final int poseRevivedHeight; + final int poseBanHeight; + final int revocationReason; + final String ownerAddress; + final String votingAddress; + final String payoutAddress; + final String pubKeyOperator; + + MasternodeInfo({ + required this.proTxHash, + required this.collateralHash, + required this.collateralIndex, + required this.collateralAddress, + required this.operatorReward, + required this.serviceAddr, + required this.servicePort, + required this.registeredHeight, + required this.lastPaidHeight, + required this.posePenalty, + required this.poseRevivedHeight, + required this.poseBanHeight, + required this.revocationReason, + required this.ownerAddress, + required this.votingAddress, + required this.payoutAddress, + required this.pubKeyOperator, + }); + + Map pretty() { + return { + "ProTx Hash": proTxHash, + "IP:Port": "$serviceAddr:$servicePort", + "Status": revocationReason == 0 ? "Active" : "Revoked", + "Registered Height": registeredHeight.toString(), + "Last Paid Height": lastPaidHeight.toString(), + "Payout Address": payoutAddress, + "Owner Address": ownerAddress, + "Voting Address": votingAddress, + "Operator Public Key": pubKeyOperator, + "Operator Reward": "$operatorReward %", + "Collateral Hash": collateralHash, + "Collateral Index": collateralIndex.toString(), + "Collateral Address": collateralAddress, + "Pose Penalty": posePenalty.toString(), + "Pose Revived Height": poseRevivedHeight.toString(), + "Pose Ban Height": poseBanHeight.toString(), + "Revocation Reason": revocationReason.toString(), + }; + } +} + +final kMasterNodeValue = Decimal.fromInt(1000); // full value (not sats) -const sparkStartBlock = 819300; // (approx 18 Jan 2024) +const _zeroTxid = + "0000000000000000000000000000000000000000000000000000000000000000"; class FiroWallet extends Bip39HDWallet with @@ -81,121 +153,170 @@ class FiroWallet extends Bip39HDWallet final List
allAddressesOld = await fetchAddressesForElectrumXScan(); - final Set receivingAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.receiving) - .map((e) => convertAddressString(e.value)) - .toSet(); + final Set receivingAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.receiving) + .map((e) => convertAddressString(e.value)) + .toSet(); - final Set changeAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.change) - .map((e) => convertAddressString(e.value)) - .toSet(); + final Set changeAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.change) + .map((e) => convertAddressString(e.value)) + .toSet(); final allAddressesSet = {...receivingAddresses, ...changeAddresses}; - final List> allTxHashes = await fetchHistory( + Logging.instance.d( + "firo_wallet.dart updateTransactions() allAddressesSet.length: " + "${allAddressesSet.length}", + ); + + final List> allTxHashes1 = await fetchHistory( allAddressesSet, ); - final sparkCoins = - await mainDB.isar.sparkCoins - .where() - .walletIdEqualToAnyLTagHash(walletId) - .findAll(); + Logging.instance.d( + "firo_wallet.dart updateTransactions() allTxHashes.length: " + "${allTxHashes1.length}", + ); + + final Map> allHistory = {}; + + for (final item in allTxHashes1) { + final txid = item["tx_hash"] as String; + allHistory[txid] ??= {}; + allHistory[txid]!["height"] ??= item["height"] as int?; + } + + final sparkCoins = await mainDB.isar.sparkCoins + .where() + .walletIdEqualToAnyLTagHash(walletId) + .findAll(); final List> allTransactions = []; // some lelantus transactions aren't fetched via wallet addresses so they // will never show as confirmed in the gui. - final unconfirmedTransactions = - await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .filter() - .heightIsNull() - .findAll(); - for (final tx in unconfirmedTransactions) { - final txn = await electrumXCachedClient.getTransaction( - txHash: tx.txid, - verbose: true, - cryptoCurrency: info.coin, - ); - final height = txn["height"] as int?; - - if (height != null) { - // tx was mined - // add to allTxHashes - final info = {"tx_hash": tx.txid, "height": height}; - allTxHashes.add(info); + final unconfirmedTransactions = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .heightIsNull() + .txidProperty() + .findAll(); + for (final txid in unconfirmedTransactions) { + if (allHistory[txid] == null) { + allHistory[txid] = {}; } } final Set sparkTxids = {}; for (final coin in sparkCoins) { sparkTxids.add(coin.txHash); - // check for duplicates before adding to list - if (allTxHashes.indexWhere((e) => e["tx_hash"] == coin.txHash) == -1) { - final info = {"tx_hash": coin.txHash, "height": coin.height}; - allTxHashes.add(info); + if (allHistory[coin.txHash] == null) { + allHistory[coin.txHash] = {"height": coin.height}; } } final missing = await getSparkSpendTransactionIds(); for (final txid in missing.map((e) => e.txid).toSet()) { - // check for duplicates before adding to list - if (allTxHashes.indexWhere((e) => e["tx_hash"] == txid) == -1) { - final info = {"tx_hash": txid}; - allTxHashes.add(info); + if (allHistory[txid] == null) { + allHistory[txid] = {}; } } - final currentHeight = await chainHeight; - - for (final txHash in allTxHashes) { - final storedTx = - await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .filter() - .txidEqualTo(txHash["tx_hash"] as String) - .findFirst(); - - if (storedTx?.isConfirmed( - currentHeight, - cryptoCurrency.minConfirms, - cryptoCurrency.minCoinbaseConfirms, - ) == - true) { - // tx already confirmed, no need to process it again - continue; - } + final confirmedTxidsInIsar = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .heightIsNotNull() + .and() + .heightGreaterThan(1) + .txidProperty() + .findAll(); + + Logging.instance.d( + "firo_wallet.dart updateTransactions() confirmedTxidsInIsar.length: " + "${confirmedTxidsInIsar.length}", + ); - // firod/electrumx seem to take forever to process spark txns so we'll - // just ignore null errors and check again on next refresh. - // This could also be a bug in the custom electrumx rpc code - final Map tx; - try { - tx = await electrumXCachedClient.getTransaction( - txHash: txHash["tx_hash"] as String, - verbose: true, - cryptoCurrency: info.coin, - ); - } catch (_) { - continue; - } + // assume every tx that has a height is confirmed and remove them from the + // list of transactions to fetch and check. This should be fine in firo. + confirmedTxidsInIsar.forEach(allHistory.remove); - // check for duplicates before adding to list - if (allTransactions.indexWhere( - (e) => e["txid"] == tx["txid"] as String, - ) == - -1) { - tx["height"] ??= txHash["height"]; + final allTxids = allHistory.keys.toList(growable: false); + + const batchSize = 100; + final remainder = allTxids.length % batchSize; + final batchCount = allTxids.length ~/ batchSize; + + for (int i = 0; i < batchCount; i++) { + final start = i * batchSize; + final end = start + batchSize; + Logging.instance.i("[allTxids]: Fetching batch #$i"); + final txns = await electrumXCachedClient.getBatchTransactions( + txHashes: allTxids.sublist(start, end), + cryptoCurrency: cryptoCurrency, + ); + for (final tx in txns) { + tx["height"] ??= allHistory[tx["txid"]]!["height"]; + allTransactions.add(tx); + } + } + // handle remainder + if (remainder > 0) { + final txns = await electrumXCachedClient.getBatchTransactions( + txHashes: allTxids.sublist(allTxids.length - remainder), + cryptoCurrency: cryptoCurrency, + ); + for (final tx in txns) { + tx["height"] ??= allHistory[tx["txid"]]!["height"]; allTransactions.add(tx); } } + final Set txInputTxidsSet = {}; + for (final txData in allTransactions) { + for (final jsonInput in txData["vin"] as List) { + final map = Map.from(jsonInput as Map); + final coinbase = map["coinbase"] as String?; + + final txid = map["txid"] as String?; + final vout = map["vout"] as int?; + if (coinbase == null && + txid != null && + vout != null && + txid != _zeroTxid) { + txInputTxidsSet.add(txid); + } + } + } + final txInputTxids = txInputTxidsSet.toList(growable: false); + + final Map> someInputTxns = {}; + final remainder2 = txInputTxids.length % batchSize; + for (int i = 0; i < txInputTxids.length ~/ batchSize; i++) { + final start = i * batchSize; + final end = start + batchSize; + Logging.instance.i("[txInputTxids]: Fetching batch #$i"); + final txns = await electrumXCachedClient.getBatchTransactions( + txHashes: txInputTxids.sublist(start, end), + cryptoCurrency: cryptoCurrency, + ); + for (final tx in txns) { + someInputTxns[tx["txid"] as String] = tx; + } + } + // handle remainder + if (remainder2 > 0) { + final txns = await electrumXCachedClient.getBatchTransactions( + txHashes: txInputTxids.sublist(txInputTxids.length - remainder2), + cryptoCurrency: cryptoCurrency, + ); + for (final tx in txns) { + someInputTxns[tx["txid"] as String] = tx; + } + } + final List txns = []; for (final txData in allTransactions) { @@ -212,10 +333,11 @@ class FiroWallet extends Bip39HDWallet bool isMint = false; bool isJMint = false; bool isSparkMint = false; - final bool isSparkSpend = txData["type"] == 9 && txData["version"] == 3; + final bool isSparkSpend = isSparkSpendTransaction(txData); final bool isMySpark = sparkTxids.contains(txData["txid"] as String); - final bool isMySpentSpark = - missing.where((e) => e.txid == txData["txid"]).isNotEmpty; + final bool isMySpentSpark = missing + .where((e) => e.txid == txData["txid"]) + .isNotEmpty; final sparkCoinsInvolvedReceived = sparkCoins.where( (e) => @@ -229,14 +351,16 @@ class FiroWallet extends Bip39HDWallet if (isMySpark && sparkCoinsInvolvedReceived.isEmpty && !isMySpentSpark) { Logging.instance.e( - "sparkCoinsInvolvedReceived is empty and should not be! (ignoring tx parsing)", + "sparkCoinsInvolvedReceived is empty and should not be!" + " (ignoring tx parsing)", ); continue; } if (isMySpentSpark && sparkCoinsInvolvedSpent.isEmpty && !isMySpark) { Logging.instance.e( - "sparkCoinsInvolvedSpent is empty and should not be! (ignoring tx parsing)", + "sparkCoinsInvolvedSpent is empty and should not be!" + " (ignoring tx parsing)", ); continue; } @@ -254,7 +378,8 @@ class FiroWallet extends Bip39HDWallet isMint = true; } else { Logging.instance.d( - "Unknown mint op code found for lelantusmint tx: ${txData["txid"]}", + "Unknown mint op code found for lelantusmint tx: " + "${txData["txid"]}", ); } } else { @@ -272,7 +397,8 @@ class FiroWallet extends Bip39HDWallet isSparkMint = true; } else { Logging.instance.d( - "Unknown mint op code found for sparkmint tx: ${txData["txid"]}", + "Unknown mint op code found for sparkmint tx: " + "${txData["txid"]}", ); } } else { @@ -298,19 +424,17 @@ class FiroWallet extends Bip39HDWallet if (output.addresses.isEmpty && output.scriptPubKeyHex.length >= 488) { // likely spark related - final opByte = - output.scriptPubKeyHex - .substring(0, 2) - .toUint8ListFromHex - .first; + final opByte = output.scriptPubKeyHex + .substring(0, 2) + .toUint8ListFromHex + .first; if (opByte == OP_SPARKMINT || opByte == OP_SPARKSMINT) { final serCoin = base64Encode( output.scriptPubKeyHex.substring(2, 488).toUint8ListFromHex, ); - final coin = - sparkCoinsInvolvedReceived - .where((e) => e.serializedCoinB64!.startsWith(serCoin)) - .firstOrNull; + final coin = sparkCoinsInvolvedReceived + .where((e) => e.serializedCoinB64!.startsWith(serCoin)) + .firstOrNull; if (coin == null) { // not ours @@ -403,10 +527,9 @@ class FiroWallet extends Bip39HDWallet txid: txData["txid"] as String, network: cryptoCurrency.network, ); - spentSparkCoins = - sparkCoinsInvolvedSpent - .where((e) => tags.contains(e.lTagHash)) - .toList(); + spentSparkCoins = sparkCoinsInvolvedSpent + .where((e) => tags.contains(e.lTagHash)) + .toList(); } else if (isSparkSpend) { parseAnonFees(); } else if (isSparkMint) { @@ -438,10 +561,8 @@ class FiroWallet extends Bip39HDWallet anonFees = anonFees! + fees; } } else if (coinbase == null && txid != null && vout != null) { - final inputTx = await electrumXCachedClient.getTransaction( - txHash: txid, - cryptoCurrency: cryptoCurrency, - ); + // fetched earlier so ! unwrap should be ok + final inputTx = someInputTxns[txid]!; final prevOutJson = Map.from( (inputTx["vout"] as List).firstWhere((e) => e["n"] == vout) as Map, @@ -490,11 +611,10 @@ class FiroWallet extends Bip39HDWallet if (usedCoins.isNotEmpty) { input = input.copyWith( addresses: usedCoins.map((e) => e.address).toList(), - valueStringSats: - usedCoins - .map((e) => e.value) - .reduce((value, element) => value += element) - .toString(), + valueStringSats: usedCoins + .map((e) => e.value) + .reduce((value, element) => value += element) + .toString(), walletOwns: true, ); wasSentFromThisWallet = true; @@ -505,11 +625,10 @@ class FiroWallet extends Bip39HDWallet spentSparkCoins.isNotEmpty) { input = input.copyWith( addresses: spentSparkCoins.map((e) => e.address).toList(), - valueStringSats: - spentSparkCoins - .map((e) => e.value) - .fold(BigInt.zero, (p, e) => p + e) - .toString(), + valueStringSats: spentSparkCoins + .map((e) => e.value) + .fold(BigInt.zero, (p, e) => p + e) + .toString(), walletOwns: true, ); wasSentFromThisWallet = true; @@ -632,13 +751,11 @@ class FiroWallet extends Bip39HDWallet String? label; if (jsonUTXO["value"] is int) { - // TODO: [prio=high] use special electrumx call to verify the 1000 Firo output is masternode - // electrumx call should exist now. Unsure if it works though + // verify the 1000 Firo output is masternode + // Fall back to locked in case network call fails blocked = Amount.fromDecimal( - Decimal.fromInt( - 1000, // 1000 firo output is a possible master node - ), + kMasterNodeValue, fractionDigits: cryptoCurrency.fractionDigits, ).raw == BigInt.from(jsonUTXO["value"] as int); @@ -649,6 +766,13 @@ class FiroWallet extends Bip39HDWallet txid: jsonTX!["txid"] as String, index: jsonUTXO["tx_pos"] as int, ); + + if (blocked) { + blockedReason = + "Masternode collateral. " + "Unlocking and spending will invalidate this masternode!"; + label = "Masternode collateral"; + } } catch (_) { // call failed, lock utxo just in case // it should logically already be blocked @@ -658,19 +782,36 @@ class FiroWallet extends Bip39HDWallet } if (blocked) { - blockedReason = + blockedReason ??= "Possible masternode collateral. " "Unlock and spend at your own risk."; - label = "Possible masternode collateral"; + label ??= "Possible masternode collateral"; } } return (blockedReason: blockedReason, blocked: blocked, utxoLabel: label); } + @override + Future> fetchAddressesForElectrumXScan() async { + return await mainDB + .getAddresses(walletId) + .filter() + .not() + .group( + (q) => q + .typeEqualTo(AddressType.spark) + .or() + .typeEqualTo(AddressType.nonWallet) + .or() + .subTypeEqualTo(AddressSubType.nonWallet), + ) + .findAll(); + } + @override Future recover({required bool isRescan}) async { - if (isViewOnly) { + if (isViewOnly && viewOnlyType != ViewOnlyWalletType.spark) { await recoverViewOnly(isRescan: isRescan); return; } @@ -684,7 +825,6 @@ class FiroWallet extends Bip39HDWallet ); final start = DateTime.now(); - final root = await getRootHDNode(); final List addresses})>> receiveFutures = []; @@ -731,22 +871,26 @@ class FiroWallet extends Bip39HDWallet final canBatch = await serverCanBatch; - for (final type in cryptoCurrency.supportedDerivationPathTypes) { - receiveFutures.add( - canBatch - ? checkGapsBatched(txCountBatchSize, root, type, receiveChain) - : checkGapsLinearly(root, type, receiveChain), - ); - } + if (!isViewOnly || viewOnlyType != ViewOnlyWalletType.spark) { + final root = await getRootHDNode(); - // change addresses - Logging.instance.d("checking change addresses..."); - for (final type in cryptoCurrency.supportedDerivationPathTypes) { - changeFutures.add( - canBatch - ? checkGapsBatched(txCountBatchSize, root, type, changeChain) - : checkGapsLinearly(root, type, changeChain), - ); + for (final type in cryptoCurrency.supportedDerivationPathTypes) { + receiveFutures.add( + canBatch + ? checkGapsBatched(txCountBatchSize, root, type, receiveChain) + : checkGapsLinearly(root, type, receiveChain), + ); + } + + // change addresses + Logging.instance.d("checking change addresses..."); + for (final type in cryptoCurrency.supportedDerivationPathTypes) { + changeFutures.add( + canBatch + ? checkGapsBatched(txCountBatchSize, root, type, changeChain) + : checkGapsLinearly(root, type, changeChain), + ); + } } // io limitations may require running these linearly instead @@ -755,53 +899,10 @@ class FiroWallet extends Bip39HDWallet Future.wait(changeFutures), ]); - final receiveResults = futuresResult[0]; - final changeResults = futuresResult[1]; - - final List
addressesToStore = []; - - int highestReceivingIndexWithHistory = 0; - - for (final tuple in receiveResults) { - if (tuple.addresses.isEmpty) { - if (info.otherData[WalletInfoKeys.reuseAddress] != true) { - await checkReceivingAddressForTransactions(); - } - } else { - highestReceivingIndexWithHistory = max( - tuple.index, - highestReceivingIndexWithHistory, - ); - addressesToStore.addAll(tuple.addresses); - } - } - - 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. - for (final tuple in changeResults) { - if (tuple.addresses.isEmpty) { - await checkChangeAddressForTransactions(); - } else { - highestChangeIndexWithHistory = max( - tuple.index, - highestChangeIndexWithHistory, - ); - addressesToStore.addAll(tuple.addresses); - } - } - - // remove extra addresses to help minimize risk of creating a large gap - addressesToStore.removeWhere( - (e) => - e.subType == AddressSubType.change && - e.derivationIndex > highestChangeIndexWithHistory, - ); - addressesToStore.removeWhere( - (e) => - e.subType == AddressSubType.receiving && - e.derivationIndex > highestReceivingIndexWithHistory, - ); + final List
addressesToStore = processGapCheckResults([ + ...futuresResult[0], + ...futuresResult[1], + ]); await mainDB.updateOrPutAddresses(addressesToStore); @@ -845,6 +946,449 @@ class FiroWallet extends Bip39HDWallet @override int estimateTxFee({required int vSize, required BigInt feeRatePerKB}) { - return vSize * (feeRatePerKB.toInt() / 1000).ceil(); + return (feeRatePerKB * BigInt.from(vSize) ~/ BigInt.from(1000)).toInt(); + } + + Future registerMasternode( + String ip, + int port, + String operatorPubKey, + String votingAddress, + int operatorReward, + String payoutAddress, { + required String collateralTxid, + required int collateralVout, + required String collateralAddress, + }) async { + final collateralAddr = await mainDB + .getAddresses(walletId) + .filter() + .valueEqualTo(collateralAddress) + .findFirst(); + if (collateralAddr == null || collateralAddr.derivationPath == null) { + throw Exception( + 'Collateral address $collateralAddress not found in wallet ' + 'or has no derivation path.', + ); + } + final collateralUtxo = await mainDB + .getUTXOs(walletId) + .filter() + .txidEqualTo(collateralTxid) + .and() + .voutEqualTo(collateralVout) + .findFirst(); + final currentChainHeight = await chainHeight; + if (collateralUtxo == null || + collateralUtxo.address != collateralAddress || + collateralUtxo.isBlocked || + collateralUtxo.used == true || + !collateralUtxo.isConfirmed( + currentChainHeight, + cryptoCurrency.minConfirms, + cryptoCurrency.minCoinbaseConfirms, + )) { + throw Exception( + "Collateral outpoint is not yet confirmed/spendable. " + "Wait for confirmations and try again.", + ); + } + final expectedCollateralRaw = Amount.fromDecimal( + kMasterNodeValue, + fractionDigits: cryptoCurrency.fractionDigits, + ).raw.toInt(); + if (collateralUtxo.value != expectedCollateralRaw) { + throw Exception( + "Collateral outpoint must be exactly " + "${kMasterNodeValue.toString()} FIRO.", + ); + } + + Address? ownerAddress = await getCurrentReceivingAddress(); + const maxOwnerAttempts = 32; + for ( + var i = 0; + i < maxOwnerAttempts && + (ownerAddress == null || ownerAddress.value == collateralAddress); + i++ + ) { + await generateNewReceivingAddress(); + ownerAddress = await getCurrentReceivingAddress(); + } + if (ownerAddress == null || ownerAddress.value == collateralAddress) { + throw Exception( + "Could not derive owner address distinct from collateral address.", + ); + } + + final registrationTx = BytesBuilder(); + + // nVersion (16 bit) + registrationTx.add( + (ByteData(2)..setInt16(0, 1, Endian.little)).buffer.asUint8List(), + ); + + // nType (16 bit) + registrationTx.add( + (ByteData(2)..setInt16(0, 0, Endian.little)).buffer.asUint8List(), + ); + + // nMode (16 bit) + registrationTx.add( + (ByteData(2)..setInt16(0, 0, Endian.little)).buffer.asUint8List(), + ); + + // collateralOutpoint.hash (256 bit) — real txid, byte-reversed + final collateralTxidBytes = collateralTxid.toUint8ListFromHex.reversed + .toList(); + if (collateralTxidBytes.length != 32) { + throw Exception("Invalid collateral txid: $collateralTxid"); + } + registrationTx.add(collateralTxidBytes); + + // collateralOutpoint.index (uint32) + registrationTx.add( + (ByteData( + 4, + )..setUint32(0, collateralVout, Endian.little)).buffer.asUint8List(), + ); + + // addr — IPv4-mapped IPv6 (16 bytes) + port (2 bytes big-endian) + final ipParts = ip.split('.').map((e) => int.parse(e)).toList(); + if (ipParts.length != 4) { + throw Exception("Invalid IP address: $ip"); + } + for (final part in ipParts) { + if (part < 0 || part > 255) { + throw Exception("Invalid IP part: $part"); + } + } + registrationTx.add(ByteData(10).buffer.asUint8List()); + registrationTx.add([0xff, 0xff]); + registrationTx.add(ipParts); + if (port < 1 || port > 65535) { + throw Exception("Invalid port: $port"); + } + registrationTx.add( + (ByteData(2)..setUint16(0, port, Endian.big)).buffer.asUint8List(), + ); + + // keyIDOwner (20 bytes) + if (!cryptoCurrency.validateAddress(ownerAddress.value)) { + throw Exception("Invalid owner address: ${ownerAddress.value}"); + } + final ownerAddressBytes = base58Decode(ownerAddress.value); + assert(ownerAddressBytes.length == 21); + registrationTx.add(ownerAddressBytes.sublist(1)); + + // pubKeyOperator (48 bytes) + final operatorPubKeyBytes = operatorPubKey.toUint8ListFromHex; + if (operatorPubKeyBytes.length != 48) { + throw Exception("Invalid operator public key: $operatorPubKey"); + } + registrationTx.add(operatorPubKeyBytes); + + // keyIDVoting (20 bytes) + final String effectiveVotingAddress; + if (votingAddress == payoutAddress) { + throw Exception("Voting address and payout address cannot be the same."); + } else if (votingAddress == collateralAddress) { + throw Exception( + "Voting address cannot be the same as the collateral address.", + ); + } else if (votingAddress.isNotEmpty) { + final votingType = cryptoCurrency.getAddressType(votingAddress); + if (votingType != AddressType.p2pkh) { + throw Exception( + "Voting address must be a transparent P2PKH address, " + "not a Spark or other address type.", + ); + } + final votingAddressBytes = base58Decode(votingAddress); + assert(votingAddressBytes.length == 21); + registrationTx.add(votingAddressBytes.sublist(1)); + effectiveVotingAddress = votingAddress; + } else { + registrationTx.add(ownerAddressBytes.sublist(1)); + effectiveVotingAddress = ownerAddress.value; + } + + // nOperatorReward (16 bit) + if (operatorReward < 0 || operatorReward > 10000) { + throw Exception("Invalid operator reward: $operatorReward"); + } + registrationTx.add( + (ByteData( + 2, + )..setInt16(0, operatorReward, Endian.little)).buffer.asUint8List(), + ); + + // scriptPayout (variable) — must be P2PKH or P2SH per Firo consensus + final payoutType = cryptoCurrency.getAddressType(payoutAddress); + final Uint8List payoutScriptBytes; + if (payoutType == AddressType.p2pkh) { + final payoutHash = base58Decode(payoutAddress).sublist(1); + payoutScriptBytes = P2PKH.fromHash(payoutHash).script.compiled; + } else if (payoutType == AddressType.p2sh) { + final payoutHash = base58Decode(payoutAddress).sublist(1); + payoutScriptBytes = Uint8List.fromList([ + 0xa9, // OP_HASH160 + 0x14, // push 20 bytes + ...payoutHash, + 0x87, // OP_EQUAL + ]); + } else { + throw Exception( + "Payout address must be a transparent P2PKH or P2SH address, " + "not a Spark or other address type.", + ); + } + assert(payoutScriptBytes.length < 253); + registrationTx.addByte(payoutScriptBytes.length); + registrationTx.add(payoutScriptBytes); + + // --- coin selection for fee inputs only (exclude collateral UTXO) --- + final allUtxos = await mainDB.getUTXOs(walletId).findAll(); + final feeUtxos = allUtxos + .where( + (u) => + !(u.txid == collateralTxid && u.vout == collateralVout) && + !u.isBlocked && + u.used != true && + u.isConfirmed( + currentChainHeight, + cryptoCurrency.minConfirms, + cryptoCurrency.minCoinbaseConfirms, + ), + ) + .map((e) => StandardInput(e) as BaseInput) + .toList(); + + final partialTxData = TxData( + overrideVersion: 3 + (1 << 16), + feeRateAmount: cryptoCurrency.defaultFeeRate * BigInt.from(10), + recipients: [ + TxRecipient( + address: ownerAddress.value, + addressType: AddressType.p2pkh, + amount: cryptoCurrency.dustLimit, + isChange: false, + ), + ], + ); + + final partialTx = await coinSelection( + txData: partialTxData, + // Use non-coin-control mode so unavailable UTXOs are filtered out + // instead of causing a hard failure when any candidate is blocked + // or not yet spendable. + coinControl: false, + isSendAll: false, + isSendAllCoinControlUtxos: false, + utxos: feeUtxos, + ); + + // inputsHash (SHA256d of serialized inputs) + final inputsHashInput = BytesBuilder(); + for (final input in partialTx.usedUTXOs!) { + final standardInput = input as StandardInput; + final reversedTxidBytes = standardInput + .utxo + .txid + .toUint8ListFromHex + .reversed + .toList(); + inputsHashInput.add(reversedTxidBytes); + inputsHashInput.add( + (ByteData(4)..setInt32(0, standardInput.utxo.vout, Endian.little)) + .buffer + .asUint8List(), + ); + } + final inputsHash = crypto.sha256.convert(inputsHashInput.toBytes()).bytes; + final inputsHashHash = crypto.sha256.convert(inputsHash).bytes; + registrationTx.add(inputsHashHash); + + // --- payload hash & signature for external collateral --- + // SerializeHash(proRegTx) with SER_GETHASH excludes vchSig. + // The bytes built so far ARE the payload without vchSig. + final payloadForHash = registrationTx.toBytes(); + final payloadHash = crypto.sha256 + .convert(crypto.sha256.convert(payloadForHash).bytes) + .bytes; + // uint256::ToString() outputs bytes in reversed order + final payloadHashHex = payloadHash.reversed + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + + // MakeSignString format from Firo's providertx.cpp + final signString = + '$payoutAddress|$operatorReward|${ownerAddress.value}' + '|$effectiveVotingAddress|$payloadHashHex'; + + // Sign with the collateral private key + final root = await getRootHDNode(); + final collateralKeyPair = root.derivePath( + collateralAddr.derivationPath!.value, + ); + final signed = MessageSignature.sign( + key: collateralKeyPair.privateKey, + message: signString, + prefix: firoMessagePrefixForCoinlibSign( + cryptoCurrency.networkParams.messagePrefix, + ), + ); + + // vchSig — compact-size length + 65-byte compact signature + final vchSig = signed.signature.compact; + assert(vchSig.length == 65); + registrationTx.addByte(vchSig.length); + registrationTx.add(vchSig); + + // --- build, sign, and broadcast --- + final finalTxData = partialTx.copyWith( + vExtraData: registrationTx.toBytes(), + ); + final finalTx = await buildTransaction( + txData: finalTxData, + inputsWithKeys: partialTx.usedUTXOs!, + ); + + final finalTransactionHex = finalTx.raw!; + assert( + finalTransactionHex.toLowerCase().contains( + registrationTx.toBytes().toHex.toLowerCase(), + ), + 'ProReg payload missing from signed transaction hex', + ); + + final broadcastedTxHash = await electrumXClient.broadcastTransaction( + rawTx: finalTransactionHex, + ); + if (broadcastedTxHash.toUint8ListFromHex.length != 32) { + throw Exception("Failed to broadcast transaction: $broadcastedTxHash"); + } + Logging.instance.i( + "Successfully broadcasted masternode registration transaction: " + "$finalTransactionHex (txid $broadcastedTxHash)", + ); + + await updateSentCachedTxData(txData: finalTx); + + return broadcastedTxHash; + } + + Future> getMyMasternodes() async { + final proTxHashes = await getMyMasternodeProTxHashes(); + + return (await Future.wait( + proTxHashes.map( + (e) => Future(() async { + try { + final info = await electrumXClient.request( + command: 'protx.info', + args: [e], + ); + return MasternodeInfo( + proTxHash: info["proTxHash"] as String, + collateralHash: info["collateralHash"] as String, + collateralIndex: info["collateralIndex"] as int, + collateralAddress: info["collateralAddress"] as String, + operatorReward: double.parse(info["operatorReward"].toString()), + serviceAddr: (info["state"]["service"] as String).substring( + 0, + (info["state"]["service"] as String).lastIndexOf(":"), + ), + servicePort: int.parse( + (info["state"]["service"] as String).substring( + (info["state"]["service"] as String).lastIndexOf(":") + 1, + ), + ), + registeredHeight: info["state"]["registeredHeight"] as int, + lastPaidHeight: info["state"]["lastPaidHeight"] as int, + posePenalty: info["state"]["PoSePenalty"] as int, + poseRevivedHeight: info["state"]["PoSeRevivedHeight"] as int, + poseBanHeight: info["state"]["PoSeBanHeight"] as int, + revocationReason: info["state"]["revocationReason"] as int, + ownerAddress: info["state"]["ownerAddress"] as String, + votingAddress: info["state"]["votingAddress"] as String, + payoutAddress: info["state"]["payoutAddress"] as String, + pubKeyOperator: info["state"]["pubKeyOperator"] as String, + ); + } catch (err) { + // getMyMasternodeProTxHashes() may give non-masternode txids, so + // only log as info. + Logging.instance.i("Error getting masternode info for $e: $err"); + return null; + } + }), + ), + )).where((e) => e != null).map((e) => e!).toList(); + } + + Future> getMyMasternodeProTxHashes() async { + final List r = []; + final Set collateralTxids = {}; + final Set resolvedCollateralTxids = {}; + + final utxos = await mainDB.getUTXOs(walletId).sortByBlockHeight().findAll(); + final rawMasterNodeAmount = Amount.fromDecimal( + kMasterNodeValue, + fractionDigits: cryptoCurrency.fractionDigits, + ).raw.toInt(); + + for (final utxo in utxos) { + if (utxo.value == rawMasterNodeAmount) { + collateralTxids.add(utxo.txid); + } + } + + if (collateralTxids.isNotEmpty) { + try { + final walletTxids = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .txidProperty() + .findAll(); + + if (walletTxids.isNotEmpty) { + final txs = await electrumXCachedClient.getBatchTransactions( + txHashes: walletTxids.toSet().toList(growable: false), + cryptoCurrency: cryptoCurrency, + ); + + for (final tx in txs) { + final txid = tx["txid"]?.toString(); + final version = tx["version"]; + final type = tx["type"]; + final proReg = tx["proReg"]; + if (txid == null || version != 3 || type != 1 || proReg is! Map) { + continue; + } + + final proRegMap = Map.from(proReg); + final collateralHash = proRegMap["collateralHash"]?.toString(); + if (collateralHash != null && + collateralTxids.contains(collateralHash) && + !r.contains(txid)) { + r.add(txid); + resolvedCollateralTxids.add(collateralHash); + } + } + } + } catch (e) { + Logging.instance.i( + "Failed to resolve proTx hashes from wallet tx history: $e", + ); + } + } + + for (final txid in collateralTxids) { + if (!resolvedCollateralTxids.contains(txid)) { + r.add(txid); + } + } + + return r; } } diff --git a/lib/wallets/wallet/impl/litecoin_wallet.dart b/lib/wallets/wallet/impl/litecoin_wallet.dart index db497a9040..32cfe8fe6b 100644 --- a/lib/wallets/wallet/impl/litecoin_wallet.dart +++ b/lib/wallets/wallet/impl/litecoin_wallet.dart @@ -35,6 +35,9 @@ class LitecoinWallet @override int get isarTransactionVersion => 2; + @override + String get ordServerBaseUrl => 'https://ord-litecoin.stackwallet.com'; + LitecoinWallet(CryptoCurrencyNetwork network) : super(Litecoin(network) as T); @override @@ -49,20 +52,19 @@ class LitecoinWallet @override Future> fetchAddressesForElectrumXScan() async { - final allAddresses = - await mainDB - .getAddresses(walletId) - .filter() - .not() - .group( - (q) => q - .typeEqualTo(AddressType.mweb) - .or() - .typeEqualTo(AddressType.nonWallet) - .or() - .subTypeEqualTo(AddressSubType.nonWallet), - ) - .findAll(); + final allAddresses = await mainDB + .getAddresses(walletId) + .filter() + .not() + .group( + (q) => q + .typeEqualTo(AddressType.mweb) + .or() + .typeEqualTo(AddressType.nonWallet) + .or() + .subTypeEqualTo(AddressSubType.nonWallet), + ) + .findAll(); return allAddresses; } @@ -75,23 +77,19 @@ class LitecoinWallet await fetchAddressesForElectrumXScan(); // Separate receiving and change addresses. - final Set receivingAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.receiving) - .map((e) => e.value) - .toSet(); - final Set changeAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.change) - .map((e) => e.value) - .toSet(); + final Set receivingAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.receiving) + .map((e) => e.value) + .toSet(); + final Set changeAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.change) + .map((e) => e.value) + .toSet(); // Remove duplicates. final allAddressesSet = {...receivingAddresses, ...changeAddresses}; - final updateInscriptionsFuture = refreshInscriptions( - overrideAddressesToCheck: allAddressesSet.toList(), - ); + final updateInscriptionsFuture = refreshInscriptions(); // Fetch history from ElectrumX. final List> allTxHashes = await fetchHistory( @@ -102,11 +100,10 @@ class LitecoinWallet final List> allTransactions = []; for (final txHash in allTxHashes) { // Check for duplicates by searching for tx by tx_hash in db. - final storedTx = - await mainDB.isar.transactionV2s - .where() - .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) - .findFirst(); + final storedTx = await mainDB.isar.transactionV2s + .where() + .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) + .findFirst(); if (storedTx == null || storedTx.height == null || @@ -239,10 +236,9 @@ class LitecoinWallet final db = Drift.get(walletId); - final mwebUtxo = - await (db.select( - db.mwebUtxos, - )..where((e) => e.outputId.equals(outputId))).getSingleOrNull(); + final mwebUtxo = await (db.select( + db.mwebUtxos, + )..where((e) => e.outputId.equals(outputId))).getSingleOrNull(); final output = OutputV2.isarCantDoRequiredInDefaultConstructor( scriptPubKeyHex: "mweb", @@ -283,13 +279,12 @@ class LitecoinWallet // Check for special Litecoin outputs like ordinals. if (outputs.isNotEmpty) { // may not catch every case but it is much quicker - final hasOrdinal = - await mainDB.isar.ordinals - .where() - .filter() - .walletIdEqualTo(walletId) - .utxoTXIDEqualTo(txData["txid"] as String) - .isNotEmpty(); + final hasOrdinal = await mainDB.isar.ordinals + .where() + .filter() + .walletIdEqualTo(walletId) + .utxoTXIDEqualTo(txData["txid"] as String) + .isNotEmpty(); if (hasOrdinal) { subType = TransactionSubType.ordinal; } else { @@ -384,7 +379,7 @@ class LitecoinWallet @override int estimateTxFee({required int vSize, required BigInt feeRatePerKB}) { - return vSize * (feeRatePerKB.toInt() / 1000).ceil(); + return (feeRatePerKB * BigInt.from(vSize) ~/ BigInt.from(1000)).toInt(); } // diff --git a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart index 3850cb7500..d31be377f1 100644 --- a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart +++ b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart @@ -41,9 +41,15 @@ class MimblewimblecoinWallet extends Bip39Wallet { : super(Mimblewimblecoin(network)); final syncMutex = Mutex(); + final _walletOpenMutex = Mutex(); NodeModel? _mimblewimblecoinNode; Timer? timer; + static bool _mwcLogsInitialized = false; + + // Process-scoped Rust pointer; do not persist. + String? _walletHandle; + double highestPercent = 0; Future get getSyncPercent async { final int lastScannedBlock = @@ -75,12 +81,8 @@ class MimblewimblecoinWallet extends Bip39Wallet { value: stringConfig, ); - // Restart MWCMQS listener with new configuration if wallet has a handle. try { - final handle = await secureStorageInterface.read( - key: '${walletId}_wallet', - ); - if (handle != null && handle.isNotEmpty) { + if (_walletHandle != null) { await stopSlatepackListener(); await startSlatepackListener(); Logging.instance.i( @@ -95,32 +97,40 @@ class MimblewimblecoinWallet extends Bip39Wallet { } Future _ensureWalletOpen() async { - final existing = await secureStorageInterface.read( - key: '${walletId}_wallet', - ); - if (existing != null && existing.isNotEmpty) return existing; + return await _walletOpenMutex.protect(() async { + final cached = _walletHandle; + if (cached != null && cached.isNotEmpty) return cached; - final config = await _getRealConfig(); - final password = await secureStorageInterface.read( - key: '${walletId}_password', - ); - if (password == null) { - throw Exception('Wallet password not found'); - } - final opened = await libMwc.openWallet(config: config, password: password); - await secureStorageInterface.write( - key: '${walletId}_wallet', - value: opened, - ); - return opened; + final config = await _getRealConfig(); + if (!_mwcLogsInitialized) { + try { + await libMwc.initLogs(config: config); + _mwcLogsInitialized = true; + } catch (e, s) { + Logging.instance.w("libMwc.initLogs failed: $e\n$s"); + } + } + final password = await secureStorageInterface.read( + key: '${walletId}_password', + ); + if (password == null) { + throw Exception('Wallet password not found'); + } + final opened = await libMwc + .openWallet(config: config, password: password) + .timeout( + const Duration(seconds: 60), + onTimeout: () => throw TimeoutException('openWallet timed out'), + ); + _walletHandle = opened; + return opened; + }); } /// Returns an empty String on success, error message on failure. Future cancelPendingTransactionAndPost(String txSlateId) async { try { - final String wallet = (await secureStorageInterface.read( - key: '${walletId}_wallet', - ))!; + final String wallet = await _ensureWalletOpen(); final result = await libMwc.cancelTransaction( wallet: wallet, @@ -253,9 +263,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { /// Decode a slatepack. Future decodeSlatepack(String slatepack) async { try { - final handle = await secureStorageInterface.read( - key: '${walletId}_wallet', - ); + final handle = _walletHandle; final result = handle != null ? await libMwc.decodeSlatepackWithWallet( wallet: handle, @@ -346,13 +354,10 @@ class MimblewimblecoinWallet extends Bip39Wallet { /// Start MWCMQS listener for automatic transaction processing. Future startSlatepackListener() async { try { - await _ensureWalletOpen(); + final wallet = await _ensureWalletOpen(); final mwcmqsConfig = await getMwcMqsConfig(); - final wallet = await secureStorageInterface.read( - key: '${walletId}_wallet', - ); libMwc.startMwcMqsListener( - wallet: wallet!, + wallet: wallet, mwcmqsConfig: mwcmqsConfig.toString(), ); } catch (e, s) { @@ -409,10 +414,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { > analyzeSlatepack(String slatepack) async { try { - // Get wallet handle if available - final wallet = await secureStorageInterface.read( - key: '${walletId}_wallet', - ); + final wallet = _walletHandle; // Decode the slatepack final decoded = wallet != null @@ -563,6 +565,17 @@ class MimblewimblecoinWallet extends Bip39Wallet { // ================= Private ================================================= + Future _ensureApiSecret(String walletDir) async { + final file = File('$walletDir/.api_secret'); + final secret = _mimblewimblecoinNode?.nodeApiSecret; + if (secret != null) { + await Directory(walletDir).create(recursive: true); + await file.writeAsString(secret); + } else if (await file.exists()) { + await file.delete(); + } + } + Future _getConfig() async { if (_mimblewimblecoinNode == null) { await updateNode(); @@ -597,11 +610,11 @@ class MimblewimblecoinWallet extends Bip39Wallet { int satoshiAmount, { bool ifErrorEstimateFee = false, }) async { - final wallet = await secureStorageInterface.read(key: '${walletId}_wallet'); + final wallet = await _ensureWalletOpen(); try { final available = info.cachedBalance.spendable.raw.toInt(); final transactionFees = await libMwc.getTransactionFees( - wallet: wallet!, + wallet: wallet, amount: satoshiAmount, minimumConfirmations: cryptoCurrency.minConfirms, available: available, @@ -625,13 +638,13 @@ class MimblewimblecoinWallet extends Bip39Wallet { Future _startSync() async { Logging.instance.i("request start sync"); - final wallet = await secureStorageInterface.read(key: '${walletId}_wallet'); + final wallet = await _ensureWalletOpen(); const int refreshFromNode = 1; if (!syncMutex.isLocked) { await syncMutex.protect(() async { // How does getWalletBalances start syncing???? await libMwc.getWalletBalances( - wallet: wallet!, + wallet: wallet, refreshFromNode: refreshFromNode, minimumConfirmations: 10, ); @@ -650,10 +663,10 @@ class MimblewimblecoinWallet extends Bip39Wallet { }) > _allWalletBalances() async { - final wallet = await secureStorageInterface.read(key: '${walletId}_wallet'); + final wallet = await _ensureWalletOpen(); const refreshFromNode = 0; return await libMwc.getWalletBalances( - wallet: wallet!, + wallet: wallet, refreshFromNode: refreshFromNode, minimumConfirmations: cryptoCurrency.minConfirms, ); @@ -718,10 +731,10 @@ class MimblewimblecoinWallet extends Bip39Wallet { int index, MwcMqsConfigModel mwcmqsConfig, ) async { - final wallet = await secureStorageInterface.read(key: '${walletId}_wallet'); + final wallet = await _ensureWalletOpen(); final walletAddress = await libMwc.getAddressInfo( - wallet: wallet!, + wallet: wallet, index: index, ); @@ -744,9 +757,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { try { //First stop the current listener libMwc.stopMwcMqsListener(); - final wallet = await secureStorageInterface.read( - key: '${walletId}_wallet', - ); + final wallet = await _ensureWalletOpen(); // max number of blocks to scan per loop iteration const scanChunkSize = 10000; @@ -766,7 +777,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { ); final int nextScannedBlock = await libMwc.scanOutputs( - wallet: wallet!, + wallet: wallet, startHeight: lastScannedBlock, numberOfBlocks: scanChunkSize, ); @@ -798,10 +809,10 @@ class MimblewimblecoinWallet extends Bip39Wallet { Future _listenToMwcmqs() async { Logging.instance.i("STARTING WALLET LISTENER ...."); - final wallet = await secureStorageInterface.read(key: '${walletId}_wallet'); + final wallet = await _ensureWalletOpen(); final MwcMqsConfigModel mwcmqsConfig = await getMwcMqsConfig(); libMwc.startMwcMqsListener( - wallet: wallet!, + wallet: wallet, mwcmqsConfig: mwcmqsConfig.toString(), ); } @@ -855,22 +866,19 @@ class MimblewimblecoinWallet extends Bip39Wallet { @override Future init({bool? isRestore}) async { if (isRestore != true) { - String? encodedWallet = await secureStorageInterface.read( - key: "${walletId}_wallet", + // Password presence is the durable "wallet provisioned" marker; the + // old wallet-handle marker was process-scoped. + final existingPassword = await secureStorageInterface.read( + key: '${walletId}_password', ); - // check if should create a new wallet - if (encodedWallet == null) { + if (existingPassword == null) { await updateNode(); final mnemonicString = await getMnemonic(); final String password = generatePassword(); final String stringConfig = await _getConfig(); final MwcMqsConfigModel mwcmqsConfig = await getMwcMqsConfig(); - //if (!_logsInitialized) { - // await libMwc.initLogs(config: stringConfig); - // _logsInitialized = true; // Set flag to true after initializing - // } await secureStorageInterface.write( key: '${walletId}_config', value: stringConfig, @@ -894,14 +902,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { ); //Open wallet - encodedWallet = await libMwc.openWallet( - config: stringConfig, - password: password, - ); - await secureStorageInterface.write( - key: '${walletId}_wallet', - value: encodedWallet, - ); + await _ensureWalletOpen(); //Store MwcMqs address info await _generateAndStoreReceivingAddressForIndex(0); @@ -926,23 +927,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { ); } else { try { - final config = await _getRealConfig(); - //if (!_logsInitialized) { - // await libMwc.initLogs(config: config); - // _logsInitialized = true; // Set flag to true after initializing - //} - final password = await secureStorageInterface.read( - key: '${walletId}_password', - ); - - final walletOpen = await libMwc.openWallet( - config: config, - password: password!, - ); - await secureStorageInterface.write( - key: '${walletId}_wallet', - value: walletOpen, - ); + await _ensureWalletOpen(); await updateNode(); } catch (e, s) { @@ -958,9 +943,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { @override Future confirmSend({required TxData txData}) async { try { - final wallet = await secureStorageInterface.read( - key: '${walletId}_wallet', - ); + final wallet = await _ensureWalletOpen(); final MwcMqsConfigModel mwcmqsConfig = await getMwcMqsConfig(); // TODO determine whether it is worth sending change to a change address. @@ -983,7 +966,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { if (receiverAddress.startsWith("http://") || receiverAddress.startsWith("https://")) { transaction = await libMwc.txHttpSend( - wallet: wallet!, + wallet: wallet, selectionStrategyIsAll: 0, minimumConfirmations: cryptoCurrency.minConfirms, message: txData.noteOnChain ?? "", @@ -992,7 +975,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { ); } else if (receiverAddress.startsWith("mwcmqs://")) { transaction = await libMwc.createTransaction( - wallet: wallet!, + wallet: wallet, amount: txData.recipients!.first.amount.raw.toInt(), address: txData.recipients!.first.address, secretKeyIndex: 0, @@ -1144,14 +1127,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { ); //Open Wallet - final walletOpen = await libMwc.openWallet( - config: stringConfig, - password: password, - ); - await secureStorageInterface.write( - key: '${walletId}_wallet', - value: walletOpen, - ); + await _ensureWalletOpen(); await _generateAndStoreReceivingAddressForIndex( mimblewimblecoinData.receivingIndex, @@ -1317,9 +1293,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { @override Future updateTransactions() async { try { - final wallet = await secureStorageInterface.read( - key: '${walletId}_wallet', - ); + final wallet = await _ensureWalletOpen(); const refreshFromNode = 1; final myAddresses = await mainDB @@ -1335,7 +1309,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { final myAddressesSet = myAddresses.toSet(); final transactions = await libMwc.getTransactions( - wallet: wallet!, + wallet: wallet, refreshFromNode: refreshFromNode, ); @@ -1487,6 +1461,9 @@ class MimblewimblecoinWallet extends Bip39Wallet { Future updateNode() async { _mimblewimblecoinNode = getCurrentNode(); + final walletDir = await _currentWalletDirPath(); + await _ensureApiSecret(walletDir); + // TODO: [prio=low] move this out of secure storage if secure storage not needed final String stringConfig = await _getConfig(); await secureStorageInterface.write( @@ -1505,7 +1482,8 @@ class MimblewimblecoinWallet extends Bip39Wallet { NodeFormData() ..host = node!.host ..useSSL = node.useSSL - ..port = node.port, + ..port = node.port + ..apiSecret = node.nodeApiSecret, ) != null; } catch (e, s) { @@ -1570,8 +1548,12 @@ Future deleteMimblewimblecoinWallet({ required String walletId, required SecureStorageInterface secureStore, }) async { - final wallet = await secureStore.read(key: '${walletId}_wallet'); + await secureStore.delete(key: '${walletId}_wallet'); + String? config = await secureStore.read(key: '${walletId}_config'); + if (config == null) { + return "Tried to delete non existent mimblewimblecoin wallet file with walletId=$walletId"; + } if (Platform.isIOS) { final Directory appDir = await StackFileSystem.applicationRootDirectory(); @@ -1579,20 +1561,17 @@ Future deleteMimblewimblecoinWallet({ final String name = walletId.trim(); final walletDir = '$path/$name'; - final editConfig = jsonDecode(config as String); + final editConfig = jsonDecode(config); editConfig["wallet_dir"] = walletDir; config = jsonEncode(editConfig); } - if (wallet == null) { - return "Tried to delete non existent mimblewimblecoin wallet file with walletId=$walletId"; - } else { - try { - return libMwc.deleteWallet(wallet: wallet, config: config!); - } catch (e, s) { - Logging.instance.e("$e\n$s"); - return "deleteMimblewimblecoinWallet($walletId) failed..."; - } + try { + // Rust deleteWallet ignores the handle param. + return libMwc.deleteWallet(wallet: "", config: config); + } catch (e, s) { + Logging.instance.e("$e\n$s"); + return "deleteMimblewimblecoinWallet($walletId) failed..."; } } diff --git a/lib/wallets/wallet/impl/monero_wallet.dart b/lib/wallets/wallet/impl/monero_wallet.dart index 876bec9bf0..935d5ad3aa 100644 --- a/lib/wallets/wallet/impl/monero_wallet.dart +++ b/lib/wallets/wallet/impl/monero_wallet.dart @@ -35,19 +35,13 @@ class MoneroWallet extends LibMoneroWallet { } @override - bool walletExists(String path) => - csMonero.walletExists(path, csCoin: CsCoin.monero); + bool walletExists(String path) => csMonero.walletExists(path); @override Future loadWallet({ required String path, required String password, - }) => csMonero.loadWallet( - walletId, - path: path, - password: password, - csCoin: CsCoin.monero, - ); + }) => csMonero.loadWallet(walletId, path: path, password: password); @override Future getCreatedWallet({ @@ -56,7 +50,6 @@ class MoneroWallet extends LibMoneroWallet { required int wordCount, required String seedOffset, }) => csMonero.getCreatedWallet( - csCoin: CsCoin.monero, path: path, password: password, wordCount: wordCount, @@ -76,7 +69,6 @@ class MoneroWallet extends LibMoneroWallet { mnemonic: mnemonic, height: height, seedOffset: seedOffset, - csCoin: CsCoin.monero, walletId: walletId, ); @@ -89,7 +81,6 @@ class MoneroWallet extends LibMoneroWallet { int height = 0, }) => csMonero.getRestoredFromViewKeyWallet( walletId: walletId, - csCoin: CsCoin.monero, path: path, password: password, address: address, diff --git a/lib/wallets/wallet/impl/namecoin_wallet.dart b/lib/wallets/wallet/impl/namecoin_wallet.dart index a6dd6f74b7..10ea40c5a7 100644 --- a/lib/wallets/wallet/impl/namecoin_wallet.dart +++ b/lib/wallets/wallet/impl/namecoin_wallet.dart @@ -72,18 +72,17 @@ class NamecoinWallet @override Future> fetchAddressesForElectrumXScan() async { - final allAddresses = - await mainDB - .getAddresses(walletId) - .filter() - .not() - .group( - (q) => q - .typeEqualTo(AddressType.nonWallet) - .or() - .subTypeEqualTo(AddressSubType.nonWallet), - ) - .findAll(); + final allAddresses = await mainDB + .getAddresses(walletId) + .filter() + .not() + .group( + (q) => q + .typeEqualTo(AddressType.nonWallet) + .or() + .subTypeEqualTo(AddressSubType.nonWallet), + ) + .findAll(); return allAddresses; } @@ -140,9 +139,8 @@ class NamecoinWallet blockReason = "Contains name"; try { - final rawNameOP = - (output["scriptPubKey"]["nameOp"] as Map) - .cast(); + final rawNameOP = (output["scriptPubKey"]["nameOp"] as Map) + .cast(); otherDataString = jsonEncode({ UTXOOtherDataKeys.nameOpData: jsonEncode(rawNameOP), @@ -201,7 +199,7 @@ class NamecoinWallet @override int estimateTxFee({required int vSize, required BigInt feeRatePerKB}) { - return vSize * (feeRatePerKB.toInt() / 1000).ceil(); + return (feeRatePerKB * BigInt.from(vSize) ~/ BigInt.from(1000)).toInt(); } // TODO: Check if this is the correct formula for namecoin. @@ -227,16 +225,14 @@ class NamecoinWallet await fetchAddressesForElectrumXScan(); // Separate receiving and change addresses. - final Set receivingAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.receiving) - .map((e) => e.value) - .toSet(); - final Set changeAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.change) - .map((e) => e.value) - .toSet(); + final Set receivingAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.receiving) + .map((e) => e.value) + .toSet(); + final Set changeAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.change) + .map((e) => e.value) + .toSet(); // Remove duplicates. final allAddressesSet = {...receivingAddresses, ...changeAddresses}; @@ -250,11 +246,10 @@ class NamecoinWallet final List> allTransactions = []; for (final txHash in allTxHashes) { // Check for duplicates by searching for tx by tx_hash in db. - final storedTx = - await mainDB.isar.transactionV2s - .where() - .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) - .findFirst(); + final storedTx = await mainDB.isar.transactionV2s + .where() + .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) + .findFirst(); if (storedTx == null || storedTx.height == null || @@ -442,8 +437,11 @@ class NamecoinWallet ) async { // first check own utxos. Should only need to check NAME NEW here. // NAME UPDATE and NAME FIRST UPDATE will appear readable from electrumx - final utxos = - await mainDB.getUTXOs(walletId).filter().otherDataIsNotNull().findAll(); + final utxos = await mainDB + .getUTXOs(walletId) + .filter() + .otherDataIsNotNull() + .findAll(); for (final utxo in utxos) { final nameOp = getOpNameDataFrom(utxo); if (nameOp?.op == OpName.nameNew) { @@ -509,18 +507,17 @@ class NamecoinWallet try { final currentHeight = await chainHeight; // not ideal filtering - final utxos = - await mainDB - .getUTXOs(walletId) - .filter() - .otherDataIsNotNull() - .and() - .blockHeightIsNotNull() - .and() - .blockHeightGreaterThan(0) - .and() - .blockHeightLessThan(currentHeight - kNameWaitBlocks) - .findAll(); + final utxos = await mainDB + .getUTXOs(walletId) + .filter() + .otherDataIsNotNull() + .and() + .blockHeightIsNotNull() + .and() + .blockHeightGreaterThan(0) + .and() + .blockHeightLessThan(currentHeight - kNameWaitBlocks) + .findAll(); Logging.instance.t( "_unknownNameNewOutputs(count=${_unknownNameNewOutputs.length})" @@ -572,8 +569,9 @@ class NamecoinWallet data.salt, ); - String noteName = - data.name.startsWith("d/") ? data.name.substring(2) : data.name; + String noteName = data.name.startsWith("d/") + ? data.name.substring(2) + : data.name; if (!noteName.endsWith(".bit")) { noteName += ".bit"; } @@ -638,8 +636,10 @@ class NamecoinWallet assert(txData.recipients!.where((e) => !e.isChange).length == 1); if (!isForFeeCalcPurposesOnly) { - final nameAmount = - txData.recipients!.where((e) => !e.isChange).first.amount; + final nameAmount = txData.recipients! + .where((e) => !e.isChange) + .first + .amount; switch (txData.opNameState!.type) { case OpName.nameNew: @@ -664,10 +664,9 @@ class NamecoinWallet ); // TODO: [prio=high]: check this opt in rbf - final sequence = - this is RbfInterface && (this as RbfInterface).flagOptInRBF - ? 0xffffffff - 10 - : 0xffffffff - 1; + final sequence = this is RbfInterface && (this as RbfInterface).flagOptInRBF + ? 0xffffffff - 10 + : 0xffffffff - 1; // Add transaction inputs for (int i = 0; i < inputsWithKeys.length; i++) { @@ -737,10 +736,9 @@ class NamecoinWallet txid: inputsWithKeys[i].utxo.txid, vout: inputsWithKeys[i].utxo.vout, ), - addresses: - inputsWithKeys[i].utxo.address == null - ? [] - : [inputsWithKeys[i].utxo.address!], + addresses: inputsWithKeys[i].utxo.address == null + ? [] + : [inputsWithKeys[i].utxo.address!], valueStringSats: inputsWithKeys[i].utxo.value.toString(), witness: null, innerRedeemScriptAsm: null, @@ -872,9 +870,9 @@ class NamecoinWallet version: clTx.version, type: tempOutputs.map((e) => e.walletOwns).fold(true, (p, e) => p &= e) && - txData.paynymAccountLite == null - ? TransactionType.sentToSelf - : TransactionType.outgoing, + txData.paynymAccountLite == null + ? TransactionType.sentToSelf + : TransactionType.outgoing, subType: TransactionSubType.none, otherData: null, ), @@ -1023,20 +1021,19 @@ class NamecoinWallet final canCPFP = this is CpfpInterface && coinControl; - final spendableOutputs = - availableOutputs - .where( - (e) => - !e.isBlocked && - (e.used != true) && - (canCPFP || - e.isConfirmed( - currentChainHeight, - cryptoCurrency.minConfirms, - cryptoCurrency.minCoinbaseConfirms, - )), - ) - .toList(); + final spendableOutputs = availableOutputs + .where( + (e) => + !e.isBlocked && + (e.used != true) && + (canCPFP || + e.isConfirmed( + currentChainHeight, + cryptoCurrency.minConfirms, + cryptoCurrency.minCoinbaseConfirms, + )), + ) + .toList(); if (coinControl) { if (spendableOutputs.length < availableOutputs.length) { @@ -1118,24 +1115,22 @@ class NamecoinWallet final List recipientsAmtArray = [satoshiAmountToSend]; // gather required signing data - final inputsWithKeys = - (await addSigningKeys( - utxoObjectsToUse.map((e) => StandardInput(e)).toList(), - )).whereType().toList(); + final inputsWithKeys = (await addSigningKeys( + utxoObjectsToUse.map((e) => StandardInput(e)).toList(), + )).whereType().toList(); final int vSizeForOneOutput; try { - vSizeForOneOutput = - (await _createNameTx( - inputsWithKeys: inputsWithKeys, - isForFeeCalcPurposesOnly: true, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - [recipientAddress], - [satoshisBeingUsed], - ), - ), - )).vSize!; + vSizeForOneOutput = (await _createNameTx( + inputsWithKeys: inputsWithKeys, + isForFeeCalcPurposesOnly: true, + txData: txData.copyWith( + recipients: await helperRecipientsConvert( + [recipientAddress], + [satoshisBeingUsed], + ), + ), + )).vSize!; } catch (e, s) { Logging.instance.e("vSizeForOneOutput: $e", error: e, stackTrace: s); rethrow; @@ -1146,20 +1141,19 @@ class NamecoinWallet BigInt maxBI(BigInt a, BigInt b) => a > b ? a : b; try { - vSizeForTwoOutPuts = - (await _createNameTx( - inputsWithKeys: inputsWithKeys, - isForFeeCalcPurposesOnly: true, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - [recipientAddress, (await getCurrentChangeAddress())!.value], - [ - satoshiAmountToSend, - maxBI(BigInt.zero, satoshisBeingUsed - satoshiAmountToSend), - ], - ), - ), - )).vSize!; + vSizeForTwoOutPuts = (await _createNameTx( + inputsWithKeys: inputsWithKeys, + isForFeeCalcPurposesOnly: true, + txData: txData.copyWith( + recipients: await helperRecipientsConvert( + [recipientAddress, (await getCurrentChangeAddress())!.value], + [ + satoshiAmountToSend, + maxBI(BigInt.zero, satoshisBeingUsed - satoshiAmountToSend), + ], + ), + ), + )).vSize!; } catch (e, s) { Logging.instance.e("vSizeForTwoOutPuts: $e", error: e, stackTrace: s); rethrow; @@ -1170,18 +1164,18 @@ class NamecoinWallet satsPerVByte != null ? (satsPerVByte * vSizeForOneOutput) : estimateTxFee( - vSize: vSizeForOneOutput, - feeRatePerKB: selectedTxFeeRate, - ), + vSize: vSizeForOneOutput, + feeRatePerKB: selectedTxFeeRate, + ), ); // Assume 2 outputs, one for recipient and one for change final feeForTwoOutputs = BigInt.from( satsPerVByte != null ? (satsPerVByte * vSizeForTwoOutPuts) : estimateTxFee( - vSize: vSizeForTwoOutPuts, - feeRatePerKB: selectedTxFeeRate, - ), + vSize: vSizeForTwoOutPuts, + feeRatePerKB: selectedTxFeeRate, + ), ); Logging.instance.d( diff --git a/lib/wallets/wallet/impl/particl_wallet.dart b/lib/wallets/wallet/impl/particl_wallet.dart index eb9fb60437..65bc9c4c74 100644 --- a/lib/wallets/wallet/impl/particl_wallet.dart +++ b/lib/wallets/wallet/impl/particl_wallet.dart @@ -45,18 +45,17 @@ class ParticlWallet @override Future> fetchAddressesForElectrumXScan() async { - final allAddresses = - await mainDB - .getAddresses(walletId) - .filter() - .not() - .group( - (q) => q - .typeEqualTo(AddressType.nonWallet) - .or() - .subTypeEqualTo(AddressSubType.nonWallet), - ) - .findAll(); + final allAddresses = await mainDB + .getAddresses(walletId) + .filter() + .not() + .group( + (q) => q + .typeEqualTo(AddressType.nonWallet) + .or() + .subTypeEqualTo(AddressSubType.nonWallet), + ) + .findAll(); return allAddresses; } @@ -74,34 +73,40 @@ class ParticlWallet String? blockedReason; String? utxoLabel; + // Only check the specific output this UTXO corresponds to, not all outputs. + final vout = jsonUTXO["tx_pos"] as int; final outputs = jsonTX["vout"] as List? ?? []; - for (final output in outputs) { - if (output is Map) { - if (output['ct_fee'] != null) { - // Blind output, ignore for now. - blocked = true; - blockedReason = "Blind output."; - utxoLabel = "Unsupported output type."; - } else if (output['rangeproof'] != null) { - // Private RingCT output, ignore for now. - blocked = true; - blockedReason = "Confidential output."; - utxoLabel = "Unsupported output type."; - } else if (output['data_hex'] != null) { - // Data output, ignore for now. + // Use Map? because ElectrumX returns _Map. + Map? output; + for (final o in outputs) { + if (o is Map && o["n"] == vout) { + output = o; + break; + } + } + + if (output != null) { + if (output['ct_fee'] != null) { + blocked = true; + blockedReason = "Blind output."; + utxoLabel = "Unsupported output type."; + } else if (output['rangeproof'] != null) { + blocked = true; + blockedReason = "Confidential output."; + utxoLabel = "Unsupported output type."; + } else if (output['data_hex'] != null) { + blocked = true; + blockedReason = "Data output."; + utxoLabel = "Unsupported output type."; + } else if (output['scriptPubKey'] != null) { + if (output['scriptPubKey']?['asm'] is String && + (output['scriptPubKey']['asm'] as String).contains( + "OP_ISCOINSTAKE", + )) { blocked = true; - blockedReason = "Data output."; + blockedReason = "Spending staking"; utxoLabel = "Unsupported output type."; - } else if (output['scriptPubKey'] != null) { - if (output['scriptPubKey']?['asm'] is String && - (output['scriptPubKey']['asm'] as String).contains( - "OP_ISCOINSTAKE", - )) { - blocked = true; - blockedReason = "Spending staking"; - utxoLabel = "Unsupported output type."; - } } } } @@ -115,7 +120,7 @@ class ParticlWallet @override int estimateTxFee({required int vSize, required BigInt feeRatePerKB}) { - return vSize * (feeRatePerKB.toInt() / 1000).ceil(); + return (feeRatePerKB * BigInt.from(vSize) ~/ BigInt.from(1000)).toInt(); } @override @@ -140,16 +145,14 @@ class ParticlWallet await fetchAddressesForElectrumXScan(); // Separate receiving and change addresses. - final Set receivingAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.receiving) - .map((e) => e.value) - .toSet(); - final Set changeAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.change) - .map((e) => e.value) - .toSet(); + final Set receivingAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.receiving) + .map((e) => e.value) + .toSet(); + final Set changeAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.change) + .map((e) => e.value) + .toSet(); // Remove duplicates. final allAddressesSet = {...receivingAddresses, ...changeAddresses}; @@ -163,11 +166,10 @@ class ParticlWallet final List> allTransactions = []; for (final txHash in allTxHashes) { // Check for duplicates by searching for tx by tx_hash in db. - final storedTx = - await mainDB.isar.transactionV2s - .where() - .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) - .findFirst(); + final storedTx = await mainDB.isar.transactionV2s + .where() + .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) + .findFirst(); if (storedTx == null || storedTx.height == null || @@ -241,17 +243,12 @@ class ParticlWallet addresses.addAll(prevOut.addresses); } - InputV2 input = InputV2.isarCantDoRequiredInDefaultConstructor( - scriptSigHex: map["scriptSig"]?["hex"] as String?, - scriptSigAsm: map["scriptSig"]?["asm"] as String?, - sequence: map["sequence"] as int?, + InputV2 input = InputV2.fromElectrumxJson( + json: map, outpoint: outpoint, - valueStringSats: valueStringSats, addresses: addresses, - witness: map["witness"] as String?, + valueStringSats: valueStringSats, coinbase: coinbase, - innerRedeemScriptAsm: map["innerRedeemscriptAsm"] as String?, - // Need addresses before we can know if the wallet owns this input. walletOwns: false, ); @@ -382,31 +379,28 @@ class ParticlWallet switch (sd.derivePathType) { case DerivePathType.bip44: - data = - bitcoindart - .P2PKH( - data: bitcoindart.PaymentData(pubkey: pubKey), - network: convertedNetwork, - ) - .data; + data = bitcoindart + .P2PKH( + data: bitcoindart.PaymentData(pubkey: pubKey), + network: convertedNetwork, + ) + .data; break; case DerivePathType.bip49: - final p2wpkh = - bitcoindart - .P2WPKH( - data: bitcoindart.PaymentData(pubkey: pubKey), - network: convertedNetwork, - ) - .data; + final p2wpkh = bitcoindart + .P2WPKH( + data: bitcoindart.PaymentData(pubkey: pubKey), + network: convertedNetwork, + ) + .data; redeem = p2wpkh.output; - data = - bitcoindart - .P2SH( - data: bitcoindart.PaymentData(redeem: p2wpkh), - network: convertedNetwork, - ) - .data; + data = bitcoindart + .P2SH( + data: bitcoindart.PaymentData(redeem: p2wpkh), + network: convertedNetwork, + ) + .data; break; case DerivePathType.bip84: @@ -414,13 +408,12 @@ class ParticlWallet // prevOut: coinlib.OutPoint.fromHex(sd.utxo.txid, sd.utxo.vout), // publicKey: keys.publicKey, // ); - data = - bitcoindart - .P2WPKH( - data: bitcoindart.PaymentData(pubkey: pubKey), - network: convertedNetwork, - ) - .data; + data = bitcoindart + .P2WPKH( + data: bitcoindart.PaymentData(pubkey: pubKey), + network: convertedNetwork, + ) + .data; break; case DerivePathType.bip86: @@ -462,17 +455,16 @@ class ParticlWallet tempInputs.add( InputV2.isarCantDoRequiredInDefaultConstructor( - scriptSigHex: txb.inputs.first.script?.toHex, + scriptSigHex: txb.inputs[i].script?.toHex, scriptSigAsm: null, sequence: 0xffffffff - 1, outpoint: OutpointV2.isarCantDoRequiredInDefaultConstructor( txid: insAndKeys[i].utxo.txid, vout: insAndKeys[i].utxo.vout, ), - addresses: - insAndKeys[i].utxo.address == null - ? [] - : [insAndKeys[i].utxo.address!], + addresses: insAndKeys[i].utxo.address == null + ? [] + : [insAndKeys[i].utxo.address!], valueStringSats: insAndKeys[i].utxo.value.toString(), witness: null, innerRedeemScriptAsm: null, @@ -520,6 +512,7 @@ class ParticlWallet ), witnessValue: insAndKeys[i].utxo.value, redeemScript: extraData[i].redeem, + isParticl: true, overridePrefix: cryptoCurrency.networkParams.bech32Hrp, ); } @@ -535,30 +528,8 @@ class ParticlWallet final builtTx = txb.build(cryptoCurrency.networkParams.bech32Hrp); final vSize = builtTx.virtualSize(); - // Strip trailing 0x00 bytes from hex. - // - // This is done to match the previous particl_wallet implementation. - // TODO: [prio=low] Rework Particl tx construction so as to obviate this. - String hexString = builtTx.toHex(isParticl: true).toString(); - if (hexString.length % 2 != 0) { - // Ensure the string has an even length. - Logging.instance.e( - "Hex string has odd length, which is unexpected.", - stackTrace: StackTrace.current, - ); - throw Exception("Invalid hex string length."); - } - // int maxStrips = 3; // Strip up to 3 0x00s (match previous particl_wallet). - while (hexString.endsWith('00') && hexString.length > 2) { - hexString = hexString.substring(0, hexString.length - 2); - // maxStrips--; - // if (maxStrips <= 0) { - // break; - // } - } - return txData.copyWith( - raw: hexString, + raw: builtTx.toHex(isParticl: true), vSize: vSize, tempTx: null, // builtTx.getId() requires an isParticl flag as well but the lib does not support that yet diff --git a/lib/wallets/wallet/impl/peercoin_wallet.dart b/lib/wallets/wallet/impl/peercoin_wallet.dart index 8046f0d23c..bcdb36c3ed 100644 --- a/lib/wallets/wallet/impl/peercoin_wallet.dart +++ b/lib/wallets/wallet/impl/peercoin_wallet.dart @@ -37,18 +37,17 @@ class PeercoinWallet @override Future> fetchAddressesForElectrumXScan() async { - final allAddresses = - await mainDB - .getAddresses(walletId) - .filter() - .not() - .group( - (q) => q - .typeEqualTo(AddressType.nonWallet) - .or() - .subTypeEqualTo(AddressSubType.nonWallet), - ) - .findAll(); + final allAddresses = await mainDB + .getAddresses(walletId) + .filter() + .not() + .group( + (q) => q + .typeEqualTo(AddressType.nonWallet) + .or() + .subTypeEqualTo(AddressSubType.nonWallet), + ) + .findAll(); return allAddresses; } @@ -74,7 +73,7 @@ class PeercoinWallet /// we can just pretend vSize is size for peercoin @override int estimateTxFee({required int vSize, required BigInt feeRatePerKB}) { - return vSize * (feeRatePerKB.toInt() / 1000).ceil(); + return (feeRatePerKB * BigInt.from(vSize) ~/ BigInt.from(1000)).toInt(); } // =========================================================================== @@ -98,16 +97,14 @@ class PeercoinWallet await fetchAddressesForElectrumXScan(); // Separate receiving and change addresses. - final Set receivingAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.receiving) - .map((e) => e.value) - .toSet(); - final Set changeAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.change) - .map((e) => e.value) - .toSet(); + final Set receivingAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.receiving) + .map((e) => e.value) + .toSet(); + final Set changeAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.change) + .map((e) => e.value) + .toSet(); // Remove duplicates. final allAddressesSet = {...receivingAddresses, ...changeAddresses}; @@ -121,11 +118,10 @@ class PeercoinWallet final List> allTransactions = []; for (final txHash in allTxHashes) { // Check for duplicates by searching for tx by tx_hash in db. - final storedTx = - await mainDB.isar.transactionV2s - .where() - .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) - .findFirst(); + final storedTx = await mainDB.isar.transactionV2s + .where() + .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) + .findFirst(); if (storedTx == null || storedTx.height == null || diff --git a/lib/wallets/wallet/impl/solana_wallet.dart b/lib/wallets/wallet/impl/solana_wallet.dart index c88d995624..9abc4cf75a 100644 --- a/lib/wallets/wallet/impl/solana_wallet.dart +++ b/lib/wallets/wallet/impl/solana_wallet.dart @@ -7,15 +7,19 @@ import 'package:isar_community/isar.dart'; import 'package:socks5_proxy/socks_client.dart'; import 'package:solana/dto.dart'; import 'package:solana/solana.dart'; -import 'package:tuple/tuple.dart'; import '../../../app_config.dart'; import '../../../exceptions/wallet/node_tor_mismatch_config_exception.dart'; import '../../../models/balance.dart'; import '../../../models/isar/models/blockchain_data/transaction.dart' as isar; +import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; +import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; +import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../models/isar/models/isar_models.dart'; import '../../../models/node_model.dart'; import '../../../models/paymint/fee_object_model.dart'; +import '../../../services/event_bus/events/global/updated_in_background_event.dart'; +import '../../../services/event_bus/global_event_bus.dart'; import '../../../services/node_service.dart'; import '../../../services/tor_service.dart'; import '../../../utilities/amount/amount.dart'; @@ -33,7 +37,15 @@ class SolanaWallet extends Bip39Wallet { NodeModel? _solNode; - RpcClient? _rpcClient; // The Solana RpcClient. + RpcClient? _rpcClient; + + RpcClient? getRpcClient() { + return _rpcClient; + } + + Future getKeyPair() async { + return _getKeyPair(); + } Future _getKeyPair() async { return Ed25519HDKeyPair.fromMnemonic( @@ -57,19 +69,23 @@ class SolanaWallet extends Bip39Wallet { } Future _getCurrentBalanceInLamports() async { - _checkClient(); + checkClient(); final balance = await _rpcClient?.getBalance((await _getKeyPair()).address); return BigInt.from(balance!.value); } - Future _getEstimatedNetworkFee(Amount transferAmount) async { - _checkClient(); + Future _getEstimatedNetworkFee( + Amount transferAmount, + String? memo, + ) async { + checkClient(); final latestBlockhash = await _rpcClient?.getLatestBlockhash(); final pubKey = (await _getKeyPair()).publicKey; final compiledMessage = Message( instructions: [ + if (memo != null) MemoInstruction(signers: const [], memo: memo), SystemInstruction.transfer( fundingAccount: pubKey, recipientAccount: pubKey, @@ -90,9 +106,24 @@ class SolanaWallet extends Bip39Wallet { return BigInt.from(estimate); } + @override + int get isarTransactionVersion => 2; + + @override + FilterOperation? get transactionFilterOperation => FilterGroup.not( + const FilterCondition.equalTo( + property: r"subType", + value: TransactionSubType.splToken, + ), + ); + @override FilterOperation? get changeAddressFilterOperation => - throw UnimplementedError(); + FilterGroup.and(standardChangeAddressFilters); + + @override + FilterOperation? get receivingAddressFilterOperation => + FilterGroup.and(standardReceivingAddressFilters); @override Future checkSaveInitialReceivingAddress() async { @@ -116,7 +147,7 @@ class SolanaWallet extends Bip39Wallet { @override Future prepareSend({required TxData txData}) async { try { - _checkClient(); + checkClient(); if (txData.recipients == null || txData.recipients!.length != 1) { throw Exception("$runtimeType prepareSend requires 1 recipient"); @@ -128,7 +159,7 @@ class SolanaWallet extends Bip39Wallet { throw Exception("Insufficient available balance"); } - final feeAmount = await _getEstimatedNetworkFee(sendAmount); + final feeAmount = await _getEstimatedNetworkFee(sendAmount, txData.memo); if (feeAmount == null) { throw Exception( "Failed to get fees, please check your node connection.", @@ -177,7 +208,7 @@ class SolanaWallet extends Bip39Wallet { @override Future confirmSend({required TxData txData}) async { try { - _checkClient(); + checkClient(); final keyPair = await _getKeyPair(); final recipientAccount = txData.recipients!.first; @@ -186,6 +217,8 @@ class SolanaWallet extends Bip39Wallet { ); final message = Message( instructions: [ + if (txData.memo != null) + MemoInstruction(signers: const [], memo: txData.memo!), SystemInstruction.transfer( fundingAccount: keyPair.publicKey, recipientAccount: recipientPubKey, @@ -203,6 +236,52 @@ class SolanaWallet extends Bip39Wallet { ); final txid = await _rpcClient?.signAndSendTransaction(message, [keyPair]); + + // Persist pending transaction immediately so UI shows "Sending" status. + if (txid != null) { + final senderAddress = keyPair.address; + final isToSelf = senderAddress == recipientAccount.address; + + final tempTx = TransactionV2( + walletId: walletId, + blockHash: null, // CRITICAL: indicates pending. + hash: txid, + txid: txid, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + height: null, // CRITICAL: indicates pending. + inputs: [ + InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: null, + addresses: [senderAddress], + valueStringSats: txData.amount!.raw.toString(), + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: true, + ), + ], + outputs: [ + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: "00", + valueStringSats: txData.amount!.raw.toString(), + addresses: [recipientAccount.address], + walletOwns: isToSelf, + ), + ], + version: -1, + type: isToSelf + ? isar.TransactionType.sentToSelf + : isar.TransactionType.outgoing, + subType: isar.TransactionSubType.none, + otherData: jsonEncode({"overrideFee": txData.fee!.toJsonString()}), + ); + + await mainDB.updateOrPutTransactionV2s([tempTx]); + } + return txData.copyWith(txid: txid); } catch (e, s) { Logging.instance.e( @@ -216,7 +295,7 @@ class SolanaWallet extends Bip39Wallet { @override Future estimateFeeFor(Amount amount, BigInt feeRate) async { - _checkClient(); + checkClient(); if (info.cachedBalance.spendable.raw == BigInt.zero) { return Amount( @@ -225,35 +304,62 @@ class SolanaWallet extends Bip39Wallet { ); } - final fee = await _getEstimatedNetworkFee(amount); - if (fee == null) { - throw Exception("Failed to get fees, please check your node connection."); - } - - return Amount(rawValue: fee, fractionDigits: cryptoCurrency.fractionDigits); + // The feeRate parameter contains the total fee amount to use. + // For Solana, this is already calculated based on priority tier. + // Simply return it as the fee estimate. + return Amount( + rawValue: feeRate, + fractionDigits: cryptoCurrency.fractionDigits, + ); } @override Future get fees async { - _checkClient(); + checkClient(); - final fee = await _getEstimatedNetworkFee( + final baseFee = await _getEstimatedNetworkFee( Amount.fromDecimal( - Decimal.one, // 1 SOL + Decimal.one, // 1 SOL. fractionDigits: cryptoCurrency.fractionDigits, ), + null, // ? ); - if (fee == null) { + if (baseFee == null) { throw Exception("Failed to get fees, please check your node connection."); } + // Differentiate fees by tier using multipliers: + // Base fee is typically around 5000 lamports. + // Slow: minimum 5000 lamports. + // Average: base fee * 1.5 (but not less than slow). + // Fast: base fee * 2.0 (but not less than average). + // Ensure all fees stay within bounds: 5000-1000000 lamports. + const minFeeBig = 5000; + const maxFeeBig = 1000000; + + // Calculate tier fees with multipliers. + final slowFee = baseFee; // Use base fee for slow. + final averageFee = (baseFee * BigInt.from(3)) ~/ BigInt.from(2); // 1.5x. + final fastFee = baseFee * BigInt.from(2); // 2.0x. + + // Clamp all fees to the allowed range. + final _clamp = (BigInt value) { + if (value < BigInt.from(minFeeBig)) return BigInt.from(minFeeBig); + if (value > BigInt.from(maxFeeBig)) return BigInt.from(maxFeeBig); + return value; + }; + + final clampedSlow = _clamp(slowFee); + final clampedAverage = _clamp(averageFee); + final clampedFast = _clamp(fastFee); + return FeeObject( numberOfBlocksFast: 1, numberOfBlocksAverage: 1, numberOfBlocksSlow: 1, - fast: fee, - medium: fee, - slow: fee, + fast: clampedFast, + medium: clampedAverage, + slow: clampedSlow, ); } @@ -261,7 +367,7 @@ class SolanaWallet extends Bip39Wallet { Future pingCheck() async { String? health; try { - _checkClient(); + checkClient(); health = await _rpcClient?.getHealth(); return health != null; } catch (e, s) { @@ -272,10 +378,6 @@ class SolanaWallet extends Bip39Wallet { } } - @override - FilterOperation? get receivingAddressFilterOperation => - FilterGroup.and(standardReceivingAddressFilters); - @override Future recover({required bool isRescan}) async { await refreshMutex.protect(() async { @@ -300,7 +402,7 @@ class SolanaWallet extends Bip39Wallet { @override Future updateBalance() async { - _checkClient(); + checkClient(); try { final address = await getCurrentReceivingAddress(); @@ -350,7 +452,7 @@ class SolanaWallet extends Bip39Wallet { @override Future updateChainHeight() async { try { - _checkClient(); + checkClient(); final int blockHeight = await _rpcClient?.getSlot() ?? 0; // TODO [prio=low]: Revisit null condition. @@ -391,100 +493,192 @@ class SolanaWallet extends Bip39Wallet { @override Future updateTransactions() async { try { - _checkClient(); + checkClient(); final transactionsList = await _rpcClient?.getTransactionsList( (await _getKeyPair()).publicKey, encoding: Encoding.jsonParsed, ); - final txsList = List>.empty( - growable: true, - ); final myAddress = (await getCurrentReceivingAddress())!; - // TODO [prio=low]: Revisit null assertion below. - - for (final tx in transactionsList!) { - final senderAddress = - (tx.transaction as ParsedTransaction).message.accountKeys[0].pubkey; - var receiverAddress = - (tx.transaction as ParsedTransaction).message.accountKeys[1].pubkey; - var txType = isar.TransactionType.unknown; - final txAmount = Amount( - rawValue: BigInt.from( - tx.meta!.postBalances[1] - tx.meta!.preBalances[1], - ), - fractionDigits: cryptoCurrency.fractionDigits, - ); + if (transactionsList == null) { + return; + } - if ((senderAddress == myAddress.value) && - (receiverAddress == "11111111111111111111111111111111")) { - // The account that is only 1's are System Program accounts which - // means there is no receiver except the sender, - // see: https://explorer.solana.com/address/11111111111111111111111111111111 - txType = isar.TransactionType.sentToSelf; - receiverAddress = senderAddress; - } else if (senderAddress == myAddress.value) { - txType = isar.TransactionType.outgoing; - } else if (receiverAddress == myAddress.value) { - txType = isar.TransactionType.incoming; - } + final txns = []; + int skippedCount = 0; + + for (final tx in transactionsList) { + try { + // Skip transactions without metadata. + if (tx.meta == null) { + skippedCount++; + continue; + } + + if (tx.transaction is! ParsedTransaction) { + skippedCount++; + continue; + } + + final parsedTx = tx.transaction as ParsedTransaction; + final txid = parsedTx.signatures.isNotEmpty + ? parsedTx.signatures[0] + : null; + + if (parsedTx.signatures.length > 1) { + Logging.instance.w( + "SOL $walletId found tx with " + "${parsedTx.signatures.length} signatures", + ); + } + + if (txid == null) { + skippedCount++; + continue; + } + + final systemTransfers = parsedTx.message.instructions + .map((e) => e.toJson()) + .where( + (e) => + e.containsKey("parsed") && + e["program"] == "system" && + e["parsed"]["type"] == "transfer", + ); + + if (systemTransfers.length != 1) { + Logging.instance.w( + "SOL $walletId found tx with " + "${systemTransfers.length} system transfer! Skipping...", + ); + skippedCount++; + continue; + } + final transfer = systemTransfers.first; + final lamports = BigInt.parse( + transfer["parsed"]["info"]["lamports"].toString(), + ); + final senderAddress = transfer["parsed"]["info"]["source"] as String; + final receiverAddress = + transfer["parsed"]["info"]["destination"] as String; + + final isar.TransactionType txType; + + if ((senderAddress == myAddress.value) && + (receiverAddress == senderAddress)) { + txType = isar.TransactionType.sentToSelf; + } else if (senderAddress == myAddress.value) { + txType = isar.TransactionType.outgoing; + } else if (receiverAddress == myAddress.value) { + txType = isar.TransactionType.incoming; + } else { + // probably should never get here? If so, then this fragile parsing + // is broken which isn't surprising... + txType = isar.TransactionType.unknown; + } + + // check for memo + final memos = parsedTx.message.instructions + .map((e) => e.toJson()) + .where( + (e) => e["parsed"] is String && e["program"] == "spl-memo", + ); + final String? memo = memos.isEmpty + ? null + : memos.first["parsed"] as String; + + // Create TransactionV2 object. + final txn = TransactionV2( + walletId: walletId, + blockHash: null, + hash: txid, + txid: txid, + timestamp: + tx.blockTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000, + height: tx.slot, + inputs: [ + InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: null, + addresses: [senderAddress], + valueStringSats: lamports.toString(), + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: senderAddress == myAddress.value, + ), + ], + outputs: [ + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: "00", + valueStringSats: lamports.toString(), + addresses: [receiverAddress], + walletOwns: receiverAddress == myAddress.value, + ), + ], + version: tx.version?.version?.toInt() ?? -1, + type: txType, + subType: isar.TransactionSubType.none, + otherData: jsonEncode({ + TxV2OdKeys.overrideFee: tx.meta!.fee.toString(), + if (memo != null) TxV2OdKeys.memo: memo, + }), + ); - final transaction = isar.Transaction( - walletId: walletId, - txid: (tx.transaction as ParsedTransaction).signatures[0], - timestamp: tx.blockTime!, - type: txType, - subType: isar.TransactionSubType.none, - amount: tx.meta!.postBalances[1] - tx.meta!.preBalances[1], - amountString: txAmount.toJsonString(), - fee: tx.meta!.fee, - height: tx.slot, - isCancelled: false, - isLelantus: false, - slateId: null, - otherData: null, - inputs: [], - outputs: [], - nonce: null, - numberOfMessages: 0, - ); + txns.add(txn); + } catch (e, s) { + Logging.instance.w( + "$runtimeType updateTransactions: Failed to parse transaction", + error: e, + stackTrace: s, + ); + skippedCount++; + continue; + } + } - final txAddress = Address( - walletId: walletId, - value: receiverAddress, - publicKey: List.empty(), - derivationIndex: 0, - derivationPath: DerivationPath()..value = _addressDerivationPath, - type: AddressType.solana, - subType: txType == isar.TransactionType.outgoing - ? AddressSubType.unknown - : AddressSubType.receiving, + // Persist all transactions if any were parsed. + if (txns.isNotEmpty) { + await mainDB.updateOrPutTransactionV2s(txns); + Logging.instance.i( + "$runtimeType updateTransactions: Synced ${txns.length} transactions (skipped $skippedCount)", ); - - txsList.add(Tuple2(transaction, txAddress)); } - await mainDB.addNewTransactionData(txsList, walletId); } on NodeTorMismatchConfigException { rethrow; } catch (e, s) { Logging.instance.e( - "Error occurred in solana_wallet.dart while getting" - " transactions for solana: $e\n$s", + "$runtimeType updateTransactions failed: ", + error: e, + stackTrace: s, ); } } @override Future updateUTXOs() async { - // No UTXOs in Solana return false; } - /// Make sure the Solana RpcClient uses Tor if it's enabled. - /// - void _checkClient() { + Future updateSolanaTokens(List mintAddresses) async { + await info.updateSolanaCustomTokenMintAddresses( + newMintAddresses: mintAddresses, + isar: mainDB.isar, + ); + + GlobalEventBus.instance.fire( + UpdatedInBackgroundEvent( + "Solana custom tokens updated for: $walletId ${info.name}", + walletId, + ), + ); + } + + void checkClient() { final node = getCurrentNode(); final netOption = TorPlainNetworkOption.fromNodeData( diff --git a/lib/wallets/wallet/impl/stellar_wallet.dart b/lib/wallets/wallet/impl/stellar_wallet.dart index 86cc1aa026..ad41549a52 100644 --- a/lib/wallets/wallet/impl/stellar_wallet.dart +++ b/lib/wallets/wallet/impl/stellar_wallet.dart @@ -140,8 +140,9 @@ class StellarWallet extends Bip39Wallet { HttpClient? _httpClient; if (AppConfig.hasFeature(AppFeature.tor) && prefs.useTor) { - final ({InternetAddress host, int port}) proxyInfo = - TorService.sharedInstance.getProxyInfo(); + final ({InternetAddress host, int port}) proxyInfo = TorService + .sharedInstance + .getProxyInfo(); _httpClient = HttpClient(); SocksTCPClient.assignToHttpClient(_httpClient, [ @@ -443,7 +444,7 @@ class StellarWallet extends Bip39Wallet { .order(stellar.RequestBuilderOrder.DESC) .limit(1) .execute() - .then((value) => value.records!.first.sequence); + .then((value) => value.records.first.sequence); await info.updateCachedChainHeight(newHeight: height, isar: mainDB.isar); } catch (e, s) { Logging.instance.e( @@ -470,11 +471,10 @@ class StellarWallet extends Bip39Wallet { final List transactionList = []; stellar.Page payments; try { - payments = - await (await stellarSdk).payments - .forAccount(myAddress.value) - .order(stellar.RequestBuilderOrder.DESC) - .execute(); + payments = await (await stellarSdk).payments + .forAccount(myAddress.value) + .order(stellar.RequestBuilderOrder.DESC) + .execute(); } catch (e) { if (e is stellar.ErrorResponse && e.body.contains( @@ -492,13 +492,13 @@ class StellarWallet extends Bip39Wallet { rethrow; } } - for (final stellar.OperationResponse response in payments.records!) { + for (final stellar.OperationResponse response in payments.records) { // PaymentOperationResponse por; if (response is stellar.PaymentOperationResponse) { final por = response; - final addressTo = por.to!.accountId; - final addressFrom = por.from!.accountId; + final addressTo = por.to; + final addressFrom = por.from; final TransactionType type; if (addressFrom == myAddress.value) { @@ -513,7 +513,7 @@ class StellarWallet extends Bip39Wallet { final amount = Amount( rawValue: BigInt.parse( float - .parse(por.amount!) + .parse(por.amount) .toStringAsFixed(cryptoCurrency.fractionDigits) .replaceAll(".", ""), ), @@ -553,28 +553,27 @@ class StellarWallet extends Bip39Wallet { // por.transaction returns a null sometimes final stellar.TransactionResponse tx = await (await stellarSdk) .transactions - .transaction(por.transactionHash!); + .transaction(por.transactionHash); if (tx.hash.isNotEmpty) { - fee = tx.feeCharged!; + fee = tx.feeCharged; height = tx.ledger; } final otherData = { - "overrideFee": - Amount( - rawValue: BigInt.from(fee), - fractionDigits: cryptoCurrency.fractionDigits, - ).toJsonString(), + "overrideFee": Amount( + rawValue: BigInt.from(fee), + fractionDigits: cryptoCurrency.fractionDigits, + ).toJsonString(), }; final theTransaction = TransactionV2( walletId: walletId, blockHash: "", - hash: por.transactionHash!, - txid: por.transactionHash!, + hash: por.transactionHash, + txid: por.transactionHash, timestamp: - DateTime.parse(por.createdAt!).millisecondsSinceEpoch ~/ 1000, + DateTime.parse(por.createdAt).millisecondsSinceEpoch ~/ 1000, height: height, inputs: inputs, outputs: outputs, @@ -596,7 +595,7 @@ class StellarWallet extends Bip39Wallet { final amount = Amount( rawValue: BigInt.parse( float - .parse(caor.startingBalance!) + .parse(caor.startingBalance) .toStringAsFixed(cryptoCurrency.fractionDigits) .replaceAll(".", ""), ), @@ -613,9 +612,9 @@ class StellarWallet extends Bip39Wallet { valueStringSats: amount.raw.toString(), addresses: [ // this is what the previous code was doing and I don't think its correct - caor.sourceAccount!, + caor.sourceAccount, ], - walletOwns: caor.sourceAccount! == myAddress.value, + walletOwns: caor.sourceAccount == myAddress.value, ); final InputV2 input = InputV2.isarCantDoRequiredInDefaultConstructor( scriptSigHex: null, @@ -624,13 +623,13 @@ class StellarWallet extends Bip39Wallet { outpoint: null, addresses: [ // this is what the previous code was doing and I don't think its correct - caor.sourceAccount!, + caor.sourceAccount, ], valueStringSats: amount.raw.toString(), witness: null, innerRedeemScriptAsm: null, coinbase: null, - walletOwns: caor.sourceAccount! == myAddress.value, + walletOwns: caor.sourceAccount == myAddress.value, ); outputs.add(output); @@ -639,28 +638,27 @@ class StellarWallet extends Bip39Wallet { int fee = 0; int height = 0; final tx = await (await stellarSdk).transactions.transaction( - caor.transactionHash!, + caor.transactionHash, ); if (tx.hash.isNotEmpty) { - fee = tx.feeCharged!; + fee = tx.feeCharged; height = tx.ledger; } final otherData = { - "overrideFee": - Amount( - rawValue: BigInt.from(fee), - fractionDigits: cryptoCurrency.fractionDigits, - ).toJsonString(), + "overrideFee": Amount( + rawValue: BigInt.from(fee), + fractionDigits: cryptoCurrency.fractionDigits, + ).toJsonString(), }; final theTransaction = TransactionV2( walletId: walletId, blockHash: "", - hash: caor.transactionHash!, - txid: caor.transactionHash!, + hash: caor.transactionHash, + txid: caor.transactionHash, timestamp: - DateTime.parse(caor.createdAt!).millisecondsSinceEpoch ~/ 1000, + DateTime.parse(caor.createdAt).millisecondsSinceEpoch ~/ 1000, height: height, inputs: inputs, outputs: outputs, diff --git a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart index e45babf9f9..6aca5a0082 100644 --- a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:ethereum_addresses/ethereum_addresses.dart'; import 'package:isar_community/isar.dart'; +import 'package:wallet/wallet.dart' as eth_wallet; import 'package:web3dart/web3dart.dart' as web3dart; import '../../../../dto/ethereum/eth_token_tx_dto.dart'; @@ -110,10 +111,9 @@ class EthTokenWallet extends Wallet { inputs: List.unmodifiable(inputs), outputs: List.unmodifiable(outputs), version: -1, - type: - addressTo == myAddress - ? TransactionType.sentToSelf - : TransactionType.outgoing, + type: addressTo == myAddress + ? TransactionType.sentToSelf + : TransactionType.outgoing, subType: TransactionSubType.ethToken, otherData: jsonEncode(otherData), ); @@ -131,12 +131,24 @@ class EthTokenWallet extends Wallet { FilterOperation? get receivingAddressFilterOperation => ethWallet.receivingAddressFilterOperation; + bool _unverifiedAndUntestedHackFlagThatMightFixAnIssue = true; + + @override + Future refresh() async { + if (_unverifiedAndUntestedHackFlagThatMightFixAnIssue) { + await ethWallet.refresh(); + _unverifiedAndUntestedHackFlagThatMightFixAnIssue = false; + } + + return super.refresh(); + } + @override Future init() async { try { await super.init(); - final contractAddress = web3dart.EthereumAddress.fromHex( + final contractAddress = eth_wallet.EthereumAddress.fromHex( tokenContract.address, ); @@ -144,7 +156,7 @@ class EthTokenWallet extends Wallet { try { _tokenContract = await _updateTokenABI( forContract: tokenContract, - usingContractAddress: contractAddress.hex, + usingContractAddress: contractAddress.eip55With0x, ); } catch (e, s) { Logging.instance.w( @@ -173,7 +185,7 @@ class EthTokenWallet extends Wallet { // Some failure, try for proxy contract final contractAddressResponse = await EthereumAPI.getProxyTokenImplementationAddress( - contractAddress.hex, + contractAddress.eip55With0x, ); if (contractAddressResponse.value != null) { @@ -217,11 +229,10 @@ class EthTokenWallet extends Wallet { // double check balance after internalSharedPrepareSend call to ensure // balance is up to date - final info = - await mainDB.isar.tokenWalletInfo - .where() - .walletIdTokenAddressEqualTo(walletId, tokenContract.address) - .findFirst(); + final info = await mainDB.isar.tokenWalletInfo + .where() + .walletIdTokenAddressEqualTo(walletId, tokenContract.address) + .findFirst(); final availableBalance = info?.getCachedBalance().spendable ?? Amount.zeroWith(fractionDigits: tokenContract.decimals); @@ -232,15 +243,15 @@ class EthTokenWallet extends Wallet { final tx = web3dart.Transaction.callContract( contract: _deployedContract, function: _sendFunction, - parameters: [web3dart.EthereumAddress.fromHex(address), amount.raw], + parameters: [eth_wallet.EthereumAddress.fromHex(address), amount.raw], maxGas: txData.ethEIP1559Fee?.gasLimit ?? kEthereumTokenMinGasLimit, nonce: prep.nonce, - maxFeePerGas: web3dart.EtherAmount.fromBigInt( - web3dart.EtherUnit.wei, + maxFeePerGas: eth_wallet.EtherAmount.fromBigInt( + eth_wallet.EtherUnit.wei, prep.maxBaseFee, ), - maxPriorityFeePerGas: web3dart.EtherAmount.fromBigInt( - web3dart.EtherUnit.wei, + maxPriorityFeePerGas: eth_wallet.EtherAmount.fromBigInt( + eth_wallet.EtherUnit.wei, prep.priorityFee, ), ); @@ -302,11 +313,10 @@ class EthTokenWallet extends Wallet { @override Future updateBalance() async { try { - final info = - await mainDB.isar.tokenWalletInfo - .where() - .walletIdTokenAddressEqualTo(walletId, tokenContract.address) - .findFirst(); + final info = await mainDB.isar.tokenWalletInfo + .where() + .walletIdTokenAddressEqualTo(walletId, tokenContract.address) + .findFirst(); final response = await EthereumAPI.getWalletTokenBalance( address: (await getCurrentReceivingAddress())!.value, contractAddress: tokenContract.address, diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart new file mode 100644 index 0000000000..5fb99a19a6 --- /dev/null +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -0,0 +1,957 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'dart:convert'; + +import 'package:isar_community/isar.dart'; +import 'package:solana/dto.dart' hide Instruction; +import 'package:solana/encoder.dart' show Instruction; +import 'package:solana/solana.dart' hide Wallet; + +import '../../../../models/balance.dart'; +import '../../../../models/isar/models/blockchain_data/v2/input_v2.dart'; +import '../../../../models/isar/models/blockchain_data/v2/output_v2.dart'; +import '../../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import '../../../../models/isar/models/isar_models.dart'; +import '../../../../models/paymint/fee_object_model.dart'; +import '../../../../services/solana/solana_token_api.dart'; +import '../../../../utilities/amount/amount.dart'; +import '../../../../utilities/logger.dart'; +import '../../../models/tx_data.dart'; +import '../../wallet.dart'; +import '../solana_wallet.dart'; + +class SolanaTokenWallet extends Wallet { + @override + int get isarTransactionVersion => 2; + + SolanaTokenWallet(this.parentSolanaWallet, this.solContract) + : super(parentSolanaWallet.cryptoCurrency); + + final SolanaWallet parentSolanaWallet; + + final SolContract solContract; + + String get tokenMint => solContract.address; + String get tokenName => solContract.name; + String get tokenSymbol => solContract.symbol; + int get tokenDecimals => solContract.decimals; + + @override + FilterOperation? get changeAddressFilterOperation => + parentSolanaWallet.changeAddressFilterOperation; + + @override + FilterOperation? get receivingAddressFilterOperation => + parentSolanaWallet.receivingAddressFilterOperation; + + @override + FilterOperation? get transactionFilterOperation => FilterGroup.and([ + FilterCondition.equalTo(property: r"contractAddress", value: tokenMint), + const FilterCondition.equalTo( + property: r"subType", + value: TransactionSubType.splToken, + ), + ]); + + @override + Future init() async { + await super.init(); + + parentSolanaWallet.checkClient(); + + await Future.delayed(const Duration(milliseconds: 100)); + } + + @override + Future prepareSend({required TxData txData}) async { + try { + if (txData.recipients == null || txData.recipients!.isEmpty) { + throw ArgumentError("At least one recipient is required"); + } + + if (txData.recipients!.length != 1) { + throw ArgumentError( + "SOL token transfers support only 1 recipient per transaction", + ); + } + + if (txData.amount == null || txData.amount!.raw <= BigInt.zero) { + throw ArgumentError("Send amount must be greater than zero"); + } + + final recipientAddress = txData.recipients!.first.address; + if (recipientAddress.isEmpty) { + throw ArgumentError("Recipient address cannot be empty"); + } + + try { + Ed25519HDPublicKey.fromBase58(recipientAddress); + } catch (e) { + throw ArgumentError("Invalid recipient address: $recipientAddress"); + } + + final rpcClient = parentSolanaWallet.getRpcClient(); + if (rpcClient == null) { + throw Exception("RPC client not initialized"); + } + + final keyPair = await parentSolanaWallet.getKeyPair(); + final walletAddress = keyPair.address; + + final senderTokenAccount = await _findTokenAccount( + ownerAddress: walletAddress, + mint: tokenMint, + rpcClient: rpcClient, + ); + + if (senderTokenAccount == null) { + throw Exception( + "No token account found for mint $tokenMint. " + "Please ensure you have received tokens first.", + ); + } + + try { + final accountInfo = await rpcClient.getAccountInfo( + senderTokenAccount, + encoding: Encoding.jsonParsed, + ); + if (accountInfo.value == null) { + throw Exception( + "Sender token account $senderTokenAccount not found on-chain", + ); + } + } catch (e) { + throw Exception("Failed to validate sender token account: $e"); + } + + await rpcClient.getLatestBlockhash(); + + final recipientTokenAccount = await _findOrDeriveRecipientTokenAccount( + recipientAddress: recipientAddress, + mint: tokenMint, + rpcClient: rpcClient, + ); + + if (recipientTokenAccount.isEmpty) { + throw Exception( + "Cannot determine recipient token account for mint $tokenMint. " + "Recipient may not have a token account for this mint. " + "Please ensure the recipient has initialized an Associated Token Account (ATA) first.", + ); + } + + final senderTokenAccountKey = Ed25519HDPublicKey.fromBase58( + senderTokenAccount, + ); + final recipientTokenAccountKey = Ed25519HDPublicKey.fromBase58( + recipientTokenAccount, + ); + final mintPubkey = Ed25519HDPublicKey.fromBase58(tokenMint); + + String tokenProgramId; + try { + final mintInfo = await rpcClient.getAccountInfo( + tokenMint, + encoding: Encoding.jsonParsed, + ); + if (mintInfo.value != null) { + tokenProgramId = mintInfo.value!.owner; + Logging.instance.i( + "$runtimeType prepareSend: Token program owner = $tokenProgramId for mint $tokenMint", + ); + } else { + // Fallback to SPL Token. + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + Logging.instance.w( + "$runtimeType prepareSend: Could not query mint owner, using SPL Token", + ); + } + } catch (e) { + // Fallback to SPL Token on error. + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + Logging.instance.w( + "$runtimeType prepareSend: Error querying mint owner: $e, using SPL Token", + ); + } + + final TokenProgramType tokenProgram = + tokenProgramId == Token2022Program.programId + ? TokenProgramType.token2022Program + : TokenProgramType.tokenProgram; + + final recipientAccountInfo = await rpcClient.getAccountInfo( + recipientTokenAccount, + encoding: Encoding.jsonParsed, + ); + + AssociatedTokenAccountInstruction? createAccountInstruction; + if (recipientAccountInfo.value == null) { + createAccountInstruction = + AssociatedTokenAccountInstruction.createAccount( + funder: keyPair.publicKey, + address: recipientTokenAccountKey, + owner: Ed25519HDPublicKey.fromBase58(recipientAddress), + mint: mintPubkey, + ); + } else { + try { + final accountData = recipientAccountInfo.value!; + + // Verify account is owned by token program (not System Program). + if (accountData.owner == '11111111111111111111111111111111') { + throw Exception( + "Recipient token account $recipientTokenAccount is owned by the System Program, " + "not a token program. The account may not be a valid token account.", + ); + } + } catch (e) { + if (e.toString().contains("does not exist") || + e.toString().contains("not owned by")) { + rethrow; + } + throw Exception( + "Failed to validate recipient token account: $e. " + "Ensure the recipient has initialized their token account.", + ); + } + } + + final instruction = TokenInstruction.transferChecked( + source: senderTokenAccountKey, + destination: recipientTokenAccountKey, + mint: mintPubkey, + owner: keyPair.publicKey, + decimals: tokenDecimals, + amount: txData.amount!.raw.toInt(), + tokenProgram: tokenProgram, + ); + + final instructions = [ + if (createAccountInstruction != null) createAccountInstruction, + instruction, + ]; + + final feeEstimate = + await _getEstimatedTokenTransferFee( + ownerPublicKey: keyPair.publicKey, + rpcClient: rpcClient, + instructions: instructions, + memo: txData.memo, + ) ?? + 5000; + + return txData.copyWith( + fee: Amount(rawValue: BigInt.from(feeEstimate), fractionDigits: 9), + solInstructions: instructions, + ); + } catch (e, s) { + Logging.instance.e( + "$runtimeType prepareSend failed: ", + error: e, + stackTrace: s, + ); + rethrow; + } + } + + @override + Future confirmSend({required TxData txData}) async { + try { + // Validate that prepareSend was called. + if (txData.fee == null) { + throw Exception("Transaction not prepared. Call prepareSend() first."); + } + + if (txData.recipients == null || txData.recipients!.isEmpty) { + throw ArgumentError("Transaction must have at least one recipient"); + } + + // Get wallet state. + final rpcClient = parentSolanaWallet.getRpcClient(); + if (rpcClient == null) { + throw Exception("RPC client not initialized"); + } + + final keyPair = await parentSolanaWallet.getKeyPair(); + final walletAddress = keyPair.address; + + // Get sender's token account. + final senderTokenAccount = await _findTokenAccount( + ownerAddress: walletAddress, + mint: tokenMint, + rpcClient: rpcClient, + ); + + if (senderTokenAccount == null) { + throw Exception("Token account not found"); + } + + await rpcClient.getLatestBlockhash(); + + final instructions = txData.solInstructions; + + if (instructions == null || instructions.isEmpty) { + throw Exception( + "Token transaction missing instructions. " + "Call prepareSend() first.", + ); + } + + final recipientTokenAccount = await _findOrDeriveRecipientTokenAccount( + recipientAddress: txData.recipients!.first.address, + mint: tokenMint, + rpcClient: rpcClient, + ); + + // Create message. + final message = Message( + instructions: [ + if (txData.memo != null) + MemoInstruction(signers: const [], memo: txData.memo!), + ...instructions, + ], + ); + + // Sign and broadcast tx. + final txid = await rpcClient.signAndSendTransaction(message, [keyPair]); + + if (txid.isEmpty) { + throw Exception( + "Failed to broadcast transaction: empty signature returned", + ); + } + + // Create temporary transaction (pending = unconfirmed) and save to db. + try { + // Build inputs and outputs for the transaction record. + final inputs = [ + InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: null, + addresses: [senderTokenAccount], + valueStringSats: txData.amount!.raw.toString(), + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: true, + ), + ]; + + final outputs = [ + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: "00", + valueStringSats: txData.amount!.raw.toString(), + addresses: [recipientTokenAccount], + walletOwns: false, // We don't own recipient account. + ), + ]; + + // Determine if this is a self-transfer. + final isToSelf = senderTokenAccount == recipientTokenAccount; + + // Create the temporary transaction record. + final tempTx = TransactionV2( + walletId: walletId, + blockHash: null, // CRITICAL: null indicates pending. + hash: txid, + txid: txid, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + height: null, // CRITICAL: null indicates pending. + inputs: List.unmodifiable(inputs), + outputs: List.unmodifiable(outputs), + version: -1, + type: isToSelf + ? TransactionType.sentToSelf + : TransactionType.outgoing, + subType: TransactionSubType.splToken, + otherData: jsonEncode({ + "mint": tokenMint, + "senderTokenAccount": senderTokenAccount, + "recipientTokenAccount": recipientTokenAccount, + "isCancelled": false, + "overrideFee": txData.fee!.toJsonString(), + }), + ); + + // Persist immediately to database so UI shows transaction right away. + await mainDB.updateOrPutTransactionV2s([tempTx]); + Logging.instance.i( + "$runtimeType confirmSend: Persisted pending transaction $txid to database", + ); + } catch (e, s) { + // Log persistence error but don't fail the send operation. + Logging.instance.w( + "$runtimeType confirmSend: Failed to persist pending transaction to database: ", + error: e, + stackTrace: s, + ); + } + + // Wait for confirmation. + final confirmed = await _waitForConfirmation( + signature: txid, + maxWaitSeconds: 60, + rpcClient: rpcClient, + ); + + if (!confirmed) { + Logging.instance.w( + "$runtimeType confirmSend: Transaction not confirmed after 60 seconds, " + "but signature was successfully broadcast: $txid", + ); + } + + // Return signed TxData. + return txData.copyWith(txid: txid); + } catch (e, s) { + Logging.instance.e( + "$runtimeType confirmSend failed: ", + error: e, + stackTrace: s, + ); + rethrow; + } + } + + @override + Future recover({required bool isRescan}) async { + // TODO. + } + + @override + Future updateNode() async { + await parentSolanaWallet.updateNode(); + } + + @override + Future updateTransactions() async { + try { + final rpcClient = parentSolanaWallet.getRpcClient(); + if (rpcClient == null) { + Logging.instance.w( + "$runtimeType updateTransactions: RPC client not initialized", + ); + return; + } + + final keyPair = await parentSolanaWallet.getKeyPair(); + final walletAddress = keyPair.address; + + // Find token account for this mint. + final myTokenAccount = await _findTokenAccount( + ownerAddress: walletAddress, + mint: tokenMint, + rpcClient: rpcClient, + ); + + if (myTokenAccount == null) { + return; + } + + // Fetch recent transactions for this token account. + final txListIterable = await rpcClient.getTransactionsList( + Ed25519HDPublicKey.fromBase58(myTokenAccount), + encoding: Encoding.jsonParsed, + ); + + final txList = txListIterable.toList(); + + if (txList.isEmpty) { + return; + } + + final txns = []; + int skippedCount = 0; + + for (int i = 0; i < txList.length; i++) { + final txDetails = txList[i]; + try { + // Skip failed transactions or those without metadata. + if (txDetails.meta == null) { + skippedCount++; + continue; + } + + // Cast transaction to ParsedTransaction if available. + if (txDetails.transaction is! ParsedTransaction) { + skippedCount++; + continue; + } + final parsedTx = txDetails.transaction as ParsedTransaction; + final txid = parsedTx.signatures.isNotEmpty + ? parsedTx.signatures[0] + : null; + + if (parsedTx.signatures.length > 1) { + Logging.instance.w( + "SOL $walletId found tx with " + "${parsedTx.signatures.length} signatures", + ); + } + + if (txid == null) { + skippedCount++; + continue; + } + + final splTransfers = parsedTx.message.instructions + .map((e) => e.toJson()) + .where( + (e) => + e.containsKey("parsed") && + e["program"] == "spl-token" && + (e["parsed"]["type"] == "transferChecked" || + e["parsed"]["type"] == "transfer"), + ); + + if (splTransfers.length != 1) { + Logging.instance.w( + "SOL $walletId found tx with " + "${splTransfers.length} spl transfer! Skipping...", + ); + skippedCount++; + continue; + } + final transfer = splTransfers.first; + final transferType = transfer["parsed"]["type"] as String; + final BigInt lamports; + if (transferType == "transferChecked") { + lamports = BigInt.parse( + transfer["parsed"]["info"]["tokenAmount"]["amount"].toString(), + ); + } else { + lamports = BigInt.parse( + transfer["parsed"]["info"]["amount"].toString(), + ); + } + final senderAddress = transfer["parsed"]["info"]["source"] as String; + final receiverAddress = + transfer["parsed"]["info"]["destination"] as String; + + final TransactionType txType; + + if ((senderAddress == myTokenAccount) && + (receiverAddress == senderAddress)) { + txType = TransactionType.sentToSelf; + } else if (senderAddress == myTokenAccount) { + txType = TransactionType.outgoing; + } else if (receiverAddress == myTokenAccount) { + txType = TransactionType.incoming; + } else { + // probably should never get here? If so, then this fragile parsing + // is broken which isn't surprising... + txType = TransactionType.unknown; + } + + // check for memo + final memos = parsedTx.message.instructions + .map((e) => e.toJson()) + .where( + (e) => e["parsed"] is String && e["program"] == "spl-memo", + ); + final String? memo = memos.isEmpty + ? null + : memos.first["parsed"] as String; + + // Create placeholder TransactionV2 object. + final txn = TransactionV2( + walletId: walletId, + blockHash: null, + hash: txid, + txid: txid, + timestamp: + txDetails.blockTime ?? + DateTime.now().millisecondsSinceEpoch ~/ 1000, + height: txDetails.slot, + inputs: [ + InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: null, + addresses: [senderAddress], + valueStringSats: lamports.toString(), + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: senderAddress == myTokenAccount, + ), + ], + outputs: [ + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: "00", + valueStringSats: lamports.toString(), + addresses: [receiverAddress], + walletOwns: receiverAddress == myTokenAccount, + ), + ], + version: txDetails.version?.version?.toInt() ?? -1, + type: txType, + subType: TransactionSubType.splToken, + otherData: jsonEncode({ + TxV2OdKeys.contractAddress: tokenMint, + TxV2OdKeys.isCancelled: (txDetails.meta!.err != null), + TxV2OdKeys.overrideFee: txDetails.meta!.fee.toString(), + if (memo != null) TxV2OdKeys.memo: memo, + }), + ); + + txns.add(txn); + } catch (e, s) { + Logging.instance.w( + "$runtimeType updateTransactions: Failed to parse transaction at index $i", + error: e, + stackTrace: s, + ); + skippedCount++; + continue; + } + } + + // Persist all transactions if any were parsed. + if (txns.isNotEmpty) { + await mainDB.updateOrPutTransactionV2s(txns); + Logging.instance.i( + "$runtimeType updateTransactions: Synced ${txns.length} transactions (skipped $skippedCount)", + ); + } + } catch (e, s) { + Logging.instance.e( + "$runtimeType updateTransactions FAILED: ", + error: e, + stackTrace: s, + ); + } + } + + @override + Future updateBalance() async { + try { + final rpcClient = parentSolanaWallet.getRpcClient(); + if (rpcClient == null) { + return; + } + + final keyPair = await parentSolanaWallet.getKeyPair(); + final walletAddress = keyPair.address; + + final senderTokenAccount = await _findTokenAccount( + ownerAddress: walletAddress, + mint: tokenMint, + rpcClient: rpcClient, + ); + + if (senderTokenAccount == null) { + return; + } + + final tokenApi = SolanaTokenAPI(); + tokenApi.initializeRpcClient(rpcClient); + + final balanceResponse = await tokenApi.getTokenAccountBalance( + senderTokenAccount, + ); + + if (balanceResponse.isError) { + Logging.instance.w( + "$runtimeType updateBalance failed: ${balanceResponse.exception}", + ); + return; + } + + if (balanceResponse.value != null) { + final info = await mainDB.isar.walletSolanaTokenInfo + .where() + .walletIdTokenAddressEqualTo(walletId, tokenMint) + .findFirst(); + + if (info != null) { + final balanceAmount = Amount( + rawValue: balanceResponse.value!, + fractionDigits: tokenDecimals, + ); + + final balance = Balance( + total: balanceAmount, + spendable: balanceAmount, + blockedTotal: Amount( + rawValue: BigInt.zero, + fractionDigits: tokenDecimals, + ), + pendingSpendable: Amount( + rawValue: BigInt.zero, + fractionDigits: tokenDecimals, + ), + ); + + await info.updateCachedBalance(balance, isar: mainDB.isar); + } + } + } catch (e, s) { + Logging.instance.e( + "$runtimeType updateBalance error: ", + error: e, + stackTrace: s, + ); + } + } + + @override + Future updateUTXOs() async { + // Not applicable for Solana tokens. + return true; + } + + @override + Future updateChainHeight() async { + await parentSolanaWallet.updateChainHeight(); + } + + @override + Future refresh() async { + await parentSolanaWallet.refresh(); + await updateBalance(); + await updateTransactions(); + } + + @override + Future estimateFeeFor(Amount amount, BigInt feeRate) async { + return parentSolanaWallet.estimateFeeFor(amount, feeRate); + } + + @override + Future get fees async { + return parentSolanaWallet.fees; + } + + @override + Future pingCheck() async { + return parentSolanaWallet.pingCheck(); + } + + @override + Future checkSaveInitialReceivingAddress() async { + await parentSolanaWallet.checkSaveInitialReceivingAddress(); + } + + Future _findTokenAccount({ + required String ownerAddress, + required String mint, + required RpcClient rpcClient, + }) async { + try { + final result = await rpcClient.getTokenAccountsByOwner( + ownerAddress, + TokenAccountsFilter.byMint(mint), + encoding: Encoding.jsonParsed, + ); + + if (result.value.isEmpty) { + Logging.instance.w( + "$runtimeType _findTokenAccount: No token account found for " + "owner=$ownerAddress, mint=$mint", + ); + return null; + } + + final tokenAccountAddress = result.value.first.pubkey; + Logging.instance.i( + "$runtimeType _findTokenAccount: Found token account $tokenAccountAddress " + "for owner=$ownerAddress, mint=$mint", + ); + return tokenAccountAddress; + } catch (e) { + Logging.instance.w("$runtimeType _findTokenAccount error: $e"); + return null; + } + } + + Future _findOrDeriveRecipientTokenAccount({ + required String recipientAddress, + required String mint, + required RpcClient rpcClient, + }) async { + // First, try to find an existing token account + final existingAccount = await _findTokenAccount( + ownerAddress: recipientAddress, + mint: mint, + rpcClient: rpcClient, + ); + + if (existingAccount != null) { + Logging.instance.i( + "$runtimeType Found existing token account for recipient: $existingAccount", + ); + return existingAccount; + } + + // If no existing account found, try to derive the ATA + Logging.instance.i( + "$runtimeType No existing token account found, deriving ATA for recipient", + ); + + return await _deriveAtaAddress( + ownerAddress: recipientAddress, + mint: mint, + rpcClient: rpcClient, + ); + } + + Future _deriveAtaAddress({ + required String ownerAddress, + required String mint, + required RpcClient rpcClient, + }) async { + final ownerPubkey = Ed25519HDPublicKey.fromBase58(ownerAddress); + final mintPubkey = Ed25519HDPublicKey.fromBase58(mint); + + final tokenApi = SolanaTokenAPI(); + tokenApi.initializeRpcClient(rpcClient); + + String tokenProgramId; + try { + final mintInfo = await rpcClient.getAccountInfo( + mint, + encoding: Encoding.jsonParsed, + ); + if (mintInfo.value != null) { + tokenProgramId = mintInfo.value!.owner; + } else { + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + } + } catch (e) { + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + } + + final tokenProgramPubkey = Ed25519HDPublicKey.fromBase58(tokenProgramId); + + const associatedTokenProgramId = + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'; + final associatedTokenProgramPubkey = Ed25519HDPublicKey.fromBase58( + associatedTokenProgramId, + ); + + final seeds = [ + ownerPubkey.bytes, + tokenProgramPubkey.bytes, + mintPubkey.bytes, + ]; + + final ataAddress = await Ed25519HDPublicKey.findProgramAddress( + seeds: seeds, + programId: associatedTokenProgramPubkey, + ); + + final ataBase58 = ataAddress.toBase58(); + + return ataBase58; + } + + Future _getEstimatedTokenTransferFee({ + required Ed25519HDPublicKey ownerPublicKey, + required RpcClient rpcClient, + required List instructions, + required String? memo, + }) async { + try { + // Get latest blockhash for message compilation. + final latestBlockhash = await rpcClient.getLatestBlockhash(); + + // Compile the message with the blockhash. + final compiledMessage = + Message( + instructions: [ + if (memo != null) MemoInstruction(signers: const [], memo: memo), + ...instructions, + ], + ).compile( + recentBlockhash: latestBlockhash.value.blockhash, + feePayer: ownerPublicKey, + ); + + // Get the fee for this compiled message. + final feeEstimate = await rpcClient.getFeeForMessage( + base64Encode(compiledMessage.toByteArray().toList()), + commitment: Commitment.confirmed, + ); + + if (feeEstimate != null) { + Logging.instance.i( + "$runtimeType Estimated token transfer fee: $feeEstimate lamports (from RPC)", + ); + return feeEstimate; + } + + Logging.instance.w("$runtimeType getFeeForMessage returned null"); + return null; + } catch (e) { + Logging.instance.w( + "$runtimeType _getEstimatedTokenTransferFee error: $e", + ); + return null; + } + } + + Future _waitForConfirmation({ + required String signature, + required int maxWaitSeconds, + required RpcClient rpcClient, + }) async { + final startTime = DateTime.now(); + + while (true) { + try { + final status = await rpcClient.getSignatureStatuses([ + signature, + ], searchTransactionHistory: true); + + if (status.value.isNotEmpty) { + final txStatus = status.value.first; + + // Check if transaction failed + if (txStatus?.err != null) { + Logging.instance.e( + "$runtimeType Transaction failed: ${txStatus?.err}", + ); + return false; + } + + // Check if transaction confirmed + if (txStatus?.confirmationStatus == Commitment.confirmed || + txStatus?.confirmationStatus == Commitment.finalized) { + Logging.instance.i( + "$runtimeType Transaction confirmed: $signature", + ); + return true; + } + } + } catch (e) { + Logging.instance.w( + "$runtimeType Error checking transaction confirmation: $e", + ); + } + + // Check timeout + final elapsed = DateTime.now().difference(startTime).inSeconds; + if (elapsed > maxWaitSeconds) { + Logging.instance.w( + "$runtimeType Transaction confirmation timeout after $maxWaitSeconds seconds", + ); + return false; + } + + // Wait before next check (2 seconds) + await Future.delayed(const Duration(seconds: 2)); + } + } +} diff --git a/lib/wallets/wallet/impl/wownero_wallet.dart b/lib/wallets/wallet/impl/wownero_wallet.dart index 691f601266..86f018381e 100644 --- a/lib/wallets/wallet/impl/wownero_wallet.dart +++ b/lib/wallets/wallet/impl/wownero_wallet.dart @@ -5,14 +5,14 @@ import 'package:compat/compat.dart' as lib_monero_compat; import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/enums/fee_rate_type_enum.dart'; -import '../../../wl_gen/interfaces/cs_monero_interface.dart'; import '../../../wl_gen/interfaces/cs_salvium_interface.dart' show WrappedWallet; +import '../../../wl_gen/interfaces/cs_wownero_interface.dart'; import '../../crypto_currency/crypto_currency.dart'; import '../../models/tx_data.dart'; -import '../intermediate/lib_monero_wallet.dart'; +import '../intermediate/lib_wownero_wallet.dart'; -class WowneroWallet extends LibMoneroWallet { +class WowneroWallet extends LibWowneroWallet { WowneroWallet(CryptoCurrencyNetwork network) : super(Wownero(network), lib_monero_compat.WalletType.wownero); @@ -66,7 +66,7 @@ class WowneroWallet extends LibMoneroWallet { // unsure why this delay? await Future.delayed(const Duration(milliseconds: 500)); } catch (e) { - approximateFee = await csMonero.estimateFee( + approximateFee = await csWownero.estimateFee( feeRate.toInt(), amount.raw, wallet: wallet!, @@ -86,19 +86,13 @@ class WowneroWallet extends LibMoneroWallet { } @override - bool walletExists(String path) => - csMonero.walletExists(path, csCoin: CsCoin.wownero); + bool walletExists(String path) => csWownero.walletExists(path); @override Future loadWallet({ required String path, required String password, - }) => csMonero.loadWallet( - walletId, - path: path, - password: password, - csCoin: CsCoin.wownero, - ); + }) => csWownero.loadWallet(walletId, path: path, password: password); @override Future getCreatedWallet({ @@ -106,8 +100,7 @@ class WowneroWallet extends LibMoneroWallet { required String password, required int wordCount, required String seedOffset, - }) => csMonero.getCreatedWallet( - csCoin: CsCoin.wownero, + }) => csWownero.getCreatedWallet( path: path, password: password, wordCount: wordCount, @@ -121,13 +114,12 @@ class WowneroWallet extends LibMoneroWallet { required String mnemonic, required String seedOffset, int height = 0, - }) => csMonero.getRestoredWallet( + }) => csWownero.getRestoredWallet( path: path, password: password, mnemonic: mnemonic, height: height, seedOffset: seedOffset, - csCoin: CsCoin.wownero, walletId: walletId, ); @@ -138,9 +130,8 @@ class WowneroWallet extends LibMoneroWallet { required String address, required String privateViewKey, int height = 0, - }) => csMonero.getRestoredFromViewKeyWallet( + }) => csWownero.getRestoredFromViewKeyWallet( walletId: walletId, - csCoin: CsCoin.wownero, path: path, password: password, address: address, diff --git a/lib/wallets/wallet/impl/xelis_wallet.dart b/lib/wallets/wallet/impl/xelis_wallet.dart index 9423b06973..66b551bb8e 100644 --- a/lib/wallets/wallet/impl/xelis_wallet.dart +++ b/lib/wallets/wallet/impl/xelis_wallet.dart @@ -57,7 +57,7 @@ class XelisWallet extends LibXelisWallet { seed: mnemonic, network: cryptoCurrency.network, precomputedTablesPath: tablePath, - l1Low: tableState.currentSize.isLow, + stack_l1Low: tableState.currentSize.isLow, ); await secureStorageInterface.write( @@ -91,7 +91,7 @@ class XelisWallet extends LibXelisWallet { password: password, network: cryptoCurrency.network, precomputedTablesPath: tablePath, - l1Low: tableState.currentSize.isLow, + stack_l1Low: tableState.currentSize.isLow, ); final mnemonic = await libXelis.getSeed(wallet); @@ -123,7 +123,7 @@ class XelisWallet extends LibXelisWallet { password: password!, network: cryptoCurrency.network, precomputedTablesPath: tablePath, - l1Low: tableState.currentSize.isLow, + stack_l1Low: tableState.currentSize.isLow, ); await _finishInit(); diff --git a/lib/wallets/wallet/intermediate/cryptonote_wallet.dart b/lib/wallets/wallet/intermediate/cryptonote_wallet.dart index 0d32e07a3c..62a08ce3a1 100644 --- a/lib/wallets/wallet/intermediate/cryptonote_wallet.dart +++ b/lib/wallets/wallet/intermediate/cryptonote_wallet.dart @@ -1,10 +1,65 @@ +import 'package:meta/meta.dart'; + +import '../../../models/input.dart'; +import '../../../models/keys/cw_key_data.dart'; +import '../../../wl_gen/interfaces/cs_monero_interface.dart' + show CsOutput, CsPendingTransaction, CsRecipient; +import '../../../wl_gen/interfaces/cs_salvium_interface.dart'; import '../../crypto_currency/intermediate/cryptonote_currency.dart'; -import '../wallet.dart'; import '../wallet_mixin_interfaces/coin_control_interface.dart'; import '../wallet_mixin_interfaces/mnemonic_interface.dart'; import 'external_wallet.dart'; -abstract class CryptonoteWallet extends ExternalWallet +abstract class CryptonoteWallet + extends ExternalWallet with MnemonicInterface, CoinControlInterface { CryptonoteWallet(super.currency); + + WrappedWallet? wallet; + + double highestPercentCached = 0; + int currentKnownChainHeight = 0; + + @mustCallSuper + @override + Future init({bool? isRestore, int? wordCount}); + + Future getKeys(); + + Future getTxKeyFor({required String txid}); + + Future<(String, String)> + hackToCreateNewViewOnlyWalletDataFromNewlyCreatedWalletThisFunctionShouldNotBeCalledUnlessYouKnowWhatYouAreDoing(); + + void setRefreshFromBlockHeight(int newHeight); + + Future getRefreshFromBlockHeight(); + + Future internalGetAddress({ + required int accountIndex, + required int addressIndex, + }); + + Future internalGetUnlockedBalance({int accountIndex = 0}); + Future> internalGetOutputs({ + bool refresh = false, + bool includeSpent = false, + }); + + Future internalCreateTx({ + required CsRecipient output, + required int priority, + required bool sweep, + List? preferredInputs, + required int accountIndex, + required int minConfirms, + required int currentHeight, + }); + + Future internalCommitTx(CsPendingTransaction tx); + + // tx prio forwarding + int getTxPriorityHigh(); + int getTxPriorityMedium(); + int getTxPriorityNormal(); } diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index e5f91c4ff8..6c0c49884e 100644 --- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart @@ -35,7 +35,8 @@ import '../../../utilities/enums/fee_rate_type_enum.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/stack_file_system.dart'; import '../../../wl_gen/interfaces/cs_monero_interface.dart'; -import '../../../wl_gen/interfaces/cs_salvium_interface.dart'; +import '../../../wl_gen/interfaces/cs_salvium_interface.dart' + show WrappedWallet; import '../../crypto_currency/intermediate/cryptonote_currency.dart'; import '../../isar/models/wallet_info.dart'; import '../../models/tx_data.dart'; @@ -51,8 +52,6 @@ abstract class LibMoneroWallet @override int get isarTransactionVersion => 2; - WrappedWallet? wallet; - LibMoneroWallet(super.currency, this.compatType) { final bus = GlobalEventBus.instance; @@ -135,8 +134,6 @@ abstract class LibMoneroWallet bool _txRefreshLock = false; int _lastCheckedHeight = -1; int _txCount = 0; - int currentKnownChainHeight = 0; - double highestPercentCached = 0; Future loadWallet({ required String path, @@ -170,7 +167,8 @@ abstract class LibMoneroWallet bool walletExists(String path); - String getTxKeyFor({required String txid}) { + @override + Future getTxKeyFor({required String txid}) { if (wallet == null) { throw Exception("Cannot get tx key in uninitialized libMoneroWallet"); } @@ -220,7 +218,7 @@ abstract class LibMoneroWallet Address? currentAddress = await getCurrentReceivingAddress(); if (currentAddress == null) { - currentAddress = addressFor(index: 0); + currentAddress = await addressFor(index: 0); await mainDB.updateOrPutAddresses([currentAddress]); } if (info.cachedReceivingAddress != currentAddress.value) { @@ -233,14 +231,14 @@ abstract class LibMoneroWallet if (wasNull) { try { _setSyncStatus(lib_monero_compat.ConnectingSyncStatus()); - csMonero.startSyncing(wallet!); + await csMonero.startSyncing(wallet!); } catch (_) { _setSyncStatus(lib_monero_compat.FailedSyncStatus()); // TODO log } } _setListener(); - csMonero.startListeners(wallet!); + await csMonero.startListeners(wallet!); csMonero.startAutoSaving(wallet!); unawaited(refresh()); @@ -269,8 +267,8 @@ abstract class LibMoneroWallet await csMonero.save(wallet!); } - Address addressFor({required int index, int account = 0}) { - final address = csMonero.getAddress( + Future
addressFor({required int index, int account = 0}) async { + final address = await csMonero.getAddress( wallet!, accountIndex: account, addressIndex: index, @@ -293,6 +291,7 @@ abstract class LibMoneroWallet return newReceivingAddress; } + @override Future getKeys() async { final oldInfo = getLibMoneroWalletInfo(walletId); if (wallet == null || (oldInfo != null && oldInfo.name != walletId)) { @@ -301,10 +300,10 @@ abstract class LibMoneroWallet try { return CWKeyData( walletId: walletId, - publicViewKey: csMonero.getPublicViewKey(wallet!), - privateViewKey: csMonero.getPrivateViewKey(wallet!), - publicSpendKey: csMonero.getPublicSpendKey(wallet!), - privateSpendKey: csMonero.getPrivateSpendKey(wallet!), + publicViewKey: await csMonero.getPublicViewKey(wallet!), + privateViewKey: await csMonero.getPrivateViewKey(wallet!), + publicSpendKey: await csMonero.getPublicSpendKey(wallet!), + privateSpendKey: await csMonero.getPrivateSpendKey(wallet!), ); } catch (e, s) { Logging.instance.f("getKeys failed: ", error: e, stackTrace: s); @@ -318,6 +317,7 @@ abstract class LibMoneroWallet } } + @override Future<(String, String)> hackToCreateNewViewOnlyWalletDataFromNewlyCreatedWalletThisFunctionShouldNotBeCalledUnlessYouKnowWhatYouAreDoing() async { final path = await pathForWallet(name: walletId, type: compatType); @@ -330,7 +330,10 @@ abstract class LibMoneroWallet throw Exception("Password not found $e, $s"); } wallet = await loadWallet(path: path, password: password); - return (csMonero.getAddress(wallet!), csMonero.getPrivateViewKey(wallet!)); + return ( + await csMonero.getAddress(wallet!), + await csMonero.getPrivateViewKey(wallet!), + ); } @override @@ -338,7 +341,7 @@ abstract class LibMoneroWallet final path = await pathForWallet(name: walletId, type: compatType); if (!(walletExists(path)) && isRestore != true) { if (wordCount == null) { - throw Exception("Missing word count for new xmr/wow wallet!"); + throw Exception("Missing word count for new xmr wallet!"); } try { final password = generatePassword(); @@ -355,24 +358,28 @@ abstract class LibMoneroWallet ); await info.updateRestoreHeight( - newRestoreHeight: csMonero.getRefreshFromBlockHeight(wallet), + newRestoreHeight: await csMonero.getRefreshFromBlockHeight(wallet), isar: mainDB.isar, ); - // special case for xmr/wow. Normally mnemonic + passphrase is saved + // special case for xmr. Normally mnemonic + passphrase is saved // before wallet.init() is called await secureStorageInterface.write( key: Wallet.mnemonicKey(walletId: walletId), - value: csMonero.getSeed(wallet), + value: await csMonero.getSeed(wallet), ); await secureStorageInterface.write( key: Wallet.mnemonicPassphraseKey(walletId: walletId), value: "", ); + + this.wallet = wallet; + await updateNode(); + await csMonero.close(wallet, save: true); + this.wallet = null; } catch (e, s) { Logging.instance.f("", error: e, stackTrace: s); } - await updateNode(); } return super.init(); @@ -386,9 +393,9 @@ abstract class LibMoneroWallet await mainDB.deleteWalletBlockchainData(walletId); highestPercentCached = 0; - unawaited(csMonero.rescanBlockchain(wallet!)); - csMonero.startSyncing(wallet!); - // unawaited(save()); + await csMonero.rescanBlockchain(wallet!); + await csMonero.startSyncing(wallet!); + unawaited(save()); }); unawaited(refresh()); return; @@ -447,7 +454,7 @@ abstract class LibMoneroWallet walletId: walletId, derivationIndex: 0, derivationPath: null, - value: csMonero.getAddress(this.wallet!), + value: await csMonero.getAddress(this.wallet!), publicKey: [], type: AddressType.cryptonote, subType: AddressSubType.receiving, @@ -466,11 +473,11 @@ abstract class LibMoneroWallet _setListener(); // libMoneroWallet?.setRecoveringFromSeed(isRecovery: true); - unawaited(csMonero.rescanBlockchain(wallet!)); - csMonero.startSyncing(wallet!); + await csMonero.rescanBlockchain(wallet!); + await csMonero.startSyncing(wallet!); // await save(); - csMonero.startListeners(wallet!); + await csMonero.startListeners(wallet!); csMonero.startAutoSaving(wallet!); } catch (e, s) { Logging.instance.e( @@ -499,7 +506,7 @@ abstract class LibMoneroWallet Future updateNode() async { final node = getCurrentNode(); - if (_torNodeMismatchGuard(node)) { + if (await _torNodeMismatchGuard(node)) { throw Exception("TOR – clearnet mismatch"); } @@ -544,8 +551,8 @@ abstract class LibMoneroWallet : "${proxy.host.address}:${proxy.port}", ); } - csMonero.startSyncing(wallet!); - csMonero.startListeners(wallet!); + await csMonero.startSyncing(wallet!); + await csMonero.startListeners(wallet!); csMonero.startAutoSaving(wallet!); _setSyncStatus(lib_monero_compat.ConnectedSyncStatus()); @@ -701,7 +708,7 @@ abstract class LibMoneroWallet Future get availableBalance async { try { return Amount( - rawValue: csMonero.getUnlockedBalance(wallet!)!, + rawValue: await csMonero.getUnlockedBalance(wallet!), fractionDigits: cryptoCurrency.fractionDigits, ); } catch (_) { @@ -711,28 +718,12 @@ abstract class LibMoneroWallet Future get totalBalance async { try { - final full = csMonero.getBalance(wallet!); - if (full != null) { - return Amount( - rawValue: full, - fractionDigits: cryptoCurrency.fractionDigits, - ); - } else { - final transactions = await csMonero.getAllTxs(wallet!, refresh: true); - BigInt transactionBalance = BigInt.zero; - for (final tx in transactions) { - if (!tx.isSpend) { - transactionBalance += tx.amount; - } else { - transactionBalance += -tx.amount - tx.fee; - } - } + final full = await csMonero.getBalance(wallet!); - return Amount( - rawValue: transactionBalance, - fractionDigits: cryptoCurrency.fractionDigits, - ); - } + return Amount( + rawValue: full, + fractionDigits: cryptoCurrency.fractionDigits, + ); } catch (_) { return info.cachedBalance.total; } @@ -740,13 +731,14 @@ abstract class LibMoneroWallet @override Future exit() async { - Logging.instance.i("exit called on $wallet!"); + Logging.instance.i("exit called on monero $walletId!"); if (wallet != null) { csMonero.stopAutoSaving(wallet!); - csMonero.stopListeners(wallet!); - csMonero.stopSyncing(wallet!); + await csMonero.stopListeners(wallet!); + await csMonero.stopSyncing(wallet!); await csMonero.save(wallet!); } + Logging.instance.i("exit call completed monero $walletId!"); } Future pathForWalletDir({ @@ -830,7 +822,7 @@ abstract class LibMoneroWallet if (wallet == null) { Logging.instance.w( "onUTXOsChanged triggered while cs_monero wallet is null. If this " - "occurs while not in a monero/wownero wallet this warning can be " + "occurs while not in a monero wallet this warning can be " "ignored.", ); return; @@ -1007,7 +999,7 @@ abstract class LibMoneroWallet } } - bool _torNodeMismatchGuard(NodeModel node) { + Future _torNodeMismatchGuard(NodeModel node) async { _canPing = true; // Reset. final bool mismatch = @@ -1018,8 +1010,8 @@ abstract class LibMoneroWallet _canPing = false; if (wallet != null) { csMonero.stopAutoSaving(wallet!); - csMonero.stopListeners(wallet!); - csMonero.stopSyncing(wallet!); + await csMonero.stopListeners(wallet!); + await csMonero.stopSyncing(wallet!); } _setSyncStatus(lib_monero_compat.FailedSyncStatus()); } @@ -1116,36 +1108,75 @@ abstract class LibMoneroWallet // Awaiting this lock could be dangerous. // Since refresh is periodic (generally) if (refreshMutex.isLocked) { + Logging.instance.t( + "$runtimeType refresh() refreshMutex.isLocked=true, returning...", + ); return; } + // this acquire should be almost instant due to above check. + // Slight possibility of race but should be irrelevant + Logging.instance.t( + "$runtimeType refresh() refreshMutex.acquire() waiting...", + ); + await refreshMutex.acquire(); + Logging.instance.t( + "$runtimeType refresh() refreshMutex.acquire() acquired!", + ); + + Logging.instance.t("$runtimeType refresh() final node = getCurrentNode();"); final node = getCurrentNode(); - if (_torNodeMismatchGuard(node)) { + Logging.instance.i( + "$runtimeType refresh() await _torNodeMismatchGuard(node)", + ); + if (await _torNodeMismatchGuard(node)) { throw Exception("TOR – clearnet mismatch"); } - // this acquire should be almost instant due to above check. - // Slight possibility of race but should be irrelevant - await refreshMutex.acquire(); - - csMonero.startSyncing(wallet!); + Logging.instance.t( + "$runtimeType refresh() it csMonero.startSyncing(wallet!);", + ); + await csMonero.startSyncing(wallet!); + Logging.instance.t( + "$runtimeType refresh() _setSyncStatus(lib_monero_compat.StartingSyncStatus());", + ); _setSyncStatus(lib_monero_compat.StartingSyncStatus()); + Logging.instance.t("$runtimeType refresh() await updateTransactions();"); await updateTransactions(); + Logging.instance.t("$runtimeType refresh() await updateBalance();"); await updateBalance(); + Logging.instance.t( + "$runtimeType refresh() await checkReceivingAddressForTransactions();", + ); if (info.otherData[WalletInfoKeys.reuseAddress] != true) { await checkReceivingAddressForTransactions(); } + Logging.instance.t( + "$runtimeType refresh() refreshMutex.isLocked=${refreshMutex.isLocked} pre release.", + ); if (refreshMutex.isLocked) { refreshMutex.release(); + Logging.instance.t( + "$runtimeType refresh() refreshMutex.isLocked manually released.", + ); } + Logging.instance.t( + "$runtimeType refresh() wallet != null && await csMonero.isSynced(wallet!)", + ); final synced = wallet != null && await csMonero.isSynced(wallet!); + Logging.instance.t( + "$runtimeType refresh() wallet != null && await csMonero.isSynced(wallet!) == $synced", + ); if (synced) { + Logging.instance.t( + "$runtimeType refresh() _setSyncStatus(lib_monero_compat.SyncedSyncStatus());", + ); _setSyncStatus(lib_monero_compat.SyncedSyncStatus()); } } @@ -1159,7 +1190,7 @@ abstract class LibMoneroWallet ? 0 : currentReceiving.derivationIndex + 1; - final newReceivingAddress = addressFor(index: newReceivingIndex); + final newReceivingAddress = await addressFor(index: newReceivingIndex); // Add that new receiving address await mainDB.putAddress(newReceivingAddress); @@ -1213,7 +1244,7 @@ abstract class LibMoneroWallet final newReceivingIndex = curIndex + 1; // Use new index to derive a new receiving address - final newReceivingAddress = addressFor(index: newReceivingIndex); + final newReceivingAddress = await addressFor(index: newReceivingIndex); final existing = await mainDB .getAddresses(walletId) @@ -1416,6 +1447,102 @@ abstract class LibMoneroWallet } } + @override + Future getRefreshFromBlockHeight() => wallet == null + ? throw Exception( + "Cannot getRefreshFromBlockHeight when wallet is not open", + ) + : csMonero.getRefreshFromBlockHeight(wallet!); + + @override + int getTxPriorityHigh() => csMonero.getTxPriorityHigh(); + + @override + int getTxPriorityMedium() => csMonero.getTxPriorityMedium(); + + @override + int getTxPriorityNormal() => csMonero.getTxPriorityNormal(); + + @override + Future internalCommitTx(CsPendingTransaction tx) { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + + return csMonero.commitTx(wallet!, tx); + } + + @override + Future internalCreateTx({ + required CsRecipient output, + required int priority, + required bool sweep, + List? preferredInputs, + required int accountIndex, + required int minConfirms, + required int currentHeight, + }) { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + return csMonero.createTx( + wallet!, + output: output, + priority: priority, + sweep: sweep, + accountIndex: accountIndex, + minConfirms: minConfirms, + currentHeight: currentHeight, + preferredInputs: preferredInputs, + ); + } + + @override + Future internalGetAddress({ + required int accountIndex, + required int addressIndex, + }) { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + return csMonero.getAddress( + wallet!, + accountIndex: accountIndex, + addressIndex: addressIndex, + ); + } + + @override + Future> internalGetOutputs({ + bool refresh = false, + bool includeSpent = false, + }) { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + return csMonero.getOutputs( + wallet!, + refresh: refresh, + includeSpent: includeSpent, + ); + } + + @override + Future internalGetUnlockedBalance({int accountIndex = 0}) { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + return csMonero.getUnlockedBalance(wallet!, accountIndex: accountIndex); + } + + @override + void setRefreshFromBlockHeight(int newHeight) { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + csMonero.setRefreshFromBlockHeight(wallet!, newHeight); + } + // ============== View only ================================================== @override @@ -1463,7 +1590,7 @@ abstract class LibMoneroWallet walletId: walletId, derivationIndex: 0, derivationPath: null, - value: csMonero.getAddress(this.wallet!), + value: await csMonero.getAddress(this.wallet!), publicKey: [], type: AddressType.cryptonote, subType: AddressSubType.receiving, @@ -1478,11 +1605,11 @@ abstract class LibMoneroWallet await updateNode(); _setListener(); - unawaited(csMonero.rescanBlockchain(this.wallet!)); - csMonero.startSyncing(this.wallet!); + await csMonero.rescanBlockchain(this.wallet!); + await csMonero.startSyncing(this.wallet!); // await save(); - csMonero.startListeners(this.wallet!); + await csMonero.startListeners(this.wallet!); csMonero.startAutoSaving(this.wallet!); } catch (e, s) { Logging.instance.e( diff --git a/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart b/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart index 4081c9012b..03e11f74c0 100644 --- a/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_salvium_wallet.dart @@ -32,7 +32,8 @@ import '../../../utilities/amount/amount.dart'; import '../../../utilities/enums/fee_rate_type_enum.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/stack_file_system.dart'; -import '../../../wl_gen/interfaces/cs_monero_interface.dart'; +import '../../../wl_gen/interfaces/cs_monero_interface.dart' + show CsWalletListener, CsOutput, CsRecipient, CsPendingTransaction; import '../../../wl_gen/interfaces/cs_salvium_interface.dart'; import '../../crypto_currency/intermediate/cryptonote_currency.dart'; import '../../isar/models/wallet_info.dart'; @@ -49,8 +50,6 @@ abstract class LibSalviumWallet @override int get isarTransactionVersion => 2; - WrappedWallet? wallet; - LibSalviumWallet(super.currency) { final bus = GlobalEventBus.instance; @@ -131,8 +130,6 @@ abstract class LibSalviumWallet bool _txRefreshLock = false; int _lastCheckedHeight = -1; int _txCount = 0; - int currentKnownChainHeight = 0; - double highestPercentCached = 0; Future loadWallet({ required String path, @@ -166,7 +163,8 @@ abstract class LibSalviumWallet bool walletExists(String path); - String getTxKeyFor({required String txid}) { + @override + Future getTxKeyFor({required String txid}) async { if (wallet == null) { throw Exception("Cannot get tx key in uninitialized libSalviumWallet"); } @@ -274,6 +272,7 @@ abstract class LibSalviumWallet return newReceivingAddress; } + @override Future getKeys() async { if (wallet == null) { return null; @@ -298,6 +297,7 @@ abstract class LibSalviumWallet } } + @override Future<(String, String)> hackToCreateNewViewOnlyWalletDataFromNewlyCreatedWalletThisFunctionShouldNotBeCalledUnlessYouKnowWhatYouAreDoing() async { final path = await pathForWallet(name: walletId); @@ -350,10 +350,14 @@ abstract class LibSalviumWallet key: Wallet.mnemonicPassphraseKey(walletId: walletId), value: "", ); + + this.wallet = wallet; + await updateNode(); + await csSalvium.close(wallet, save: true); + this.wallet = null; } catch (e, s) { Logging.instance.f("", error: e, stackTrace: s); } - await updateNode(); } return super.init(); @@ -528,9 +532,9 @@ abstract class LibSalviumWallet csSalvium.startListeners(wallet!); csSalvium.startAutoSaving(wallet!); - // _setSyncStatus(ConnectedSyncStatus()); + _setSyncStatus(ConnectedSyncStatus()); } catch (e, s) { - // _setSyncStatus(FailedSyncStatus()); + _setSyncStatus(FailedSyncStatus()); Logging.instance.e( "Exception caught in $runtimeType.updateNode(): ", error: e, @@ -1409,6 +1413,102 @@ abstract class LibSalviumWallet } } + @override + Future getRefreshFromBlockHeight() async => wallet == null + ? throw Exception( + "Cannot getRefreshFromBlockHeight when wallet is not open", + ) + : csSalvium.getRefreshFromBlockHeight(wallet!); + + @override + int getTxPriorityHigh() => csSalvium.getTxPriorityHigh(); + + @override + int getTxPriorityMedium() => csSalvium.getTxPriorityMedium(); + + @override + int getTxPriorityNormal() => csSalvium.getTxPriorityNormal(); + + @override + Future internalCommitTx(CsPendingTransaction tx) { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + + return csSalvium.commitTx(wallet!, tx); + } + + @override + Future internalCreateTx({ + required CsRecipient output, + required int priority, + required bool sweep, + List? preferredInputs, + required int accountIndex, + required int minConfirms, + required int currentHeight, + }) { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + return csSalvium.createTx( + wallet!, + output: output, + priority: priority, + sweep: sweep, + accountIndex: accountIndex, + minConfirms: minConfirms, + currentHeight: currentHeight, + preferredInputs: preferredInputs, + ); + } + + @override + Future internalGetAddress({ + required int accountIndex, + required int addressIndex, + }) async { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + return csSalvium.getAddress( + wallet!, + accountIndex: accountIndex, + addressIndex: addressIndex, + ); + } + + @override + Future> internalGetOutputs({ + bool refresh = false, + bool includeSpent = false, + }) { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + return csSalvium.getOutputs( + wallet!, + refresh: refresh, + includeSpent: includeSpent, + ); + } + + @override + Future internalGetUnlockedBalance({int accountIndex = 0}) async { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + return csSalvium.getUnlockedBalance(wallet!, accountIndex: accountIndex)!; + } + + @override + void setRefreshFromBlockHeight(int newHeight) { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + csSalvium.setRefreshFromBlockHeight(wallet!, newHeight); + } + // ============== View only ================================================== @override diff --git a/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart b/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart new file mode 100644 index 0000000000..5ebd2191a3 --- /dev/null +++ b/lib/wallets/wallet/intermediate/lib_wownero_wallet.dart @@ -0,0 +1,1610 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:compat/compat.dart' as lib_monero_compat; +import 'package:isar_community/isar.dart'; +import 'package:mutex/mutex.dart'; +import 'package:stack_wallet_backup/generate_password.dart'; + +import '../../../app_config.dart'; +import '../../../db/hive/db.dart'; +import '../../../models/balance.dart'; +import '../../../models/input.dart'; +import '../../../models/isar/models/blockchain_data/address.dart'; +import '../../../models/isar/models/blockchain_data/transaction.dart'; +import '../../../models/isar/models/blockchain_data/utxo.dart'; +import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; +import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; +import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import '../../../models/keys/cw_key_data.dart'; +import '../../../models/keys/view_only_wallet_data.dart'; +import '../../../models/node_model.dart'; +import '../../../models/paymint/fee_object_model.dart'; +import '../../../services/event_bus/events/global/blocks_remaining_event.dart'; +import '../../../services/event_bus/events/global/refresh_percent_changed_event.dart'; +import '../../../services/event_bus/events/global/tor_connection_status_changed_event.dart'; +import '../../../services/event_bus/events/global/tor_status_changed_event.dart'; +import '../../../services/event_bus/events/global/updated_in_background_event.dart'; +import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import '../../../services/event_bus/global_event_bus.dart'; +import '../../../services/tor_service.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/enums/fee_rate_type_enum.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/stack_file_system.dart'; +import '../../../wl_gen/interfaces/cs_monero_interface.dart' + show CsWalletListener, CsOutput, CsRecipient, CsPendingTransaction; +import '../../../wl_gen/interfaces/cs_salvium_interface.dart' + show WrappedWallet; +import '../../../wl_gen/interfaces/cs_wownero_interface.dart'; +import '../../crypto_currency/intermediate/cryptonote_currency.dart'; +import '../../isar/models/wallet_info.dart'; +import '../../models/tx_data.dart'; +import '../wallet.dart'; +import '../wallet_mixin_interfaces/multi_address_interface.dart'; +import '../wallet_mixin_interfaces/view_only_option_interface.dart'; +import 'cryptonote_wallet.dart'; + +abstract class LibWowneroWallet + extends CryptonoteWallet + with ViewOnlyOptionInterface + implements MultiAddressInterface { + @override + int get isarTransactionVersion => 2; + + LibWowneroWallet(super.currency, this.compatType) { + final bus = GlobalEventBus.instance; + + // Listen for tor status changes. + _torStatusListener = bus.on().listen(( + event, + ) async { + switch (event.newStatus) { + case TorConnectionStatus.connecting: + if (!_torConnectingLock.isLocked) { + await _torConnectingLock.acquire(); + } + _requireMutex = true; + break; + + case TorConnectionStatus.connected: + case TorConnectionStatus.disconnected: + if (_torConnectingLock.isLocked) { + _torConnectingLock.release(); + } + _requireMutex = false; + break; + } + }); + + // Listen for tor preference changes. + _torPreferenceListener = bus.on().listen(( + event, + ) async { + await updateNode(); + }); + + // Potentially dangerous hack. See comments in _startInit() + _startInit(); + } + // cw based wallet listener to handle synchronization of utxo frozen states + late final StreamSubscription> _streamSub; + Future _startInit() async { + // Delay required as `mainDB` is not initialized in constructor. + // This is a hack and could lead to a race condition. + Future.delayed(const Duration(seconds: 2), () { + _streamSub = mainDB.isar.utxos + .where() + .walletIdEqualTo(walletId) + .watch(fireImmediately: true) + .listen((utxos) async { + try { + await onUTXOsChanged(utxos); + await updateBalance(shouldUpdateUtxos: false); + } catch (e, s) { + Logging.instance.e("_startInit", error: e, stackTrace: s); + } + }); + }); + } + + final lib_monero_compat.WalletType compatType; + + lib_monero_compat.SyncStatus? get syncStatus => _syncStatus; + lib_monero_compat.SyncStatus? _syncStatus; + int _syncedCount = 0; + void _setSyncStatus(lib_monero_compat.SyncStatus status) { + if (status is lib_monero_compat.SyncedSyncStatus) { + if (_syncStatus is lib_monero_compat.SyncedSyncStatus) { + _syncedCount++; + } + } else { + _syncedCount = 0; + } + + if (_syncedCount < 3) { + _syncStatus = status; + syncStatusChanged(); + } + } + + final prepareSendMutex = Mutex(); + final estimateFeeMutex = Mutex(); + + bool _txRefreshLock = false; + int _lastCheckedHeight = -1; + int _txCount = 0; + + Future loadWallet({ + required String path, + required String password, + }); + + Future getCreatedWallet({ + required String path, + required String password, + required int wordCount, + required String seedOffset, + }); + + Future getRestoredWallet({ + required String path, + required String password, + required String mnemonic, + required String seedOffset, + int height = 0, + }); + + Future getRestoredFromViewKeyWallet({ + required String path, + required String password, + required String address, + required String privateViewKey, + int height = 0, + }); + + void invalidSeedLengthCheck(int length); + + bool walletExists(String path); + + @override + Future getTxKeyFor({required String txid}) async { + if (wallet == null) { + throw Exception("Cannot get tx key in uninitialized LibWowneroWallet"); + } + return csWownero.getTxKey(wallet!, txid); + } + + void _setListener() { + if (wallet != null && !csWownero.hasListeners(wallet!)) { + csWownero.addListener( + wallet!, + CsWalletListener( + onSyncingUpdate: onSyncingUpdate, + onNewBlock: onNewBlock, + onBalancesChanged: onBalancesChanged, + onError: (e, s) { + Logging.instance.w("$e\n$s", error: e, stackTrace: s); + }, + ), + ); + } + } + + @override + Future open() async { + bool wasNull = false; + + if (wallet == null) { + wasNull = true; + // LibWowneroWalletT?.close(); + final path = await pathForWallet(name: walletId, type: compatType); + + final String password; + try { + password = (await secureStorageInterface.read( + key: lib_monero_compat.libMoneroWalletPasswordKey(walletId), + ))!; + } catch (e, s) { + throw Exception("Password not found $e, $s"); + } + + wallet = await loadWallet(path: path, password: password); + + _setListener(); + + await updateNode(); + } + + Address? currentAddress = await getCurrentReceivingAddress(); + if (currentAddress == null) { + currentAddress = addressFor(index: 0); + await mainDB.updateOrPutAddresses([currentAddress]); + } + if (info.cachedReceivingAddress != currentAddress.value) { + await info.updateReceivingAddress( + newAddress: currentAddress.value, + isar: mainDB.isar, + ); + } + + if (wasNull) { + try { + _setSyncStatus(lib_monero_compat.ConnectingSyncStatus()); + csWownero.startSyncing(wallet!); + } catch (_) { + _setSyncStatus(lib_monero_compat.FailedSyncStatus()); + // TODO log + } + } + _setListener(); + csWownero.startListeners(wallet!); + csWownero.startAutoSaving(wallet!); + + unawaited(refresh()); + } + + @Deprecated("Only used in the case of older wallets") + lib_monero_compat.WalletInfo? getLibWowneroWalletInfo(String walletId) { + try { + return DB.instance.moneroWalletInfoBox.values.firstWhere( + (info) => info.id == lib_monero_compat.hiveIdFor(walletId, compatType), + ); + } catch (_) { + return null; + } + } + + Future save() async { + if (!Platform.isWindows) { + final appRoot = await StackFileSystem.applicationRootDirectory(); + await lib_monero_compat.backupWalletFiles( + name: walletId, + type: compatType, + appRoot: appRoot, + ); + } + await csWownero.save(wallet!); + } + + Address addressFor({required int index, int account = 0}) { + final address = csWownero.getAddress( + wallet!, + accountIndex: account, + addressIndex: index, + ); + + if (address.contains("111")) { + throw Exception("111 address found!"); + } + + final newReceivingAddress = Address( + walletId: walletId, + derivationIndex: index, + derivationPath: null, + value: address, + publicKey: [], + type: AddressType.cryptonote, + subType: AddressSubType.receiving, + ); + + return newReceivingAddress; + } + + @override + Future getKeys() async { + final oldInfo = getLibWowneroWalletInfo(walletId); + if (wallet == null || (oldInfo != null && oldInfo.name != walletId)) { + return null; + } + try { + return CWKeyData( + walletId: walletId, + publicViewKey: csWownero.getPublicViewKey(wallet!), + privateViewKey: csWownero.getPrivateViewKey(wallet!), + publicSpendKey: csWownero.getPublicSpendKey(wallet!), + privateSpendKey: csWownero.getPrivateSpendKey(wallet!), + ); + } catch (e, s) { + Logging.instance.f("getKeys failed: ", error: e, stackTrace: s); + return CWKeyData( + walletId: walletId, + publicViewKey: "ERROR", + privateViewKey: "ERROR", + publicSpendKey: "ERROR", + privateSpendKey: "ERROR", + ); + } + } + + @override + Future<(String, String)> + hackToCreateNewViewOnlyWalletDataFromNewlyCreatedWalletThisFunctionShouldNotBeCalledUnlessYouKnowWhatYouAreDoing() async { + final path = await pathForWallet(name: walletId, type: compatType); + final String password; + try { + password = (await secureStorageInterface.read( + key: lib_monero_compat.libMoneroWalletPasswordKey(walletId), + ))!; + } catch (e, s) { + throw Exception("Password not found $e, $s"); + } + wallet = await loadWallet(path: path, password: password); + return ( + csWownero.getAddress(wallet!), + csWownero.getPrivateViewKey(wallet!), + ); + } + + @override + Future init({bool? isRestore, int? wordCount}) async { + final path = await pathForWallet(name: walletId, type: compatType); + if (!(walletExists(path)) && isRestore != true) { + if (wordCount == null) { + throw Exception("Missing word count for new xmr/wow wallet!"); + } + try { + final password = generatePassword(); + await secureStorageInterface.write( + key: lib_monero_compat.libMoneroWalletPasswordKey(walletId), + value: password, + ); + + final wallet = await getCreatedWallet( + path: path, + password: password, + wordCount: wordCount, + seedOffset: "", // default for non restored wallets for now + ); + + await info.updateRestoreHeight( + newRestoreHeight: csWownero.getRefreshFromBlockHeight(wallet), + isar: mainDB.isar, + ); + + // special case for xmr/wow. Normally mnemonic + passphrase is saved + // before wallet.init() is called + await secureStorageInterface.write( + key: Wallet.mnemonicKey(walletId: walletId), + value: csWownero.getSeed(wallet), + ); + await secureStorageInterface.write( + key: Wallet.mnemonicPassphraseKey(walletId: walletId), + value: "", + ); + + this.wallet = wallet; + await updateNode(); + await csWownero.close(wallet, save: true); + this.wallet = null; + } catch (e, s) { + Logging.instance.f("", error: e, stackTrace: s); + } + } + + return super.init(); + } + + @override + Future recover({required bool isRescan}) async { + if (isRescan) { + await refreshMutex.protect(() async { + // clear blockchain info + await mainDB.deleteWalletBlockchainData(walletId); + + highestPercentCached = 0; + unawaited(csWownero.rescanBlockchain(wallet!)); + csWownero.startSyncing(wallet!); + // unawaited(save()); + }); + unawaited(refresh()); + return; + } + + if (isViewOnly) { + await recoverViewOnly(); + return; + } + + await refreshMutex.protect(() async { + final mnemonic = await getMnemonic(); + final seedOffset = await getMnemonicPassphrase(); + final seedLength = mnemonic.trim().split(" ").length; + + invalidSeedLengthCheck(seedLength); + + try { + final height = max(info.restoreHeight, 0); + + await info.updateRestoreHeight( + newRestoreHeight: height, + isar: mainDB.isar, + ); + + final String name = walletId; + + final path = await pathForWallet(name: name, type: compatType); + + try { + final password = generatePassword(); + await secureStorageInterface.write( + key: lib_monero_compat.libMoneroWalletPasswordKey(walletId), + value: password, + ); + + final wallet = await getRestoredWallet( + path: path, + password: password, + mnemonic: mnemonic, + height: height, + seedOffset: seedOffset, + ); + + if (this.wallet != null) { + await exit(); + } + + this.wallet = wallet; + + _setListener(); + + final newReceivingAddress = + await getCurrentReceivingAddress() ?? + Address( + walletId: walletId, + derivationIndex: 0, + derivationPath: null, + value: csWownero.getAddress(this.wallet!), + publicKey: [], + type: AddressType.cryptonote, + subType: AddressSubType.receiving, + ); + + await mainDB.updateOrPutAddresses([newReceivingAddress]); + await info.updateReceivingAddress( + newAddress: newReceivingAddress.value, + isar: mainDB.isar, + ); + } catch (e, s) { + Logging.instance.f("", error: e, stackTrace: s); + rethrow; + } + await updateNode(); + _setListener(); + + // LibWowneroWallet?.setRecoveringFromSeed(isRecovery: true); + unawaited(csWownero.rescanBlockchain(wallet!)); + csWownero.startSyncing(wallet!); + + // await save(); + csWownero.startListeners(wallet!); + csWownero.startAutoSaving(wallet!); + } catch (e, s) { + Logging.instance.e( + "Exception rethrown from recoverFromMnemonic(): ", + error: e, + stackTrace: s, + ); + rethrow; + } + }); + } + + // dumb temporary hack + bool _canPing = false; + + @override + Future pingCheck() { + if (_canPing) { + return csWownero.isConnectedToDaemon(wallet!); + } else { + return Future.value(false); + } + } + + @override + Future updateNode() async { + final node = getCurrentNode(); + + if (_torNodeMismatchGuard(node)) { + throw Exception("TOR – clearnet mismatch"); + } + + final host = node.host.endsWith(".onion") + ? node.host + : Uri.parse(node.host).host; + final ({InternetAddress host, int port})? proxy = + AppConfig.hasFeature(AppFeature.tor) && prefs.useTor && !node.forceNoTor + ? TorService.sharedInstance.getProxyInfo() + : null; + + _setSyncStatus(lib_monero_compat.ConnectingSyncStatus()); + try { + if (_requireMutex) { + await _torConnectingLock.protect(() async { + await csWownero.connect( + wallet!, + daemonAddress: "$host:${node.port}", + daemonUsername: node.loginName, + daemonPassword: await node.getPassword(secureStorageInterface), + trusted: node.trusted ?? false, + useSSL: node.useSSL, + socksProxyAddress: node.forceNoTor + ? null + : proxy == null + ? null + : "${proxy.host.address}:${proxy.port}", + ); + }); + } else { + await csWownero.connect( + wallet!, + daemonAddress: "$host:${node.port}", + daemonUsername: node.loginName, + daemonPassword: await node.getPassword(secureStorageInterface), + trusted: node.trusted ?? false, + useSSL: node.useSSL, + socksProxyAddress: node.forceNoTor + ? null + : proxy == null + ? null + : "${proxy.host.address}:${proxy.port}", + ); + } + csWownero.startSyncing(wallet!); + csWownero.startListeners(wallet!); + csWownero.startAutoSaving(wallet!); + + _setSyncStatus(lib_monero_compat.ConnectedSyncStatus()); + } catch (e, s) { + _setSyncStatus(lib_monero_compat.FailedSyncStatus()); + Logging.instance.e( + "Exception caught in $runtimeType.updateNode(): ", + error: e, + stackTrace: s, + ); + } + + return; + } + + @override + Future updateTransactions() async { + if (wallet == null) { + return; + } + + final localTxids = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .heightGreaterThan(0) + .txidProperty() + .findAll(); + + final allTxids = await csWownero.getAllTxids(wallet!, refresh: true); + + final txidsToFetch = allTxids.toSet().difference(localTxids.toSet()); + + if (txidsToFetch.isEmpty) { + return; + } + + final transactions = await csWownero.getTxs( + wallet!, + txids: txidsToFetch, + refresh: false, + ); + + final allOutputs = await csWownero.getOutputs( + wallet!, + includeSpent: true, + refresh: true, + ); + + // final cachedTransactions = + // DB.instance.get(boxName: walletId, key: 'latest_tx_model') + // as TransactionData?; + // int latestTxnBlockHeight = + // DB.instance.get(boxName: walletId, key: "storedTxnDataHeight") + // as int? ?? + // 0; + // + // final txidsList = DB.instance + // .get(boxName: walletId, key: "cachedTxids") as List? ?? + // []; + // + // final Set cachedTxids = Set.from(txidsList); + + // TODO: filter to skip cached + confirmed txn processing in next step + // final unconfirmedCachedTransactions = + // cachedTransactions?.getAllTransactions() ?? {}; + // unconfirmedCachedTransactions + // .removeWhere((key, value) => value.confirmedStatus); + // + // if (cachedTransactions != null) { + // for (final tx in allTxHashes.toList(growable: false)) { + // final txHeight = tx["height"] as int; + // if (txHeight > 0 && + // txHeight < latestTxnBlockHeight - MINIMUM_CONFIRMATIONS) { + // if (unconfirmedCachedTransactions[tx["tx_hash"] as String] == null) { + // allTxHashes.remove(tx); + // } + // } + // } + // } + + final List txns = []; + + for (final tx in transactions) { + final associatedOutputs = allOutputs.where((e) => e.hash == tx.hash); + final List inputs = []; + final List outputs = []; + TransactionType type; + if (!tx.isSpend) { + type = TransactionType.incoming; + for (final output in associatedOutputs) { + outputs.add( + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: "", + valueStringSats: output.value.toString(), + addresses: [output.address], + walletOwns: true, + ), + ); + } + } else { + type = TransactionType.outgoing; + for (final output in associatedOutputs) { + inputs.add( + InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: null, + addresses: [output.address], + valueStringSats: output.value.toString(), + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: true, + ), + ); + } + } + + final txn = TransactionV2( + walletId: walletId, + blockHash: null, // not exposed via current cs_monero + hash: tx.hash, + txid: tx.hash, + timestamp: (tx.timeStamp.millisecondsSinceEpoch ~/ 1000), + height: tx.blockHeight, + inputs: inputs, + outputs: outputs, + version: -1, // not exposed via current cs_monero + type: type, + subType: TransactionSubType.none, + otherData: jsonEncode({ + TxV2OdKeys.overrideFee: Amount( + rawValue: tx.fee, + fractionDigits: cryptoCurrency.fractionDigits, + ).toJsonString(), + TxV2OdKeys.moneroAmount: Amount( + rawValue: tx.amount, + fractionDigits: cryptoCurrency.fractionDigits, + ).toJsonString(), + TxV2OdKeys.moneroAccountIndex: tx.accountIndex, + TxV2OdKeys.isMoneroTransaction: true, + }), + ); + + txns.add(txn); + } + + await mainDB.updateOrPutTransactionV2s(txns); + } + + Future get availableBalance async { + try { + return Amount( + rawValue: csWownero.getUnlockedBalance(wallet!)!, + fractionDigits: cryptoCurrency.fractionDigits, + ); + } catch (_) { + return info.cachedBalance.spendable; + } + } + + Future get totalBalance async { + try { + final full = csWownero.getBalance(wallet!); + if (full != null) { + return Amount( + rawValue: full, + fractionDigits: cryptoCurrency.fractionDigits, + ); + } else { + final transactions = await csWownero.getAllTxs(wallet!, refresh: true); + BigInt transactionBalance = BigInt.zero; + for (final tx in transactions) { + if (!tx.isSpend) { + transactionBalance += tx.amount; + } else { + transactionBalance += -tx.amount - tx.fee; + } + } + + return Amount( + rawValue: transactionBalance, + fractionDigits: cryptoCurrency.fractionDigits, + ); + } + } catch (_) { + return info.cachedBalance.total; + } + } + + @override + Future exit() async { + Logging.instance.i("exit called on $wallet!"); + if (wallet != null) { + csWownero.stopAutoSaving(wallet!); + csWownero.stopListeners(wallet!); + csWownero.stopSyncing(wallet!); + await csWownero.save(wallet!); + } + } + + Future pathForWalletDir({ + required String name, + required lib_monero_compat.WalletType type, + }) async { + final Directory root = await StackFileSystem.applicationRootDirectory(); + return lib_monero_compat.pathForWalletDir( + name: name, + type: type.name.toLowerCase(), + appRoot: root, + ); + } + + Future pathForWallet({ + required String name, + required lib_monero_compat.WalletType type, + }) async => await pathForWalletDir( + name: name, + type: type, + ).then((path) => '$path/$name'); + + void onSyncingUpdate({ + required int syncHeight, + required int nodeHeight, + String? message, + }) { + if (nodeHeight > 0 && syncHeight >= 0) { + currentKnownChainHeight = nodeHeight; + updateChainHeight(); + final blocksLeft = nodeHeight - syncHeight; + final lib_monero_compat.SyncStatus status; + if (blocksLeft < 100) { + status = lib_monero_compat.SyncedSyncStatus(); + + // if (!_hasSyncAfterStartup) { + // _hasSyncAfterStartup = true; + // await save(); + // } + // + // if (walletInfo.isRecovery!) { + // await setAsRecovered(); + // } + } else { + final percent = syncHeight / currentKnownChainHeight; + + status = lib_monero_compat.SyncingSyncStatus( + blocksLeft, + percent, + currentKnownChainHeight, + ); + } + + _setSyncStatus(status); + _refreshTxDataHelper(); + } + } + + void onBalancesChanged({ + required BigInt newBalance, + required BigInt newUnlockedBalance, + }) async { + try { + await updateBalance(); + await updateTransactions(); + } catch (e, s) { + Logging.instance.w("onBalancesChanged(): ", error: e, stackTrace: s); + } + } + + void onNewBlock(int nodeHeight) async { + try { + await updateTransactions(); + } catch (e, s) { + Logging.instance.w("onNewBlock(): ", error: e, stackTrace: s); + } + } + + final _utxosUpdateLock = Mutex(); + Future onUTXOsChanged(List utxos) async { + if (wallet == null) { + Logging.instance.w( + "onUTXOsChanged triggered while cs_monero wallet is null. If this " + "occurs while not in a monero/wownero wallet this warning can be " + "ignored.", + ); + return; + } + + await _utxosUpdateLock.protect(() async { + final cwUtxos = await csWownero.getOutputs(wallet!, refresh: true); + + // bool changed = false; + + for (final cw in cwUtxos) { + final match = utxos.where( + (e) => + e.keyImage != null && + e.keyImage!.isNotEmpty && + e.keyImage == cw.keyImage, + ); + + if (match.isNotEmpty) { + final u = match.first; + + if (u.isBlocked) { + if (!cw.isFrozen) { + await csWownero.freezeOutput(wallet!, cw.keyImage); + // changed = true; + } + } else { + if (cw.isFrozen) { + await csWownero.thawOutput(wallet!, cw.keyImage); + // changed = true; + } + } + } + } + + // if (changed) { + // await LibWowneroWallet?.updateUTXOs(); + // } + }); + } + + void onNewTransaction() { + // TODO: [prio=low] get rid of UpdatedInBackgroundEvent and move to + // adding the v2 tx to the db which would update ui automagically since the + // db is watched by the ui + // call this here? + GlobalEventBus.instance.fire( + UpdatedInBackgroundEvent( + "New data found in $walletId ${info.name} in background!", + walletId, + ), + ); + } + + void syncStatusChanged() async { + final _syncStatus = syncStatus; + + if (_syncStatus != null) { + if (_syncStatus.progress() == 1 && refreshMutex.isLocked) { + refreshMutex.release(); + } + + WalletSyncStatus? status; + xmrAndWowSyncSpecificFunctionThatShouldBeGottenRidOfInTheFuture(true); + + if (_syncStatus is lib_monero_compat.SyncingSyncStatus) { + final int blocksLeft = _syncStatus.blocksLeft; + + // ensure at least 1 to prevent math errors + final int height = max(1, _syncStatus.height); + + final nodeHeight = height + blocksLeft; + currentKnownChainHeight = nodeHeight; + + // final percent = height / nodeHeight; + final percent = _syncStatus.ptc; + + final highest = max(highestPercentCached, percent); + + final unchanged = highest == highestPercentCached; + if (unchanged) { + return; + } + + // update cached + if (highestPercentCached < percent) { + highestPercentCached = percent; + } + + GlobalEventBus.instance.fire( + RefreshPercentChangedEvent(highest, walletId), + ); + GlobalEventBus.instance.fire( + BlocksRemainingEvent(blocksLeft, walletId), + ); + } else if (_syncStatus is lib_monero_compat.SyncedSyncStatus) { + status = WalletSyncStatus.synced; + } else if (_syncStatus is lib_monero_compat.NotConnectedSyncStatus) { + status = WalletSyncStatus.unableToSync; + xmrAndWowSyncSpecificFunctionThatShouldBeGottenRidOfInTheFuture(false); + } else if (_syncStatus is lib_monero_compat.StartingSyncStatus) { + status = WalletSyncStatus.syncing; + GlobalEventBus.instance.fire( + RefreshPercentChangedEvent(highestPercentCached, walletId), + ); + } else if (_syncStatus is lib_monero_compat.FailedSyncStatus) { + status = WalletSyncStatus.unableToSync; + xmrAndWowSyncSpecificFunctionThatShouldBeGottenRidOfInTheFuture(false); + } else if (_syncStatus is lib_monero_compat.ConnectingSyncStatus) { + status = WalletSyncStatus.syncing; + GlobalEventBus.instance.fire( + RefreshPercentChangedEvent(highestPercentCached, walletId), + ); + } else if (_syncStatus is lib_monero_compat.ConnectedSyncStatus) { + status = WalletSyncStatus.syncing; + GlobalEventBus.instance.fire( + RefreshPercentChangedEvent(highestPercentCached, walletId), + ); + } else if (_syncStatus is lib_monero_compat.LostConnectionSyncStatus) { + status = WalletSyncStatus.unableToSync; + xmrAndWowSyncSpecificFunctionThatShouldBeGottenRidOfInTheFuture(false); + } + + if (status != null) { + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent(status, walletId, info.coin), + ); + } + } + } + + @override + Future checkSaveInitialReceivingAddress() async { + // this doesn't work without opening the wallet first which takes a while + } + + // ============ Private ====================================================== + Future _refreshTxDataHelper() async { + if (_txRefreshLock) return; + _txRefreshLock = true; + + final _syncStatus = syncStatus; + + if (_syncStatus != null && + _syncStatus is lib_monero_compat.SyncingSyncStatus) { + final int blocksLeft = _syncStatus.blocksLeft; + final tenKChange = blocksLeft ~/ 10000; + + // only refresh transactions periodically during a sync + if (_lastCheckedHeight == -1 || tenKChange < _lastCheckedHeight) { + _lastCheckedHeight = tenKChange; + await _refreshTxData(); + } + } else { + await _refreshTxData(); + } + + _txRefreshLock = false; + } + + Future _refreshTxData() async { + await updateTransactions(); + final count = await mainDB.getTransactions(walletId).count(); + + if (count > _txCount) { + _txCount = count; + await updateBalance(); + GlobalEventBus.instance.fire( + UpdatedInBackgroundEvent( + "New transaction data found in $walletId ${info.name}!", + walletId, + ), + ); + } + } + + bool _torNodeMismatchGuard(NodeModel node) { + _canPing = true; // Reset. + + final bool mismatch = + (prefs.useTor && node.clearnetEnabled && !node.torEnabled) || + (!prefs.useTor && !node.clearnetEnabled && node.torEnabled); + + if (mismatch) { + _canPing = false; + if (wallet != null) { + csWownero.stopAutoSaving(wallet!); + csWownero.stopListeners(wallet!); + csWownero.stopSyncing(wallet!); + } + _setSyncStatus(lib_monero_compat.FailedSyncStatus()); + } + + return mismatch; // Caller decides whether to throw. + } + + // ============ Overrides ==================================================== + + @override + FilterOperation? get changeAddressFilterOperation => null; + + @override + FilterOperation? get receivingAddressFilterOperation => null; + + @override + Future updateUTXOs() async { + final List outputArray = []; + final utxos = wallet == null + ? [] + : await csWownero.getOutputs(wallet!, refresh: true); + for (final rawUTXO in utxos) { + if (!rawUTXO.spent) { + final current = await mainDB.isar.utxos + .where() + .walletIdEqualTo(walletId) + .filter() + .voutEqualTo(rawUTXO.vout) + .and() + .txidEqualTo(rawUTXO.hash) + .findFirst(); + final tx = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .txidEqualTo(rawUTXO.hash) + .findFirst(); + + final otherDataMap = { + UTXOOtherDataKeys.keyImage: rawUTXO.keyImage, + UTXOOtherDataKeys.spent: rawUTXO.spent, + }; + + final utxo = UTXO( + address: rawUTXO.address, + walletId: walletId, + txid: rawUTXO.hash, + vout: rawUTXO.vout, + value: rawUTXO.value.toInt(), + name: current?.name ?? "", + isBlocked: current?.isBlocked ?? rawUTXO.isFrozen, + blockedReason: current?.blockedReason ?? "", + isCoinbase: rawUTXO.coinbase, + blockHash: "", + blockHeight: + tx?.height ?? (rawUTXO.height > 0 ? rawUTXO.height : null), + blockTime: tx?.timestamp, + otherData: jsonEncode(otherDataMap), + ); + + outputArray.add(utxo); + } + } + + await mainDB.updateUTXOs(walletId, outputArray); + + return true; + } + + @override + Future updateBalance({bool shouldUpdateUtxos = true}) async { + if (shouldUpdateUtxos) { + await updateUTXOs(); + } + + final total = await totalBalance; + final available = await availableBalance; + + final balance = Balance( + total: total, + spendable: available, + blockedTotal: Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ), + pendingSpendable: total - available, + ); + + await info.updateBalance(newBalance: balance, isar: mainDB.isar); + } + + @override + Future refresh() async { + // Awaiting this lock could be dangerous. + // Since refresh is periodic (generally) + if (refreshMutex.isLocked) { + return; + } + + final node = getCurrentNode(); + + if (_torNodeMismatchGuard(node)) { + throw Exception("TOR – clearnet mismatch"); + } + + // this acquire should be almost instant due to above check. + // Slight possibility of race but should be irrelevant + await refreshMutex.acquire(); + + csWownero.startSyncing(wallet!); + _setSyncStatus(lib_monero_compat.StartingSyncStatus()); + + await updateTransactions(); + await updateBalance(); + + if (info.otherData[WalletInfoKeys.reuseAddress] != true) { + await checkReceivingAddressForTransactions(); + } + + if (refreshMutex.isLocked) { + refreshMutex.release(); + } + + final synced = wallet != null && await csWownero.isSynced(wallet!); + + if (synced) { + _setSyncStatus(lib_monero_compat.SyncedSyncStatus()); + } + } + + @override + Future generateNewReceivingAddress() async { + try { + final currentReceiving = await getCurrentReceivingAddress(); + + final newReceivingIndex = currentReceiving == null + ? 0 + : currentReceiving.derivationIndex + 1; + + final newReceivingAddress = addressFor(index: newReceivingIndex); + + // Add that new receiving address + await mainDB.putAddress(newReceivingAddress); + await info.updateReceivingAddress( + newAddress: newReceivingAddress.value, + isar: mainDB.isar, + ); + } catch (e, s) { + Logging.instance.e( + "Exception in generateNewAddress(): ", + error: e, + stackTrace: s, + ); + } + } + + @override + Future checkReceivingAddressForTransactions() async { + if (info.otherData[WalletInfoKeys.reuseAddress] == true) { + try { + throw Exception(); + } catch (_, s) { + Logging.instance.e( + "checkReceivingAddressForTransactions called but reuse address flag set: $s", + error: e, + stackTrace: s, + ); + } + } + + try { + int highestIndex = -1; + final entries = await csWownero.getAllTxs(wallet!, refresh: true); + for (final element in entries) { + if (!element.isSpend) { + final int curAddressIndex = element.addressIndexes.isEmpty + ? 0 + : element.addressIndexes.reduce(max); + if (curAddressIndex > highestIndex) { + highestIndex = curAddressIndex; + } + } + } + + // Check the new receiving index + final currentReceiving = await getCurrentReceivingAddress(); + final curIndex = currentReceiving?.derivationIndex ?? -1; + + if (highestIndex >= curIndex) { + // First increment the receiving index + final newReceivingIndex = curIndex + 1; + + // Use new index to derive a new receiving address + final newReceivingAddress = addressFor(index: newReceivingIndex); + + final existing = await mainDB + .getAddresses(walletId) + .filter() + .valueEqualTo(newReceivingAddress.value) + .findFirst(); + if (existing == null) { + // Add that new change address + await mainDB.putAddress(newReceivingAddress); + } else { + // we need to update the address + await mainDB.updateAddress(existing, newReceivingAddress); + } + if (info.otherData[WalletInfoKeys.reuseAddress] != true) { + // keep checking until address with no tx history is set as current + await checkReceivingAddressForTransactions(); + } + } + } on SocketException catch (se, s) { + Logging.instance.e( + "SocketException caught in _checkReceivingAddressForTransactions(): $se\n$s", + error: e, + stackTrace: s, + ); + return; + } catch (e, s) { + Logging.instance.e( + "Exception rethrown from _checkReceivingAddressForTransactions(): ", + error: e, + stackTrace: s, + ); + rethrow; + } + } + + // TODO: this needs some work. Prio's may need to be changed as well as estimated blocks + @override + Future get fees async => FeeObject( + numberOfBlocksFast: 10, + numberOfBlocksAverage: 15, + numberOfBlocksSlow: 20, + fast: BigInt.from(csWownero.getTxPriorityHigh()), + medium: BigInt.from(csWownero.getTxPriorityMedium()), + slow: BigInt.from(csWownero.getTxPriorityNormal()), + ); + + @override + Future updateChainHeight() async { + await info.updateCachedChainHeight( + newHeight: currentKnownChainHeight, + isar: mainDB.isar, + ); + } + + @override + Future checkChangeAddressForTransactions() async { + // do nothing + } + + @override + Future generateNewChangeAddress() async { + // do nothing + } + + @override + Future prepareSend({required TxData txData}) async { + try { + final feeRate = txData.feeRateType; + if (feeRate is FeeRateType) { + final int feePriority; + switch (feeRate) { + case FeeRateType.fast: + feePriority = csWownero.getTxPriorityHigh(); + break; + case FeeRateType.average: + feePriority = csWownero.getTxPriorityMedium(); + break; + case FeeRateType.slow: + feePriority = csWownero.getTxPriorityNormal(); + break; + default: + throw ArgumentError("Invalid use of custom fee"); + } + + try { + final bool sweep; + + if (txData.utxos == null) { + final balance = await availableBalance; + sweep = txData.amount! == balance; + } else { + final totalInputsValue = txData.utxos! + .map((e) => e.value) + .fold(BigInt.zero, (p, e) => p + e); + sweep = txData.amount!.raw == totalInputsValue; + } + + // TODO: test this one day + // cs_monero may not support this yet properly + if (sweep && txData.recipients!.length > 1) { + throw Exception("Send all not supported with multiple recipients"); + } + + final List outputs = []; + for (final recipient in txData.recipients!) { + final output = CsRecipient(recipient.address, recipient.amount.raw); + + outputs.add(output); + } + + if (outputs.isEmpty) { + throw Exception("No recipients provided"); + } + + final height = await chainHeight; + final inputs = txData.utxos?.whereType().toList(); + + return await prepareSendMutex.protect(() async { + final CsPendingTransaction pendingTransaction; + if (outputs.length == 1) { + pendingTransaction = await csWownero.createTx( + wallet!, + minConfirms: cryptoCurrency.minConfirms, + currentHeight: height, + output: outputs.first, + sweep: sweep, + priority: feePriority, + preferredInputs: inputs, + accountIndex: 0, // sw only uses account 0 at this time + ); + } else { + pendingTransaction = await csWownero.createTxMultiDest( + wallet!, + minConfirms: cryptoCurrency.minConfirms, + currentHeight: height, + outputs: outputs, + priority: feePriority, + preferredInputs: inputs, + sweep: sweep, + accountIndex: 0, // sw only uses account 0 at this time + ); + } + + final realFee = Amount( + rawValue: pendingTransaction.fee, + fractionDigits: cryptoCurrency.fractionDigits, + ); + + return txData.copyWith( + fee: realFee, + pendingTransaction: pendingTransaction, + ); + }); + } catch (e) { + rethrow; + } + } else { + throw ArgumentError("Invalid fee rate argument provided!"); + } + } catch (e, s) { + Logging.instance.i( + "Exception rethrown from prepare send(): ", + error: e, + stackTrace: s, + ); + + if (e.toString().contains("Incorrect unlocked balance")) { + throw Exception("Insufficient balance!"); + } else { + throw Exception("Transaction failed with error: $e"); + } + } + } + + @override + Future confirmSend({required TxData txData}) async { + try { + try { + await csWownero.commitTx(wallet!, txData.pendingTransaction!); + + Logging.instance.d( + "transaction ${txData.pendingTransaction!.txid} has been sent", + ); + return txData.copyWith(txid: txData.pendingTransaction!.txid); + } catch (e, s) { + Logging.instance.e( + "${info.name} ${compatType.name.toLowerCase()} confirmSend: ", + error: e, + stackTrace: s, + ); + rethrow; + } + } catch (e, s) { + Logging.instance.e( + "Exception rethrown from confirmSend(): ", + error: e, + stackTrace: s, + ); + rethrow; + } + } + + @override + Future getRefreshFromBlockHeight() async => wallet == null + ? throw Exception( + "Cannot getRefreshFromBlockHeight when wallet is not open", + ) + : csWownero.getRefreshFromBlockHeight(wallet!); + + @override + int getTxPriorityHigh() => csWownero.getTxPriorityHigh(); + + @override + int getTxPriorityMedium() => csWownero.getTxPriorityMedium(); + + @override + int getTxPriorityNormal() => csWownero.getTxPriorityNormal(); + + @override + Future internalCommitTx(CsPendingTransaction tx) { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + + return csWownero.commitTx(wallet!, tx); + } + + @override + Future internalCreateTx({ + required CsRecipient output, + required int priority, + required bool sweep, + List? preferredInputs, + required int accountIndex, + required int minConfirms, + required int currentHeight, + }) { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + return csWownero.createTx( + wallet!, + output: output, + priority: priority, + sweep: sweep, + accountIndex: accountIndex, + minConfirms: minConfirms, + currentHeight: currentHeight, + preferredInputs: preferredInputs, + ); + } + + @override + Future internalGetAddress({ + required int accountIndex, + required int addressIndex, + }) async { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + return csWownero.getAddress( + wallet!, + accountIndex: accountIndex, + addressIndex: addressIndex, + ); + } + + @override + Future> internalGetOutputs({ + bool refresh = false, + bool includeSpent = false, + }) { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + return csWownero.getOutputs( + wallet!, + refresh: refresh, + includeSpent: includeSpent, + ); + } + + @override + Future internalGetUnlockedBalance({int accountIndex = 0}) async { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + return csWownero.getUnlockedBalance(wallet!, accountIndex: accountIndex)!; + } + + @override + void setRefreshFromBlockHeight(int newHeight) { + if (wallet == null) { + throw Exception("Cannot internalCommitTx when wallet is not open"); + } + csWownero.setRefreshFromBlockHeight(wallet!, newHeight); + } + + // ============== View only ================================================== + + @override + Future recoverViewOnly() async { + await refreshMutex.protect(() async { + final data = + await getViewOnlyWalletData() as CryptonoteViewOnlyWalletData; + + try { + final height = max(info.restoreHeight, 0); + + await info.updateRestoreHeight( + newRestoreHeight: height, + isar: mainDB.isar, + ); + + final String name = walletId; + + final path = await pathForWallet(name: name, type: compatType); + + final password = generatePassword(); + await secureStorageInterface.write( + key: lib_monero_compat.libMoneroWalletPasswordKey(walletId), + value: password, + ); + + final wallet = await getRestoredFromViewKeyWallet( + path: path, + password: password, + address: data.address, + privateViewKey: data.privateViewKey, + height: height, + ); + + if (this.wallet == null) { + await exit(); + } + this.wallet = wallet; + + _setListener(); + + final newReceivingAddress = + await getCurrentReceivingAddress() ?? + Address( + walletId: walletId, + derivationIndex: 0, + derivationPath: null, + value: csWownero.getAddress(this.wallet!), + publicKey: [], + type: AddressType.cryptonote, + subType: AddressSubType.receiving, + ); + + await mainDB.updateOrPutAddresses([newReceivingAddress]); + await info.updateReceivingAddress( + newAddress: newReceivingAddress.value, + isar: mainDB.isar, + ); + + await updateNode(); + _setListener(); + + unawaited(csWownero.rescanBlockchain(this.wallet!)); + csWownero.startSyncing(this.wallet!); + + // await save(); + csWownero.startListeners(this.wallet!); + csWownero.startAutoSaving(this.wallet!); + } catch (e, s) { + Logging.instance.e( + "Exception rethrown from recoverViewOnly(): ", + error: e, + stackTrace: s, + ); + rethrow; + } + }); + } + + // ============== Private ==================================================== + + StreamSubscription? _torStatusListener; + StreamSubscription? _torPreferenceListener; + + final Mutex _torConnectingLock = Mutex(); + bool _requireMutex = false; +} diff --git a/lib/wallets/wallet/intermediate/lib_xelis_wallet.dart b/lib/wallets/wallet/intermediate/lib_xelis_wallet.dart index 6b276c21a7..eb818021a6 100644 --- a/lib/wallets/wallet/intermediate/lib_xelis_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_xelis_wallet.dart @@ -169,7 +169,7 @@ abstract class LibXelisWallet await _eventSubscription?.cancel(); _eventSubscription = null; - if (wallet != null) { + if (wallet != null && await libXelis.isOnline(wallet!)) { await libXelis.offlineMode(wallet!); } await super.exit(); @@ -226,11 +226,10 @@ extension XelisTableManagement on LibXelisWallet { try { Logging.instance.i("Xelis: Generating large tables in background"); - final tablePath = await getPrecomputedTablesPath(); await libXelis.updateTables( precomputedTablesPath: tablePath, - l1Low: state.desiredSize.isLow, + stack_l1Low: state.desiredSize.isLow, ); await setTableState( diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 0fa72d6822..1aa40ef6a7 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -7,6 +7,7 @@ import 'package:mutex/mutex.dart'; import '../../db/isar/main_db.dart'; import '../../models/isar/models/blockchain_data/address.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../models/isar/models/solana/sol_contract.dart'; import '../../models/keys/view_only_wallet_data.dart'; import '../../models/node_model.dart'; import '../../models/paymint/fee_object_model.dart'; @@ -48,6 +49,7 @@ import 'impl/salvium_wallet.dart'; import 'impl/solana_wallet.dart'; import 'impl/stellar_wallet.dart'; import 'impl/sub_wallets/eth_token_wallet.dart'; +import 'impl/sub_wallets/solana_token_wallet.dart'; import 'impl/tezos_wallet.dart'; import 'impl/wownero_wallet.dart'; import 'impl/xelis_wallet.dart'; @@ -244,11 +246,10 @@ abstract class Wallet { required NodeService nodeService, required Prefs prefs, }) async { - final walletInfo = - await mainDB.isar.walletInfo - .where() - .walletIdEqualTo(walletId) - .findFirst(); + final walletInfo = await mainDB.isar.walletInfo + .where() + .walletIdEqualTo(walletId) + .findFirst(); Logging.instance.i( "Wallet.load loading" @@ -287,6 +288,20 @@ abstract class Wallet { return wallet.._walletId = ethWallet.info.walletId; } + static Wallet loadSolTokenWallet({ + required SolanaWallet solWallet, + required SolContract contract, + }) { + final Wallet wallet = SolanaTokenWallet(solWallet, contract); + + wallet.prefs = solWallet.prefs; + wallet.nodeService = solWallet.nodeService; + wallet.secureStorageInterface = solWallet.secureStorageInterface; + wallet.mainDB = solWallet.mainDB; + + return wallet.._walletId = solWallet.info.walletId; + } + //============================================================================ // ========== Static Util ==================================================== @@ -438,10 +453,9 @@ abstract class Wallet { final bool hasNetwork = await pingCheck(); if (_isConnected != hasNetwork) { - final NodeConnectionStatus status = - hasNetwork - ? NodeConnectionStatus.connected - : NodeConnectionStatus.disconnected; + final NodeConnectionStatus status = hasNetwork + ? NodeConnectionStatus.connected + : NodeConnectionStatus.disconnected; if (!doNotFireRefreshEvents) { GlobalEventBus.instance.fire( NodeConnectionStatusChangedEvent(status, walletId, cryptoCurrency), @@ -756,11 +770,10 @@ abstract class Wallet { // Check if there's another wallet of this coin on the sync list. final List walletIds = []; for (final id in prefs.walletIdsSyncOnStartup) { - final wallet = - mainDB.isar.walletInfo - .where() - .walletIdEqualTo(id) - .findFirstSync()!; + final wallet = mainDB.isar.walletInfo + .where() + .walletIdEqualTo(id) + .findFirstSync()!; if (wallet.coin == cryptoCurrency) { walletIds.add(id); diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 26106e6778..100aa9f9fe 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; @@ -36,11 +37,12 @@ import 'cpfp_interface.dart'; import 'mweb_interface.dart'; import 'paynym_interface.dart'; import 'rbf_interface.dart'; +import 'sign_verify_interface.dart'; import 'view_only_option_interface.dart'; mixin ElectrumXInterface on Bip39HDWallet - implements ViewOnlyOptionInterface { + implements ViewOnlyOptionInterface, SignVerifyInterface { late ElectrumXClient electrumXClient; late CachedElectrumXClient electrumXCachedClient; @@ -152,9 +154,9 @@ mixin ElectrumXInterface if (txData.type == TxType.mweb || txData.type == TxType.mwebPegOut) { if (utxos == null) { final db = Drift.get(walletId); - final mwebUtxos = - await (db.select(db.mwebUtxos) - ..where((e) => e.used.equals(false))).get(); + final mwebUtxos = await (db.select( + db.mwebUtxos, + )..where((e) => e.used.equals(false))).get(); availableOutputs = mwebUtxos.map((e) => MwebInput(e)).toList(); } else { @@ -172,23 +174,22 @@ mixin ElectrumXInterface final canCPFP = this is CpfpInterface && coinControl; - final spendableOutputs = - availableOutputs.where((e) { - if (e is StandardInput) { - return !e.utxo.isBlocked && - (e.utxo.used != true) && - (canCPFP || - e.utxo.isConfirmed( - currentChainHeight, - cryptoCurrency.minConfirms, - cryptoCurrency.minCoinbaseConfirms, - )); - } else if (e is MwebInput) { - return !e.utxo.blocked && !e.utxo.used; - } else { - return false; - } - }).toList(); + final spendableOutputs = availableOutputs.where((e) { + if (e is StandardInput) { + return !e.utxo.isBlocked && + (e.utxo.used != true) && + (canCPFP || + e.utxo.isConfirmed( + currentChainHeight, + cryptoCurrency.minConfirms, + cryptoCurrency.minCoinbaseConfirms, + )); + } else if (e is MwebInput) { + return !e.utxo.blocked && !e.utxo.used; + } else { + return false; + } + }).toList(); final spendableSatoshiValue = spendableOutputs.fold( BigInt.zero, (p, e) => p + e.value, @@ -222,6 +223,31 @@ mixin ElectrumXInterface Logging.instance.d("spendableSatoshiValue: $spendableSatoshiValue"); Logging.instance.d("satoshiAmountToSend: $satoshiAmountToSend"); + // Use coinlib CoinSelection algorithms except for + // "coinControl", "SendAll", "MWEB", "overrideFeeAmount", + // 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.type != TxType.mweb && + txData.type != TxType.mwebPegOut && + txData.type != TxType.mwebPegIn; + + if (useOptimalSelection) { + return await _optimalCoinSelection( + txData: txData, + spendableOutputs: spendableOutputs.whereType().toList(), + recipientAddress: recipientAddress, + satoshiAmountToSend: satoshiAmountToSend, + satsPerVByte: satsPerVByte, + feeRatePerKB: selectedTxFeeRate, + changeAddress: await changeAddress(), + ); + } + BigInt satoshisBeingUsed = BigInt.zero; int inputsBeingConsumed = 0; final List utxoObjectsToUse = []; @@ -296,16 +322,15 @@ mixin ElectrumXInterface final int vSizeForOneOutput; try { - vSizeForOneOutput = - (await buildTransaction( - inputsWithKeys: inputsWithKeys, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - [recipientAddress], - [satoshisBeingUsed - BigInt.one], - ), - ), - )).vSize!; + 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; @@ -316,22 +341,21 @@ mixin ElectrumXInterface 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), - ), - ], + vSizeForTwoOutPuts = (await buildTransaction( + inputsWithKeys: inputsWithKeys, + txData: txData.copyWith( + recipients: await helperRecipientsConvert( + [recipientAddress, (await changeAddress()).value], + [ + satoshiAmountToSend, + maxBI( + BigInt.zero, + satoshisBeingUsed - (satoshiAmountToSend + BigInt.one), ), - ), - )).vSize!; + ], + ), + ), + )).vSize!; } catch (e, s) { Logging.instance.e("vSizeForTwoOutPuts: $e", error: e, stackTrace: s); rethrow; @@ -344,9 +368,9 @@ mixin ElectrumXInterface satsPerVByte != null ? (satsPerVByte * vSizeForOneOutput) : estimateTxFee( - vSize: vSizeForOneOutput, - feeRatePerKB: selectedTxFeeRate, - ), + vSize: vSizeForOneOutput, + feeRatePerKB: selectedTxFeeRate, + ), ); // Assume 2 outputs, one for recipient and one for change final feeForTwoOutputs = @@ -355,9 +379,9 @@ mixin ElectrumXInterface satsPerVByte != null ? (satsPerVByte * vSizeForTwoOutPuts) : estimateTxFee( - vSize: vSizeForTwoOutPuts, - feeRatePerKB: selectedTxFeeRate, - ), + vSize: vSizeForTwoOutPuts, + feeRatePerKB: selectedTxFeeRate, + ), ); Logging.instance.d("feeForTwoOutputs: $feeForTwoOutputs"); @@ -513,28 +537,30 @@ mixin ElectrumXInterface BigInt feeForOneOutput; if (overrideFeeAmount == null) { - final int vSizeForOneOutput = - (await buildTransaction( - inputsWithKeys: inputsWithKeys, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - [recipientAddress], - [satoshisBeingUsed - BigInt.one], - ), - ), - )).vSize!; + 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, - ), + vSize: vSizeForOneOutput, + feeRatePerKB: feeRatePerKB, + ), ); if (satsPerVByte == null) { - final roughEstimate = - roughFeeEstimate(inputsWithKeys.length, 1, feeRatePerKB).raw; + final roughEstimate = roughFeeEstimate( + inputsWithKeys.length, + 1, + feeRatePerKB, + ).raw; if (feeForOneOutput < roughEstimate) { feeForOneOutput = roughEstimate; } @@ -543,30 +569,258 @@ mixin ElectrumXInterface feeForOneOutput = overrideFeeAmount; } - final satoshiAmountToSend = satoshisBeingUsed - feeForOneOutput; + 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( + txData: txData.copyWith( + recipients: await helperRecipientsConvert( + [recipientAddress], + [satoshiAmountToSend], + ), + ), + 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 data.copyWith( + fee: Amount( + rawValue: feeForOneOutput, + fractionDigits: cryptoCurrency.fractionDigits, + ), + usedUTXOs: inputsWithKeys, + ); + } + + coinlib.Input standardInputToCoinlibInput( + StandardInput input, { + int sequence = 0xffffffff, + }) { + final hash = Uint8List.fromList( + input.utxo.txid.toUint8ListFromHex.reversed.toList(), + ); + final prevOut = coinlib.OutPoint(hash, input.utxo.vout); + + switch (input.derivePathType) { + case DerivePathType.bip44: + case DerivePathType.bch44: + return coinlib.P2PKHInput( + prevOut: prevOut, + publicKey: input.key!.publicKey, + sequence: sequence, + ); + + // TODO: fix this as it is (probably) wrong! + case DerivePathType.bip49: + throw Exception("TODO p2sh"); + // return coinlib.P2SHMultisigInput( + // prevOut: prevOut, + // program: coinlib.MultisigProgram.decompile( + // input.redeemScript!, + // ), + // sequence: sequence, + // ); + + case DerivePathType.bip84: + return coinlib.P2WPKHInput( + prevOut: prevOut, + publicKey: input.key!.publicKey, + sequence: sequence, + ); + + case DerivePathType.bip86: + return coinlib.TaprootKeyInput(prevOut: prevOut); + + default: + throw UnsupportedError( + "Unknown derivation path type found: ${input.derivePathType}", + ); + } + } + + /// Helper that will convert BaseInput into InputCandidates + /// and use [coinlib.CoinSelection.optimal] to select the good candidates. + Future _optimalCoinSelection({ + required TxData txData, + required List spendableOutputs, + required String recipientAddress, + required BigInt satoshiAmountToSend, + required int? satsPerVByte, + required BigInt feeRatePerKB, + required Address changeAddress, + }) async { + final List candidateInputs = await addSigningKeys( + spendableOutputs, + ); + + final BigInt feePerKb = satsPerVByte != null + ? BigInt.from(satsPerVByte * 1000) + : feeRatePerKB; + + // minFee should be equal or above the Vsize of the tx, which should happen + // since coin selection algorithms will respect feeRatePerKB. So there is no + // need to define a minFee + final BigInt minFee = BigInt.zero; - if (satoshiAmountToSend.isNegative) { - throw Exception( - "Estimated fee ($feeForOneOutput sats) is greater than balance!", + final List candidates = []; + final Map candidateBaseInputs = {}; + + for (int i = 0; i < candidateInputs.length; i++) { + final baseInput = candidateInputs[i]; + + if (baseInput is! StandardInput) { + // This shouldn't be happening since only non MWEB inputs + // will be given to this helper + throw Exception(''' + Unexpected input type ${baseInput.runtimeType} + only StandardInput are supported + '''); + } + + final input = standardInputToCoinlibInput(baseInput); + + candidates.add( + coinlib.InputCandidate(input: input, value: baseInput.value), ); + candidateBaseInputs[i] = baseInput; + } + + final coinlib.Address clRecipientAddress = coinlib.Address.fromString( + normalizeAddress(recipientAddress), + cryptoCurrency.networkParams, + ); + final coinlib.Output recipientOutput = coinlib.Output.fromAddress( + satoshiAmountToSend, + clRecipientAddress, + ); + + final coinlib.Address clChangeAddress = coinlib.Address.fromString( + normalizeAddress(changeAddress.value), + cryptoCurrency.networkParams, + ); + + final coinlib.Program changeProgram = clChangeAddress.program; + + final coinlib.CoinSelection selection = coinlib.CoinSelection.optimal( + candidates: candidates, + recipients: [recipientOutput], + changeProgram: changeProgram, + feePerKb: feePerKb, + minFee: minFee, + minChange: cryptoCurrency.dustLimit.raw, + ); + + if (selection.tooLarge) { + throw Exception("Selected transaction would be too large"); + } + if (!selection.ready) { + throw Exception("Selection of coins was not successful"); + } + + // Going back from InputCandidates to BaseInput + // This could be avoided since buildTransaction will do the exact opposite ? + final List selectedBaseInputs = []; + for (final picked in selection.selected) { + final pickedTxid = Uint8List.fromList( + picked.input.prevOut.hash.reversed.toList(), + ).toHex; + final pickedVout = picked.input.prevOut.n; + bool matched = false; + for (final entry in candidateBaseInputs.entries) { + final base = entry.value; + if (base is StandardInput && + base.utxo.txid == pickedTxid && + base.utxo.vout == pickedVout) { + selectedBaseInputs.add(base); + matched = true; + break; + } + } + if (!matched) { + throw Exception( + "Selected input not found among candidates (txid=$pickedTxid" + " vout=$pickedVout)", + ); + } } - final data = await buildTransaction( + Logging.instance.d( + "Optimal selection: picked ${selectedBaseInputs.length} input(s)," + " inputValue=${selection.inputValue}, fee=${selection.fee}," + " changeValue=${selection.changeValue}," + " signedSize=${selection.signedSize}", + ); + + /// Add the change if there is one + final List recipientsArray = [recipientAddress]; + final List 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( - [recipientAddress], - [satoshiAmountToSend], + recipientsArray, + recipientsAmtArray, ), + usedUTXOs: selectedBaseInputs, ), - inputsWithKeys: inputsWithKeys, ); - return data.copyWith( + return txBuilt.copyWith( fee: Amount( - rawValue: feeForOneOutput, + rawValue: selection.fee, fractionDigits: cryptoCurrency.fractionDigits, ), - usedUTXOs: inputsWithKeys, + usedUTXOs: selectedBaseInputs, ); } @@ -604,8 +858,8 @@ mixin ElectrumXInterface final code = await (this as PaynymInterface) .paymentCodeStringByKey(address.otherData!); - final bip47base = - await (this as PaynymInterface).getBip47BaseNode(); + final bip47base = await (this as PaynymInterface) + .getBip47BaseNode(); final privateKey = await (this as PaynymInterface) .getPrivateKeyForPaynymReceivingAddress( @@ -658,16 +912,18 @@ mixin ElectrumXInterface final List prevOuts = []; coinlib.Transaction clTx = coinlib.Transaction( - version: txData.type.isMweb() ? 2 : cryptoCurrency.transactionVersion, + vExtraData: txData.vExtraData, + version: + txData.overrideVersion ?? + (txData.type.isMweb() ? 2 : cryptoCurrency.transactionVersion), inputs: [], outputs: [], ); // TODO: [prio=high]: check this opt in rbf - final sequence = - this is RbfInterface && (this as RbfInterface).flagOptInRBF - ? 0xffffffff - 10 - : 0xffffffff - 1; + final sequence = this is RbfInterface && (this as RbfInterface).flagOptInRBF + ? 0xffffffff - 10 + : 0xffffffff - 1; bool isMweb = false; bool hasNonWitnessInput = false; @@ -712,14 +968,6 @@ mixin ElectrumXInterface ), ); } else if (data is StandardInput) { - final txid = data.utxo.txid; - - final hash = Uint8List.fromList( - txid.toUint8ListFromHex.reversed.toList(), - ); - - final prevOutpoint = coinlib.OutPoint(hash, data.utxo.vout); - final prevOutput = coinlib.Output.fromAddress( BigInt.from(data.utxo.value), coinlib.Address.fromString( @@ -730,43 +978,7 @@ mixin ElectrumXInterface prevOuts.add(prevOutput); - final coinlib.Input input; - - switch (data.derivePathType) { - case DerivePathType.bip44: - case DerivePathType.bch44: - input = coinlib.P2PKHInput( - prevOut: prevOutpoint, - publicKey: data.key!.publicKey, - sequence: sequence, - ); - - // TODO: fix this as it is (probably) wrong! - case DerivePathType.bip49: - throw Exception("TODO p2sh"); - // input = coinlib.P2SHMultisigInput( - // prevOut: prevOutpoint, - // program: coinlib.MultisigProgram.decompile( - // data.redeemScript!, - // ), - // sequence: sequence, - // ); - - case DerivePathType.bip84: - input = coinlib.P2WPKHInput( - prevOut: prevOutpoint, - publicKey: data.key!.publicKey, - sequence: sequence, - ); - - case DerivePathType.bip86: - input = coinlib.TaprootKeyInput(prevOut: prevOutpoint); - - default: - throw UnsupportedError( - "Unknown derivation path type found: ${data.derivePathType}", - ); - } + final input = standardInputToCoinlibInput(data, sequence: sequence); if (input is! coinlib.WitnessInput) { hasNonWitnessInput = true; @@ -849,6 +1061,63 @@ mixin ElectrumXInterface ); } + // Add OP_RETURN output if provided (for Rosen Bridge and other protocols) + // Currently only supported for Firo + if (cryptoCurrency is Firo && + txData.opReturnData != null && + txData.opReturnData!.isNotEmpty) { + try { + final opReturnBytes = txData.opReturnData!.toUint8ListFromHex; + + // Validate OP_RETURN size (Bitcoin/Firo limit is 80 bytes) + if (opReturnBytes.length > 80) { + throw Exception( + "OP_RETURN data exceeds 80 byte limit: ${opReturnBytes.length} bytes", + ); + } + + // Encode push data: OP_PUSHDATA1 (0x4c) for 76-80 bytes, direct length otherwise + final pushData = opReturnBytes.length <= 75 + ? Uint8List.fromList([opReturnBytes.length, ...opReturnBytes]) + : Uint8List.fromList([ + 0x4c, + opReturnBytes.length, + ...opReturnBytes, + ]); + + final opReturnScript = Uint8List.fromList([ + 0x6a, // OP_RETURN opcode + ...pushData, + ]); + + final opReturnOutput = coinlib.Output.fromScriptBytes( + BigInt.zero, // OP_RETURN outputs have 0 value + opReturnScript, + ); + + clTx = clTx.addOutput(opReturnOutput); + + Logging.instance.i( + "Added OP_RETURN output with ${opReturnBytes.length} bytes of data", + ); + + tempOutputs.add( + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: opReturnScript.toHex, + valueStringSats: "0", + addresses: [], + walletOwns: false, + ), + ); + } catch (e, s) { + Logging.instance.e( + "Failed to add OP_RETURN output", + error: e, + stackTrace: s, + ); + throw Exception("Invalid OP_RETURN data: $e"); + } + } if (isMweb) { if (hasNonWitnessInput) { throw Exception("Found non witness input in mweb tx"); @@ -907,44 +1176,43 @@ mixin ElectrumXInterface raw: clTx.toHex(), // dirty shortcut for peercoin's weirdness vSize: this is PeercoinWallet ? clTx.size : clTx.vSize(), - tempTx: - txData.type == TxType.mwebPegIn - ? null - : txData.type.isMweb() - ? TransactionV2( - walletId: walletId, - blockHash: null, - hash: clTx.hashHex, - txid: clTx.txid, - height: null, - timestamp: DateTime.timestamp().millisecondsSinceEpoch ~/ 1000, - inputs: List.unmodifiable(tempInputs), - outputs: List.unmodifiable(tempOutputs), - version: clTx.version, - type: TransactionType.outgoing, - subType: TransactionSubType.mweb, - otherData: null, - ) - : TransactionV2( - walletId: walletId, - blockHash: null, - hash: clTx.hashHex, - txid: clTx.txid, - height: null, - timestamp: DateTime.timestamp().millisecondsSinceEpoch ~/ 1000, - inputs: List.unmodifiable(tempInputs), - outputs: List.unmodifiable(tempOutputs), - version: clTx.version, - type: - tempOutputs - .map((e) => e.walletOwns) - .fold(true, (p, e) => p &= e) && - txData.paynymAccountLite == null - ? TransactionType.sentToSelf - : TransactionType.outgoing, - subType: TransactionSubType.none, - otherData: null, - ), + tempTx: txData.type == TxType.mwebPegIn + ? null + : txData.type.isMweb() + ? TransactionV2( + walletId: walletId, + blockHash: null, + hash: clTx.hashHex, + txid: clTx.txid, + height: null, + timestamp: DateTime.timestamp().millisecondsSinceEpoch ~/ 1000, + inputs: List.unmodifiable(tempInputs), + outputs: List.unmodifiable(tempOutputs), + version: clTx.version, + type: TransactionType.outgoing, + subType: TransactionSubType.mweb, + otherData: null, + ) + : TransactionV2( + walletId: walletId, + blockHash: null, + hash: clTx.hashHex, + txid: clTx.txid, + height: null, + timestamp: DateTime.timestamp().millisecondsSinceEpoch ~/ 1000, + inputs: List.unmodifiable(tempInputs), + outputs: List.unmodifiable(tempOutputs), + version: clTx.version, + type: + tempOutputs + .map((e) => e.walletOwns) + .fold(true, (p, e) => p &= e) && + txData.paynymAccountLite == null + ? TransactionType.sentToSelf + : TransactionType.outgoing, + subType: TransactionSubType.none, + otherData: null, + ), ); } @@ -1023,21 +1291,20 @@ mixin ElectrumXInterface } Future updateElectrumX() async { - final failovers = - nodeService - .failoverNodesFor(currency: cryptoCurrency) - .map( - (e) => ElectrumXNode( - address: e.host, - port: e.port, - name: e.name, - id: e.id, - useSSL: e.useSSL, - torEnabled: e.torEnabled, - clearnetEnabled: e.clearnetEnabled, - ), - ) - .toList(); + final failovers = nodeService + .failoverNodesFor(currency: cryptoCurrency) + .map( + (e) => ElectrumXNode( + address: e.host, + port: e.port, + name: e.name, + id: e.id, + useSSL: e.useSSL, + torEnabled: e.torEnabled, + clearnetEnabled: e.clearnetEnabled, + ), + ) + .toList(); final newNode = await _getCurrentElectrumXNode(); try { @@ -1074,7 +1341,7 @@ mixin ElectrumXInterface ) async { final List
addressArray = []; int gapCounter = 0; - int highestIndexWithHistory = 0; + int highestIndexWithHistory = -1; for ( int index = 0; @@ -1118,10 +1385,12 @@ mixin ElectrumXInterface publicKey: keys.publicKey.data, type: addressData.addressType, derivationIndex: index + j, - derivationPath: - isViewOnly ? null : (DerivationPath()..value = derivePath), - subType: - chain == 0 ? AddressSubType.receiving : AddressSubType.change, + derivationPath: isViewOnly + ? null + : (DerivationPath()..value = derivePath), + subType: chain == 0 + ? AddressSubType.receiving + : AddressSubType.change, ); addressArray.add(address); @@ -1163,6 +1432,7 @@ mixin ElectrumXInterface final List
addressArray = []; int gapCounter = 0; int index = 0; + int highestIndexWithHistory = -1; for (; gapCounter < cryptoCurrency.maxUnusedAddressGap; index++) { Logging.instance.d( @@ -1199,8 +1469,9 @@ mixin ElectrumXInterface publicKey: keys.publicKey.data, type: addressData.addressType, derivationIndex: index, - derivationPath: - isViewOnly ? null : (DerivationPath()..value = derivePath), + derivationPath: isViewOnly + ? null + : (DerivationPath()..value = derivePath), subType: chain == 0 ? AddressSubType.receiving : AddressSubType.change, ); @@ -1211,10 +1482,11 @@ mixin ElectrumXInterface ), ); + addressArray.add(address); + // check and add appropriate addresses if (count > 0) { - // add address to array - addressArray.add(address); + highestIndexWithHistory = index; // reset counter gapCounter = 0; // add info to derivations @@ -1224,7 +1496,7 @@ mixin ElectrumXInterface } } - return (addresses: addressArray, index: index); + return (addresses: addressArray, index: highestIndexWithHistory); } Future>> fetchHistory( @@ -1391,21 +1663,18 @@ mixin ElectrumXInterface numberOfBlocksFast: f, numberOfBlocksAverage: m, numberOfBlocksSlow: s, - fast: - Amount.fromDecimal( - fast, - fractionDigits: info.coin.fractionDigits, - ).raw, - medium: - Amount.fromDecimal( - medium, - fractionDigits: info.coin.fractionDigits, - ).raw, - slow: - Amount.fromDecimal( - slow, - fractionDigits: info.coin.fractionDigits, - ).raw, + fast: Amount.fromDecimal( + fast, + fractionDigits: info.coin.fractionDigits, + ).raw, + medium: Amount.fromDecimal( + medium, + fractionDigits: info.coin.fractionDigits, + ).raw, + slow: Amount.fromDecimal( + slow, + fractionDigits: info.coin.fractionDigits, + ).raw, ); Logging.instance.d("fetched fees: $feeObject"); @@ -1478,7 +1747,9 @@ mixin ElectrumXInterface @override Future checkReceivingAddressForTransactions() async { - if (isViewOnly && viewOnlyType == ViewOnlyWalletType.addressOnly) { + if (isViewOnly && + (viewOnlyType == ViewOnlyWalletType.addressOnly || + viewOnlyType == ViewOnlyWalletType.spark)) { return; } @@ -1533,7 +1804,9 @@ mixin ElectrumXInterface @override Future checkChangeAddressForTransactions() async { - if (isViewOnly && viewOnlyType == ViewOnlyWalletType.addressOnly) { + if (isViewOnly && + (viewOnlyType == ViewOnlyWalletType.addressOnly || + viewOnlyType == ViewOnlyWalletType.spark)) { return; } @@ -1642,57 +1915,16 @@ mixin ElectrumXInterface Future.wait(changeFutures), ]); - final receiveResults = futuresResult[0]; - final changeResults = futuresResult[1]; - - final List
addressesToStore = []; - - int highestReceivingIndexWithHistory = 0; - - for (final tuple in receiveResults) { - if (tuple.addresses.isEmpty) { - await checkReceivingAddressForTransactions(); - } else { - highestReceivingIndexWithHistory = max( - tuple.index, - highestReceivingIndexWithHistory, - ); - addressesToStore.addAll(tuple.addresses); - } - } - - 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. - for (final tuple in changeResults) { - if (tuple.addresses.isEmpty) { - await checkChangeAddressForTransactions(); - } else { - highestChangeIndexWithHistory = max( - tuple.index, - highestChangeIndexWithHistory, - ); - addressesToStore.addAll(tuple.addresses); - } - } - - // remove extra addresses to help minimize risk of creating a large gap - addressesToStore.removeWhere( - (e) => - e.subType == AddressSubType.change && - e.derivationIndex > highestChangeIndexWithHistory, - ); - addressesToStore.removeWhere( - (e) => - e.subType == AddressSubType.receiving && - e.derivationIndex > highestReceivingIndexWithHistory, - ); + final List
addressesToStore = processGapCheckResults([ + ...futuresResult[0], + ...futuresResult[1], + ]); await mainDB.updateOrPutAddresses(addressesToStore); if (this is PaynymInterface) { - final notificationAddress = - await (this as PaynymInterface).getMyNotificationAddress(); + final notificationAddress = await (this as PaynymInterface) + .getMyNotificationAddress(); await (this as BitcoinWallet).updateTransactions( overrideAddresses: [notificationAddress], @@ -1824,19 +2056,18 @@ mixin ElectrumXInterface Logging.instance.d("Sent txHash: $txHash"); txData = txData.copyWith( - usedUTXOs: - txData.usedUTXOs!.map((e) { - if (e is StandardInput) { - return StandardInput( - e.utxo.copyWith(used: true), - derivePathType: e.derivePathType, - ); - } else if (e is MwebInput) { - return MwebInput(e.utxo.copyWith(used: true)); - } else { - return e; - } - }).toList(), + usedUTXOs: txData.usedUTXOs!.map((e) { + if (e is StandardInput) { + return StandardInput( + e.utxo.copyWith(used: true), + derivePathType: e.derivePathType, + ); + } else if (e is MwebInput) { + return MwebInput(e.utxo.copyWith(used: true)); + } else { + return e; + } + }).toList(), // TODO revisit setting these both txHash: txHash, @@ -1870,8 +2101,8 @@ mixin ElectrumXInterface final balance = txData.type == TxType.mweb || txData.type == TxType.mwebPegOut - ? info.cachedBalanceSecondary - : info.cachedBalance; + ? info.cachedBalanceSecondary + : info.cachedBalance; final feeRateType = txData.feeRateType; final customSatsPerVByte = txData.satsPerVByte; final feeRateAmount = txData.feeRateAmount; @@ -1980,6 +2211,7 @@ mixin ElectrumXInterface final data = await (this as MwebInterface).processMwebTransaction( mwebData, ); + Logging.instance.d("prepare MWEB send: $data"); return data.copyWith(fee: fee); } @@ -2038,6 +2270,57 @@ mixin ElectrumXInterface } } + @override + Future signMessage( + final String message, { + required final Address address, + }) async { + if (isViewOnly) { + throw Exception("Cannot sign a message in a view only wallet"); + } + + final root = await getRootHDNode(); + final keyPair = root.derivePath(address.derivationPath!.value); + + final signed = coinlib.MessageSignature.sign( + key: keyPair.privateKey, + message: message, + prefix: _cleanEncodedPrefixLength( + cryptoCurrency.networkParams.messagePrefix, + ), + ); + + return base64Encode(signed.signature.compact); + } + + @override + Future verifyMessage( + final String message, { + required final String address, + required final String signature, + }) async { + final signed = coinlib.MessageSignature.fromBase64(signature); + + coinlib.Address clAddress; + try { + clAddress = coinlib.Address.fromString( + normalizeAddress(address), + cryptoCurrency.networkParams, + ); + } catch (e, s) { + Logging.instance.i("$e\n$s"); + return false; + } + + return signed.verifyAddress( + address: clAddress, + message: message, + prefix: _cleanEncodedPrefixLength( + cryptoCurrency.networkParams.messagePrefix, + ), + ); + } + // =========================================================================== // ========== Interface functions ============================================ @@ -2059,6 +2342,22 @@ mixin ElectrumXInterface // =========================================================================== // ========== private helpers ================================================ + String _cleanEncodedPrefixLength(String prefix) { + final messagePrefixBytes = + cryptoCurrency.networkParams.messagePrefix.toUint8ListFromUtf8; + // Check if prefix already has length encoded and remove as coinlib + // recalculates it. Really not ideal.... + // TODO: clean up cryptoCurrency.networkParams.messagePrefix once its + // determined that every usage of messagePrefix does not expect the length + // prefixed. + final ignoreFirstByte = + messagePrefixBytes.first == messagePrefixBytes.length - 1; + return (ignoreFirstByte + ? messagePrefixBytes.sublist(1) + : messagePrefixBytes) + .toUtf8String; + } + List _spendableUTXOs(List utxos) { return utxos .where( @@ -2113,6 +2412,24 @@ mixin ElectrumXInterface return address; } + List
processGapCheckResults( + List<({int index, List
addresses})> results, + ) { + final List
result = []; + for (final tuple in results) { + if (tuple.addresses.isNotEmpty) { + int highestIndexWithHistory = -1; + highestIndexWithHistory = max(tuple.index, highestIndexWithHistory); + + result.addAll( + tuple.addresses.where( + (e) => e.derivationIndex <= highestIndexWithHistory, + ), + ); + } + } + return result; + } // ============== View only ================================================== @override @@ -2120,7 +2437,7 @@ mixin ElectrumXInterface final data = await getViewOnlyWalletData(); final coinlib.HDKey? root; - if (data is AddressViewOnlyWalletData) { + if (data is AddressViewOnlyWalletData || data is SparkViewOnlyWalletData) { root = null; } else { if ((data as ExtendedKeysViewOnlyWalletData).xPubs.length != 1) { @@ -2173,11 +2490,11 @@ mixin ElectrumXInterface receiveFutures.add( canBatch ? checkGapsBatched( - txCountBatchSize, - root, - type, - receiveChain, - ) + txCountBatchSize, + root, + type, + receiveChain, + ) : checkGapsLinearly(root, type, receiveChain), ); } @@ -2197,11 +2514,11 @@ mixin ElectrumXInterface changeFutures.add( canBatch ? checkGapsBatched( - txCountBatchSize, - root, - type, - changeChain, - ) + txCountBatchSize, + root, + type, + changeChain, + ) : checkGapsLinearly(root, type, changeChain), ); } @@ -2213,48 +2530,8 @@ mixin ElectrumXInterface Future.wait(changeFutures), ]); - final receiveResults = futuresResult[0]; - final changeResults = futuresResult[1]; - - int highestReceivingIndexWithHistory = 0; - - for (final tuple in receiveResults) { - if (tuple.addresses.isEmpty) { - await checkReceivingAddressForTransactions(); - } else { - highestReceivingIndexWithHistory = max( - tuple.index, - highestReceivingIndexWithHistory, - ); - addressesToStore.addAll(tuple.addresses); - } - } - - 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. - for (final tuple in changeResults) { - if (tuple.addresses.isEmpty) { - await checkChangeAddressForTransactions(); - } else { - highestChangeIndexWithHistory = max( - tuple.index, - highestChangeIndexWithHistory, - ); - addressesToStore.addAll(tuple.addresses); - } - } - - // remove extra addresses to help minimize risk of creating a large gap - addressesToStore.removeWhere( - (e) => - e.subType == AddressSubType.change && - e.derivationIndex > highestChangeIndexWithHistory, - ); - addressesToStore.removeWhere( - (e) => - e.subType == AddressSubType.receiving && - e.derivationIndex > highestReceivingIndexWithHistory, + addressesToStore.addAll( + processGapCheckResults([...futuresResult[0], ...futuresResult[1]]), ); } else { final clAddress = coinlib.Address.fromString( diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart index 82f45ff347..bfeb24e72f 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart @@ -14,6 +14,7 @@ import '../../../models/input.dart'; import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../models/isar/models/isar_models.dart'; +import '../../../models/isar/ordinal.dart'; import '../../../services/event_bus/events/global/blocks_remaining_event.dart'; import '../../../services/event_bus/events/global/refresh_percent_changed_event.dart'; import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; @@ -185,8 +186,45 @@ mixin MwebInterface Logging.instance.i("info.restoreHeight: ${info.restoreHeight}"); Logging.instance.i( - "info.otherData[WalletInfoKeys.mwebScanHeight]: ${info.otherData[WalletInfoKeys.mwebScanHeight]}", + "info.otherData[WalletInfoKeys.mwebScanHeight]:" + " ${info.otherData[WalletInfoKeys.mwebScanHeight]}", ); + + // ========================================================================= + final List utxos = []; + final stream = await client.utxos( + UtxosRequest( + fromHeight: info.restoreHeight, + scanSecret: await _scanSecret, + ), + ); + try { + await for (final utxo in stream.timeout(const Duration(seconds: 2))) { + final newUtxo = MwebUtxosCompanion( + outputId: Value(utxo.outputId), + address: Value(utxo.address), + value: Value(utxo.value.toInt()), + height: Value(utxo.height), + blockTime: Value(utxo.blockTime), + blocked: const Value(false), + used: const Value(false), + ); + utxos.add(newUtxo); + } + } catch (_) {} + + try { + await stream.cancel(); + } catch (_) {} + final db = Drift.get(walletId); + await db.transaction(() async { + await db.delete(db.mwebUtxos).go(); + for (final utxo in utxos) { + await db.into(db.mwebUtxos).insert(utxo); + } + }); + // ========================================================================= + final fromHeight = info.otherData[WalletInfoKeys.mwebScanHeight] as int? ?? info.restoreHeight; @@ -196,7 +234,6 @@ mixin MwebInterface scanSecret: await _scanSecret, ); - final db = Drift.get(walletId); _mwebUtxoSubscription = (await client.utxos(request)).listen((utxo) async { Logging.instance.t( "Found UTXO in stream: Utxo(" @@ -212,9 +249,9 @@ mixin MwebInterface try { await db.transaction(() async { final prev = - await (db.select(db.mwebUtxos)..where( - (e) => e.outputId.equals(utxo.outputId), - )).getSingleOrNull(); + await (db.select(db.mwebUtxos) + ..where((e) => e.outputId.equals(utxo.outputId))) + .getSingleOrNull(); if (prev == null) { final newUtxo = MwebUtxosCompanion( @@ -254,10 +291,9 @@ mixin MwebInterface blockHash: null, // ?? hash: "", txid: fakeTxid, - timestamp: - utxo.height < 1 - ? DateTime.now().millisecondsSinceEpoch ~/ 1000 - : utxo.blockTime, + timestamp: utxo.height < 1 + ? DateTime.now().millisecondsSinceEpoch ~/ 1000 + : utxo.blockTime, height: utxo.height, inputs: [], outputs: [ @@ -272,13 +308,11 @@ mixin MwebInterface type: TransactionType.incoming, subType: TransactionSubType.mweb, otherData: jsonEncode({ - TxV2OdKeys.overrideFee: - Amount( - rawValue: - BigInt - .zero, // TODO fill in correctly when we have a real txid - fractionDigits: cryptoCurrency.fractionDigits, - ).toJsonString(), + TxV2OdKeys.overrideFee: Amount( + rawValue: BigInt + .zero, // TODO fill in correctly when we have a real txid + fractionDigits: cryptoCurrency.fractionDigits, + ).toJsonString(), }), ); @@ -325,7 +359,8 @@ mixin MwebInterface Future
generateNextMwebAddress({bool isChange = false}) async { if (!info.isMwebEnabled) { throw Exception( - "Tried calling generateNextMwebAddress with mweb disabled for $walletId ${info.name}", + "Tried calling generateNextMwebAddress with mweb " + "disabled for $walletId ${info.name}", ); } @@ -359,21 +394,18 @@ mixin MwebInterface } Future checkMwebSpends() async { - final pending = - await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .filter() - .heightIsNull() - .and() - .blockHashIsNull() - .and() - .subTypeEqualTo(TransactionSubType.mweb) - .and() - .typeEqualTo(TransactionType.outgoing) - .findAll(); - - Logging.instance.f(pending); + final pending = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .heightIsNull() + .and() + .blockHashIsNull() + .and() + .subTypeEqualTo(TransactionSubType.mweb) + .and() + .typeEqualTo(TransactionType.outgoing) + .findAll(); final client = await _client; for (final tx in pending) { @@ -388,14 +420,14 @@ mixin MwebInterface SpentRequest(outputId: [input.outpoint!.txid]), ); if (response.outputId.contains(input.outpoint!.txid)) { - // dummy to show tx as confirmed. Need a better way to handle this as its kind of stupid, resulting in terrible UX + // dummy to show tx as confirmed. Need a better way to handle + // this as its kind of stupid, resulting in terrible UX final dummyHeight = await chainHeight; - TransactionV2? transaction = - await mainDB.isar.transactionV2s - .where() - .txidWalletIdEqualTo(tx.txid, walletId) - .findFirst(); + TransactionV2? transaction = await mainDB.isar.transactionV2s + .where() + .txidWalletIdEqualTo(tx.txid, walletId) + .findFirst(); if (transaction == null || transaction.height == null) { transaction = (transaction ?? tx).copyWith(height: dummyHeight); @@ -487,7 +519,8 @@ mixin MwebInterface Future _confirmSendMweb({required TxData txData}) async { if (!info.isMwebEnabled) { throw Exception( - "Tried calling _confirmSendMweb with mweb disabled for $walletId ${info.name}", + "Tried calling _confirmSendMweb with mweb disabled for" + " $walletId ${info.name}", ); } @@ -504,19 +537,18 @@ mixin MwebInterface Logging.instance.d("Sent txHash: $txHash"); txData = txData.copyWith( - usedUTXOs: - txData.usedUTXOs!.map((e) { - if (e is StandardInput) { - return StandardInput( - e.utxo.copyWith(used: true), - derivePathType: e.derivePathType, - ); - } else if (e is MwebInput) { - return MwebInput(e.utxo.copyWith(used: true)); - } else { - return e; - } - }).toList(), + usedUTXOs: txData.usedUTXOs!.map((e) { + if (e is StandardInput) { + return StandardInput( + e.utxo.copyWith(used: true), + derivePathType: e.derivePathType, + ); + } else if (e is MwebInput) { + return MwebInput(e.utxo.copyWith(used: true)); + } else { + return e; + } + }).toList(), txHash: txHash, txid: txHash, ); @@ -530,8 +562,10 @@ mixin MwebInterface ); // Update used mweb utxos as used in database - final usedMwebUtxos = - txData.usedUTXOs!.whereType().map((e) => e.utxo).toList(); + final usedMwebUtxos = txData.usedUTXOs! + .whereType() + .map((e) => e.utxo) + .toList(); Logging.instance.i("Used mweb inputs: $usedMwebUtxos"); @@ -539,7 +573,11 @@ mixin MwebInterface final db = Drift.get(walletId); await db.transaction(() async { for (final used in usedMwebUtxos) { - await db.update(db.mwebUtxos).replace(used); + await db + .update(db.mwebUtxos) + .replace( + used.copyWith(used: true), + ); // used should already be set to true here but... } }); } @@ -557,10 +595,9 @@ mixin MwebInterface @override Future prepareSend({required TxData txData}) async { - final hasMwebOutputs = - txData.recipients! - .where((e) => e.addressType == AddressType.mweb) - .isNotEmpty; + final hasMwebOutputs = txData.recipients! + .where((e) => e.addressType == AddressType.mweb) + .isNotEmpty; if (hasMwebOutputs) { // assume pegin tx txData = txData.copyWith(type: TxType.mwebPegIn); @@ -571,10 +608,9 @@ mixin MwebInterface /// prepare mweb transaction where spending mweb outputs Future prepareSendMweb({required TxData txData}) async { - final hasMwebOutputs = - txData.recipients! - .where((e) => e.addressType == AddressType.mweb) - .isNotEmpty; + final hasMwebOutputs = txData.recipients! + .where((e) => e.addressType == AddressType.mweb) + .isNotEmpty; final type = hasMwebOutputs ? TxType.mweb : TxType.mwebPegOut; @@ -586,7 +622,8 @@ mixin MwebInterface Future anonymizeAllMweb() async { if (!info.isMwebEnabled) { Logging.instance.e( - "Tried calling anonymizeAllMweb with mweb disabled for $walletId ${info.name}", + "Tried calling anonymizeAllMweb with mweb disabled for" + " $walletId ${info.name}", ); return; } @@ -594,27 +631,39 @@ mixin MwebInterface try { final currentHeight = await chainHeight; - final spendableUtxos = - await mainDB.isar.utxos - .where() - .walletIdEqualTo(walletId) - .filter() - .isBlockedEqualTo(false) - .and() - .group((q) => q.usedEqualTo(false).or().usedIsNull()) - .and() - .valueGreaterThan(0) - .findAll(); + final spendableUtxos = await mainDB.isar.utxos + .where() + .walletIdEqualTo(walletId) + .filter() + .isBlockedEqualTo(false) + .and() + .group((q) => q.usedEqualTo(false).or().usedIsNull()) + .and() + .valueGreaterThan(0) + .findAll(); spendableUtxos.removeWhere( - (e) => - !e.isConfirmed( - currentHeight, - cryptoCurrency.minConfirms, - cryptoCurrency.minCoinbaseConfirms, - ), + (e) => !e.isConfirmed( + currentHeight, + cryptoCurrency.minConfirms, + cryptoCurrency.minCoinbaseConfirms, + ), ); + // Never peg ordinal UTXOs into MWEB. + spendableUtxos.removeWhere((e) { + final ord = mainDB.isar.ordinals + .where() + .filter() + .walletIdEqualTo(walletId) + .and() + .utxoTXIDEqualTo(e.txid) + .and() + .utxoVOUTEqualTo(e.vout) + .findFirstSync(); + return ord != null; + }); + if (spendableUtxos.isEmpty) { throw Exception("No available UTXOs found to anonymize"); } @@ -713,9 +762,9 @@ mixin MwebInterface try { final currentHeight = await chainHeight; final db = Drift.get(walletId); - final mwebUtxos = - await (db.select(db.mwebUtxos) - ..where((e) => e.used.equals(false))).get(); + final mwebUtxos = await (db.select( + db.mwebUtxos, + )..where((e) => e.used.equals(false))).get(); Amount satoshiBalanceTotal = Amount( rawValue: BigInt.zero, @@ -871,53 +920,10 @@ mixin MwebInterface Future.wait(changeFutures), ]); - final receiveResults = futuresResult[0]; - final changeResults = futuresResult[1]; - - final List
addressesToStore = []; - - int highestReceivingIndexWithHistory = 0; - - for (final tuple in receiveResults) { - if (tuple.addresses.isEmpty) { - if (info.otherData[WalletInfoKeys.reuseAddress] != true) { - await checkReceivingAddressForTransactions(); - } - } else { - highestReceivingIndexWithHistory = math.max( - tuple.index, - highestReceivingIndexWithHistory, - ); - addressesToStore.addAll(tuple.addresses); - } - } - - 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. - for (final tuple in changeResults) { - if (tuple.addresses.isEmpty) { - await checkChangeAddressForTransactions(); - } else { - highestChangeIndexWithHistory = math.max( - tuple.index, - highestChangeIndexWithHistory, - ); - addressesToStore.addAll(tuple.addresses); - } - } - - // remove extra addresses to help minimize risk of creating a large gap - addressesToStore.removeWhere( - (e) => - e.subType == AddressSubType.change && - e.derivationIndex > highestChangeIndexWithHistory, - ); - addressesToStore.removeWhere( - (e) => - e.subType == AddressSubType.receiving && - e.derivationIndex > highestReceivingIndexWithHistory, - ); + final List
addressesToStore = processGapCheckResults([ + ...futuresResult[0], + ...futuresResult[1], + ]); await mainDB.updateOrPutAddresses(addressesToStore); }); @@ -962,6 +968,9 @@ mixin MwebInterface 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 client = await _client; final resp = await client.create( @@ -969,7 +978,7 @@ mixin MwebInterface rawTx: txData.raw!.toUint8ListFromHex, scanSecret: await _scanSecret, spendSecret: await _spendSecret, - feeRatePerKb: Int64(txData.feeRateAmount!.toInt()), + feeRatePerKb: Int64(feeRate * 1000), dryRun: true, ), ); @@ -979,18 +988,17 @@ mixin MwebInterface ); BigInt maxBI(BigInt a, BigInt b) => a > b ? a : b; - final posUtxos = - utxos - .where( - (utxo) => processedTx.inputs.any( - (input) => - input.prevOut.hash.toHex == - Uint8List.fromList( - utxo.id.toUint8ListFromHex.reversed.toList(), - ).toHex, - ), - ) - .toList(); + final posUtxos = utxos + .where( + (utxo) => processedTx.inputs.any( + (input) => + input.prevOut.hash.toHex == + Uint8List.fromList( + utxo.id.toUint8ListFromHex.reversed.toList(), + ).toHex, + ), + ) + .toList(); final posOutputSum = processedTx.outputs.fold( BigInt.zero, @@ -1002,14 +1010,11 @@ mixin MwebInterface BigInt feeIncrease = posOutputSum - expectedPegin; if (expectedPegin > BigInt.zero) { - feeIncrease += - BigInt.from((txData.feeRateAmount! / BigInt.from(1000)).ceil()) * - BigInt.from(41); + feeIncrease += BigInt.from(feeRate * 41); } - // bandaid: add one to account for a rounding error that happens sometimes return Amount( - rawValue: fee + feeIncrease + BigInt.one, + rawValue: fee + feeIncrease, fractionDigits: cryptoCurrency.fractionDigits, ); } diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart index 686d1f90a9..f9165fb697 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart @@ -1,53 +1,79 @@ import 'package:isar_community/isar.dart'; import '../../../dto/ordinals/inscription_data.dart'; +import '../../../models/input.dart'; +import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../models/isar/models/blockchain_data/utxo.dart'; import '../../../models/isar/ordinal.dart'; -import '../../../services/litescribe_api.dart'; +import '../../../services/ord_api.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/enums/fee_rate_type_enum.dart'; import '../../../utilities/logger.dart'; import '../../crypto_currency/interfaces/electrumx_currency_interface.dart'; +import '../../models/tx_data.dart'; import 'electrumx_interface.dart'; mixin OrdinalsInterface on ElectrumXInterface { - final LitescribeAPI _litescribeAPI = LitescribeAPI( - baseUrl: 'https://litescribe.io/api', - ); + /// Subclasses must provide the base URL for their ord server. + /// e.g. 'https://ord-litecoin.stackwallet.com' + String get ordServerBaseUrl; - // check if an inscription is in a given output - Future _inscriptionInAddress(String address) async { + late final OrdAPI _ordAPI = OrdAPI(baseUrl: ordServerBaseUrl); + + /// Check whether a specific output contains inscriptions. + Future _inscriptionInOutput(String txid, int vout) async { try { - return (await _litescribeAPI.getInscriptionsByAddress( - address, - )).isNotEmpty; + final ids = await _ordAPI.getInscriptionIdsForOutput(txid, vout); + return ids.isNotEmpty; } catch (e, s) { - Logging.instance.e("Litescribe api failure!", error: e, stackTrace: s); - + Logging.instance.e( + "Ord API output check failure!", + error: e, + stackTrace: s, + ); return false; } } - Future refreshInscriptions({ - List? overrideAddressesToCheck, - }) async { + Future refreshInscriptions() async { try { - final uniqueAddresses = - overrideAddressesToCheck ?? - await mainDB - .getUTXOs(walletId) - .filter() - .addressIsNotNull() - .distinctByAddress() - .addressProperty() - .findAll(); - final inscriptions = await _getInscriptionDataFromAddresses( - uniqueAddresses.cast(), - ); + final utxos = await mainDB.getUTXOs(walletId).findAll(); + + final List allInscriptions = []; + + for (final utxo in utxos) { + try { + final ids = await _ordAPI.getInscriptionIdsForOutput( + utxo.txid, + utxo.vout, + ); + + for (final inscriptionId in ids) { + try { + final json = await _ordAPI.getInscriptionData(inscriptionId); + allInscriptions.add( + InscriptionData.fromOrdJson( + json, + _ordAPI.contentUrl(inscriptionId), + ), + ); + } catch (e) { + Logging.instance.w( + "Failed to fetch inscription $inscriptionId: $e", + ); + } + } + } catch (e) { + Logging.instance.w( + "Failed to check output ${utxo.txid}:${utxo.vout}: $e", + ); + } + } - final ords = - inscriptions - .map((e) => Ordinal.fromInscriptionData(e, walletId)) - .toList(); + final ords = allInscriptions + .map((e) => Ordinal.fromInscriptionData(e, walletId)) + .toList(); await mainDB.isar.writeTxn(() async { await mainDB.isar.ordinals @@ -65,6 +91,70 @@ mixin OrdinalsInterface ); } } + + /// Build a transaction that sends the ordinal UTXO to [recipientAddress]. + /// + /// Uses coin-control send-all from the single ordinal UTXO so the ordinal + /// (at input offset 0) lands on the only output (the recipient) via FIFO. + /// If the UTXO value can't cover the fee, an exception is thrown. + Future prepareOrdinalSend({ + required UTXO ordinalUtxo, + required String recipientAddress, + FeeRateType feeRateType = FeeRateType.average, + }) async { + // Temporarily unblock so coinSelection accepts it. + final wasBlocked = ordinalUtxo.isBlocked; + // utxoForTx is the in-memory object passed to coinSelection; it must have + // isBlocked=false or the spendable-outputs filter will reject it. + UTXO utxoForTx = ordinalUtxo; + if (wasBlocked) { + final unblocked = ordinalUtxo.copyWith( + isBlocked: false, + blockedReason: null, + ); + unblocked.id = ordinalUtxo.id; + await mainDB.putUTXO(unblocked); + utxoForTx = unblocked; + } + + try { + final utxoValue = Amount( + rawValue: BigInt.from(ordinalUtxo.value), + fractionDigits: cryptoCurrency.fractionDigits, + ); + + final txData = TxData( + feeRateType: feeRateType, + recipients: [ + TxRecipient( + address: recipientAddress, + amount: utxoValue, + isChange: false, + addressType: + cryptoCurrency.getAddressType(recipientAddress) ?? + AddressType.unknown, + ), + ], + utxos: {StandardInput(utxoForTx)}, + ignoreCachedBalanceChecks: true, + note: + "Send ordinal #${(await mainDB.isar.ordinals.where().filter().walletIdEqualTo(walletId).and().utxoTXIDEqualTo(ordinalUtxo.txid).and().utxoVOUTEqualTo(ordinalUtxo.vout).findFirst())?.inscriptionNumber ?? "unknown"}", + ); + + return await prepareSend(txData: txData); + } finally { + // Re-block regardless of success or failure. + if (wasBlocked) { + final reblocked = ordinalUtxo.copyWith( + isBlocked: true, + blockedReason: "Ordinal", + ); + reblocked.id = ordinalUtxo.id; + await mainDB.putUTXO(reblocked); + } + } + } + // =================== Overrides ============================================= @override @@ -79,58 +169,20 @@ mixin OrdinalsInterface String? blockReason; String? label; + final txid = jsonTX["txid"] as String; + final vout = jsonUTXO["tx_pos"] as int; final utxoAmount = jsonUTXO["value"] as int; - // TODO: [prio=med] check following 3 todos - - // TODO check the specific output, not just the address in general - // TODO optimize by freezing output in OrdinalsInterface, so one ordinal API calls is made (or at least many less) - if (utxoOwnerAddress != null && - await _inscriptionInAddress(utxoOwnerAddress)) { + if (await _inscriptionInOutput(txid, vout)) { shouldBlock = true; blockReason = "Ordinal"; - label = "Ordinal detected at address"; - } else { - // TODO implement inscriptionInOutput - if (utxoAmount <= 10000) { - shouldBlock = true; - blockReason = "May contain ordinal"; - label = "Possible ordinal"; - } + label = "Ordinal detected at output"; + } else if (utxoAmount <= 10000) { + shouldBlock = true; + blockReason = "May contain ordinal"; + label = "Possible ordinal"; } return (blockedReason: blockReason, blocked: shouldBlock, utxoLabel: label); } - - @override - Future updateUTXOs() async { - final newUtxosAdded = await super.updateUTXOs(); - if (newUtxosAdded) { - try { - await refreshInscriptions(); - } catch (_) { - // do nothing but do not block/fail this updateUTXOs call based on litescribe call failures - } - } - - return newUtxosAdded; - } - - // ===================== Private ============================================= - Future> _getInscriptionDataFromAddresses( - List addresses, - ) async { - final List allInscriptions = []; - for (final String address in addresses) { - try { - final inscriptions = await _litescribeAPI.getInscriptionsByAddress( - address, - ); - allInscriptions.addAll(inscriptions); - } catch (e) { - throw Exception("Error fetching inscriptions for address $address: $e"); - } - } - return allInscriptions; - } } diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart index 0d993036d3..6c815ec12d 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart @@ -91,66 +91,110 @@ mixin PaynymInterface Future
currentReceivingPaynymAddress({ required PaymentCode sender, - required bool isSegwit, + required DerivePathType derivePathType, }) async { final keys = await lookupKey(sender.toString()); - final address = - await mainDB - .getAddresses(walletId) - .filter() - .subTypeEqualTo(AddressSubType.paynymReceive) - .and() - .group((q) { - if (isSegwit) { - return q - .typeEqualTo(AddressType.p2sh) - .or() - .typeEqualTo(AddressType.p2wpkh); - } else { - return q.typeEqualTo(AddressType.p2pkh); - } - }) - .and() - .anyOf( - keys, - (q, String e) => q.otherDataEqualTo(e), - ) - .sortByDerivationIndexDesc() - .findFirst(); + final AddressType filterType; + switch (derivePathType) { + case DerivePathType.bip86: + filterType = AddressType.p2tr; + break; + case DerivePathType.bip84: + filterType = AddressType.p2wpkh; + break; + case DerivePathType.bip44: + default: + filterType = AddressType.p2pkh; + break; + } + + final address = await mainDB + .getAddresses(walletId) + .filter() + .subTypeEqualTo(AddressSubType.paynymReceive) + .and() + .typeEqualTo(filterType) + .and() + .anyOf(keys, (q, String e) => q.otherDataEqualTo(e)) + .sortByDerivationIndexDesc() + .findFirst(); if (address == null) { final generatedAddress = await _generatePaynymReceivingAddress( sender: sender, index: 0, - generateSegwitAddress: isSegwit, + derivePathType: derivePathType, ); - final existing = - await mainDB - .getAddresses(walletId) - .filter() - .valueEqualTo(generatedAddress.value) - .findFirst(); + final existing = await mainDB + .getAddresses(walletId) + .filter() + .valueEqualTo(generatedAddress.value) + .findFirst(); if (existing == null) { - // Add that new address await mainDB.putAddress(generatedAddress); } else { - // we need to update the address await mainDB.updateAddress(existing, generatedAddress); } - return currentReceivingPaynymAddress(isSegwit: isSegwit, sender: sender); + return currentReceivingPaynymAddress( + derivePathType: derivePathType, + sender: sender, + ); } else { return address; } } + /// Convert a compressed public key to a P2TR (taproot) address string. + String _pubKeyToP2TRAddress(Uint8List compressedPubKey) { + final ecPubKey = coinlib.ECPublicKey(compressedPubKey); + final taproot = coinlib.Taproot(internalKey: ecPubKey); + final addr = coinlib.P2TRAddress.fromTaproot( + taproot, + hrp: cryptoCurrency.networkParams.bech32Hrp, + ); + return addr.toString(); + } + + ({String address, AddressType type}) _paynymAddressAndType({ + required PaymentAddress paymentAddress, + required DerivePathType derivePathType, + required bool isSend, + }) { + switch (derivePathType) { + case DerivePathType.bip86: + final pubKey = isSend + ? paymentAddress.getDerivedSendPublicKey() + : paymentAddress.getDerivedReceivePublicKey(); + return ( + address: _pubKeyToP2TRAddress(pubKey), + type: isSend ? AddressType.nonWallet : AddressType.p2tr, + ); + case DerivePathType.bip84: + return ( + address: isSend + ? paymentAddress.getSendAddressP2WPKH() + : paymentAddress.getReceiveAddressP2WPKH(), + type: isSend ? AddressType.nonWallet : AddressType.p2wpkh, + ); + case DerivePathType.bip44: + default: + return ( + address: isSend + ? paymentAddress.getSendAddressP2PKH() + : paymentAddress.getReceiveAddressP2PKH(), + type: isSend ? AddressType.nonWallet : AddressType.p2pkh, + ); + } + } + Future
_generatePaynymReceivingAddress({ required PaymentCode sender, required int index, - required bool generateSegwitAddress, + required DerivePathType derivePathType, }) async { final root = await _getRootNode(); final node = root.derivePath( @@ -164,23 +208,23 @@ mixin PaynymInterface index: 0, ); - final addressString = - generateSegwitAddress - ? paymentAddress.getReceiveAddressP2WPKH() - : paymentAddress.getReceiveAddressP2PKH(); + final result = _paynymAddressAndType( + paymentAddress: paymentAddress, + derivePathType: derivePathType, + isSend: false, + ); final address = Address( walletId: walletId, - value: addressString, + value: result.address, publicKey: [], derivationIndex: index, - derivationPath: - DerivationPath() - ..value = _receivingPaynymAddressDerivationPath( - index, - testnet: info.coin.network.isTestNet, - ), - type: generateSegwitAddress ? AddressType.p2wpkh : AddressType.p2pkh, + derivationPath: DerivationPath() + ..value = _receivingPaynymAddressDerivationPath( + index, + testnet: info.coin.network.isTestNet, + ), + type: result.type, subType: AddressSubType.paynymReceive, otherData: await storeCode(sender.toString()), ); @@ -191,7 +235,7 @@ mixin PaynymInterface Future
_generatePaynymSendAddress({ required PaymentCode other, required int index, - required bool generateSegwitAddress, + required DerivePathType derivePathType, bip32.BIP32? mySendBip32Node, }) async { final node = mySendBip32Node ?? await deriveNotificationBip32Node(); @@ -203,23 +247,23 @@ mixin PaynymInterface index: index, ); - final addressString = - generateSegwitAddress - ? paymentAddress.getSendAddressP2WPKH() - : paymentAddress.getSendAddressP2PKH(); + final result = _paynymAddressAndType( + paymentAddress: paymentAddress, + derivePathType: derivePathType, + isSend: true, + ); final address = Address( walletId: walletId, - value: addressString, + value: result.address, publicKey: [], derivationIndex: index, - derivationPath: - DerivationPath() - ..value = _sendPaynymAddressDerivationPath( - index, - testnet: info.coin.network.isTestNet, - ), - type: AddressType.nonWallet, + derivationPath: DerivationPath() + ..value = _sendPaynymAddressDerivationPath( + index, + testnet: info.coin.network.isTestNet, + ), + type: result.type, subType: AddressSubType.paynymSend, otherData: await storeCode(other.toString()), ); @@ -229,11 +273,11 @@ mixin PaynymInterface Future checkCurrentPaynymReceivingAddressForTransactions({ required PaymentCode sender, - required bool isSegwit, + required DerivePathType derivePathType, }) async { final address = await currentReceivingPaynymAddress( sender: sender, - isSegwit: isSegwit, + derivePathType: derivePathType, ); final txCount = await fetchTxCount( @@ -246,27 +290,24 @@ mixin PaynymInterface final nextAddress = await _generatePaynymReceivingAddress( sender: sender, index: address.derivationIndex + 1, - generateSegwitAddress: isSegwit, + derivePathType: derivePathType, ); - final existing = - await mainDB - .getAddresses(walletId) - .filter() - .valueEqualTo(nextAddress.value) - .findFirst(); + final existing = await mainDB + .getAddresses(walletId) + .filter() + .valueEqualTo(nextAddress.value) + .findFirst(); if (existing == null) { - // Add that new address await mainDB.putAddress(nextAddress); } else { - // we need to update the address await mainDB.updateAddress(existing, nextAddress); } // keep checking until address with no tx history is set as current await checkCurrentPaynymReceivingAddressForTransactions( sender: sender, - isSegwit: isSegwit, + derivePathType: derivePathType, ); } } @@ -278,15 +319,23 @@ mixin PaynymInterface futures.add( checkCurrentPaynymReceivingAddressForTransactions( sender: code, - isSegwit: true, + derivePathType: DerivePathType.bip84, ), ); futures.add( checkCurrentPaynymReceivingAddressForTransactions( sender: code, - isSegwit: false, + derivePathType: DerivePathType.bip44, ), ); + if (code.isTaprootEnabled()) { + futures.add( + checkCurrentPaynymReceivingAddressForTransactions( + sender: code, + derivePathType: DerivePathType.bip86, + ), + ); + } } await Future.wait(futures); } @@ -317,7 +366,10 @@ mixin PaynymInterface } /// fetch or generate this wallet's bip47 payment code - Future getPaymentCode({required bool isSegwit}) async { + Future getPaymentCode({ + required bool isSegwit, + bool isTaproot = false, + }) async { final node = await _getRootNode(); final paymentCode = PaymentCode.fromBip32Node( @@ -325,7 +377,8 @@ mixin PaynymInterface _basePaynymDerivePath(testnet: info.coin.network.isTestNet), ), networkType: networkType, - shouldSetSegwitBit: isSegwit, + shouldSetSegwitBit: isSegwit || isTaproot, + shouldSetTaprootBit: isTaproot, ); return paymentCode; @@ -342,10 +395,23 @@ mixin PaynymInterface } Future signStringWithNotificationKey(String data) async { - final bytes = await signWithNotificationKey( - Uint8List.fromList(utf8.encode(data)), + final myPrivateKeyNode = await deriveNotificationBip32Node(); + final key = coinlib.ECPrivateKey(myPrivateKeyNode.privateKey!); + + // Clean prefix: strip leading length byte if present (coinlib recalculates) + final prefixBytes = + cryptoCurrency.networkParams.messagePrefix.toUint8ListFromUtf8; + final ignoreFirstByte = prefixBytes.first == prefixBytes.length - 1; + final prefix = + (ignoreFirstByte ? prefixBytes.sublist(1) : prefixBytes).toUtf8String; + + final signed = coinlib.MessageSignature.sign( + key: key, + message: data, + prefix: prefix, ); - return Format.uint8listToString(bytes); + + return base64Encode(signed.signature.compact); } Future preparePaymentCodeSend({ @@ -370,10 +436,19 @@ mixin PaynymInterface ); } else { final myPrivateKeyNode = await deriveNotificationBip32Node(); + final DerivePathType sendDeriveType; + if (txData.paynymAccountLite!.taproot) { + sendDeriveType = DerivePathType.bip86; + } else if (txData.paynymAccountLite!.segwit) { + sendDeriveType = DerivePathType.bip84; + } else { + sendDeriveType = DerivePathType.bip44; + } + final sendToAddress = await nextUnusedSendAddressFrom( pCode: paymentCode, privateKeyNode: myPrivateKeyNode, - isSegwit: txData.paynymAccountLite!.segwit, + derivePathType: sendDeriveType, ); return prepareSend( @@ -395,7 +470,7 @@ mixin PaynymInterface /// and your own private key Future
nextUnusedSendAddressFrom({ required PaymentCode pCode, - required bool isSegwit, + required DerivePathType derivePathType, required bip32.BIP32 privateKeyNode, int startIndex = 0, }) async { @@ -404,19 +479,15 @@ mixin PaynymInterface for (int i = startIndex; i < maxCount; i++) { final keys = await lookupKey(pCode.toString()); - final address = - await mainDB - .getAddresses(walletId) - .filter() - .subTypeEqualTo(AddressSubType.paynymSend) - .and() - .anyOf( - keys, - (q, String e) => q.otherDataEqualTo(e), - ) - .and() - .derivationIndexEqualTo(i) - .findFirst(); + final address = await mainDB + .getAddresses(walletId) + .filter() + .subTypeEqualTo(AddressSubType.paynymSend) + .and() + .anyOf(keys, (q, String e) => q.otherDataEqualTo(e)) + .and() + .derivationIndexEqualTo(i) + .findFirst(); if (address != null) { final count = await fetchTxCount( @@ -432,7 +503,7 @@ mixin PaynymInterface final address = await _generatePaynymSendAddress( other: pCode, index: i, - generateSegwitAddress: isSegwit, + derivePathType: derivePathType, mySendBip32Node: privateKeyNode, ); @@ -496,8 +567,21 @@ mixin PaynymInterface ); } - // sort spendable by age (oldest first) - spendableOutputs.sort((a, b) => b.blockTime!.compareTo(a.blockTime!)); + // 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!); + }); BigInt satoshisBeingUsed = BigInt.zero; int outputsBeingUsed = 0; @@ -527,10 +611,9 @@ mixin PaynymInterface } // gather required signing data - final inputsWithKeys = - (await addSigningKeys( - utxoObjectsToUse.map((e) => StandardInput(e)).toList(), - )).whereType().toList(); + final inputsWithKeys = (await addSigningKeys( + utxoObjectsToUse.map((e) => StandardInput(e)).toList(), + )).whereType().toList(); final vSizeForNoChange = BigInt.from( (await _createNotificationTx( @@ -826,8 +909,8 @@ mixin PaynymInterface clTx = clTx.addInput(input); } - final String notificationAddress = - targetPaymentCode.notificationAddressP2PKH(); + final String notificationAddress = targetPaymentCode + .notificationAddressP2PKH(); final address = coinlib.Address.fromString( normalizeAddress(notificationAddress), @@ -995,13 +1078,12 @@ mixin PaynymInterface final myNotificationAddress = await getMyNotificationAddress(); - final txns = - await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .filter() - .subTypeEqualTo(TransactionSubType.bip47Notification) - .findAll(); + final txns = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .subTypeEqualTo(TransactionSubType.bip47Notification) + .findAll(); for (final tx in txns) { switch (tx.type) { @@ -1035,15 +1117,14 @@ mixin PaynymInterface case TransactionType.outgoing: for (final output in tx.outputs) { for (final outputAddress in output.addresses) { - final address = - await mainDB.isar.addresses - .where() - .walletIdEqualTo(walletId) - .filter() - .subTypeEqualTo(AddressSubType.paynymNotification) - .and() - .valueEqualTo(outputAddress) - .findFirst(); + final address = await mainDB.isar.addresses + .where() + .walletIdEqualTo(walletId) + .filter() + .subTypeEqualTo(AddressSubType.paynymNotification) + .and() + .valueEqualTo(outputAddress) + .findFirst(); if (address?.otherData != null) { final code = await paymentCodeStringByKey(address!.otherData!); @@ -1097,8 +1178,8 @@ mixin PaynymInterface final designatedInput = transaction.inputs.first; - final txPoint = - designatedInput.outpoint!.txid.toUint8ListFromHex.reversed.toList(); + final txPoint = designatedInput.outpoint!.txid.toUint8ListFromHex.reversed + .toList(); final txPointIndex = designatedInput.outpoint!.vout; final rev = Uint8List(txPoint.length + 4); @@ -1106,7 +1187,12 @@ mixin PaynymInterface final buffer = rev.buffer.asByteData(); buffer.setUint32(txPoint.length, txPointIndex, Endian.little); - final pubKey = _pubKeyFromInput(designatedInput)!; + final pubKey = _pubKeyFromInput(designatedInput); + + // Taproot inputs don't expose the raw public key — can't compute ECDH. + if (pubKey == null) { + return null; + } final myPrivateKey = (await deriveNotificationBip32Node()).privateKey!; @@ -1156,8 +1242,8 @@ mixin PaynymInterface final designatedInput = transaction.inputs.first; - final txPoint = - designatedInput.outpoint!.txid.toUint8ListFromHex.toList(); + final txPoint = designatedInput.outpoint!.txid.toUint8ListFromHex + .toList(); final txPointIndex = designatedInput.outpoint!.vout; final rev = Uint8List(txPoint.length + 4); @@ -1165,7 +1251,12 @@ mixin PaynymInterface final buffer = rev.buffer.asByteData(); buffer.setUint32(txPoint.length, txPointIndex, Endian.little); - final pubKey = _pubKeyFromInput(designatedInput)!; + final pubKey = _pubKeyFromInput(designatedInput); + + // Taproot inputs don't expose the raw public key — can't compute ECDH. + if (pubKey == null) { + return null; + } final myPrivateKey = (await deriveNotificationBip32Node()).privateKey!; @@ -1202,13 +1293,12 @@ mixin PaynymInterface Future> getAllPaymentCodesFromNotificationTransactions() async { - final txns = - await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .filter() - .subTypeEqualTo(TransactionSubType.bip47Notification) - .findAll(); + final txns = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .subTypeEqualTo(TransactionSubType.bip47Notification) + .findAll(); final List codes = []; @@ -1219,15 +1309,14 @@ mixin PaynymInterface for (final outputAddress in output.addresses.where( (e) => e.isNotEmpty, )) { - final address = - await mainDB.isar.addresses - .where() - .walletIdEqualTo(walletId) - .filter() - .subTypeEqualTo(AddressSubType.paynymNotification) - .and() - .valueEqualTo(outputAddress) - .findFirst(); + final address = await mainDB.isar.addresses + .where() + .walletIdEqualTo(walletId) + .filter() + .subTypeEqualTo(AddressSubType.paynymNotification) + .and() + .valueEqualTo(outputAddress) + .findFirst(); if (address?.otherData != null) { final codeString = await paymentCodeStringByKey( @@ -1273,15 +1362,14 @@ mixin PaynymInterface Future checkForNotificationTransactionsTo( Set otherCodeStrings, ) async { - final sentNotificationTransactions = - await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .filter() - .subTypeEqualTo(TransactionSubType.bip47Notification) - .and() - .typeEqualTo(TransactionType.outgoing) - .findAll(); + final sentNotificationTransactions = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .subTypeEqualTo(TransactionSubType.bip47Notification) + .and() + .typeEqualTo(TransactionType.outgoing) + .findAll(); final List codes = []; for (final codeString in otherCodeStrings) { @@ -1353,12 +1441,19 @@ mixin PaynymInterface final List> futures = []; for (final code in codes) { + final types = [DerivePathType.bip44]; + if (code.isSegWitEnabled()) { + types.add(DerivePathType.bip84); + } + if (code.isTaprootEnabled()) { + types.add(DerivePathType.bip86); + } futures.add( _restoreHistoryWith( other: code, maxUnusedAddressGap: maxUnusedAddressGap, maxNumberOfIndexesToCheck: maxNumberOfIndexesToCheck, - checkSegwitAsWell: code.isSegWitEnabled(), + derivePathTypes: types, ), ); } @@ -1368,159 +1463,81 @@ mixin PaynymInterface Future _restoreHistoryWith({ required PaymentCode other, - required bool checkSegwitAsWell, + required List derivePathTypes, required int maxUnusedAddressGap, required int maxNumberOfIndexesToCheck, }) async { - // https://en.bitcoin.it/wiki/BIP_0047#Path_levels const maxCount = 2147483647; assert(maxNumberOfIndexesToCheck < maxCount); final mySendBip32Node = await deriveNotificationBip32Node(); - final List
addresses = []; - int receivingGapCounter = 0; - int outgoingGapCounter = 0; - - // non segwit receiving - for ( - int i = 0; - i < maxNumberOfIndexesToCheck && - receivingGapCounter < maxUnusedAddressGap; - i++ - ) { - if (receivingGapCounter < maxUnusedAddressGap) { + + for (final derivePathType in derivePathTypes) { + int receivingGap = 0; + for ( + int i = 0; + i < maxNumberOfIndexesToCheck && receivingGap < maxUnusedAddressGap; + i++ + ) { final address = await _generatePaynymReceivingAddress( sender: other, index: i, - generateSegwitAddress: false, + derivePathType: derivePathType, ); - addresses.add(address); - final count = await fetchTxCount( addressScriptHash: cryptoCurrency.addressToScriptHash( address: address.value, ), ); - if (count > 0) { - receivingGapCounter = 0; + receivingGap = 0; } else { - receivingGapCounter++; + receivingGap++; } } - } - // non segwit sends - for ( - int i = 0; - i < maxNumberOfIndexesToCheck && outgoingGapCounter < maxUnusedAddressGap; - i++ - ) { - if (outgoingGapCounter < maxUnusedAddressGap) { + int outgoingGap = 0; + for ( + int i = 0; + i < maxNumberOfIndexesToCheck && outgoingGap < maxUnusedAddressGap; + i++ + ) { final address = await _generatePaynymSendAddress( other: other, index: i, - generateSegwitAddress: false, + derivePathType: derivePathType, mySendBip32Node: mySendBip32Node, ); - addresses.add(address); - final count = await fetchTxCount( addressScriptHash: cryptoCurrency.addressToScriptHash( address: address.value, ), ); - if (count > 0) { - outgoingGapCounter = 0; + outgoingGap = 0; } else { - outgoingGapCounter++; + outgoingGap++; } } } - if (checkSegwitAsWell) { - int receivingGapCounterSegwit = 0; - int outgoingGapCounterSegwit = 0; - // segwit receiving - for ( - int i = 0; - i < maxNumberOfIndexesToCheck && - receivingGapCounterSegwit < maxUnusedAddressGap; - i++ - ) { - if (receivingGapCounterSegwit < maxUnusedAddressGap) { - final address = await _generatePaynymReceivingAddress( - sender: other, - index: i, - generateSegwitAddress: true, - ); - - addresses.add(address); - - final count = await fetchTxCount( - addressScriptHash: cryptoCurrency.addressToScriptHash( - address: address.value, - ), - ); - - if (count > 0) { - receivingGapCounterSegwit = 0; - } else { - receivingGapCounterSegwit++; - } - } - } - - // segwit sends - for ( - int i = 0; - i < maxNumberOfIndexesToCheck && - outgoingGapCounterSegwit < maxUnusedAddressGap; - i++ - ) { - if (outgoingGapCounterSegwit < maxUnusedAddressGap) { - final address = await _generatePaynymSendAddress( - other: other, - index: i, - generateSegwitAddress: true, - mySendBip32Node: mySendBip32Node, - ); - - addresses.add(address); - - final count = await fetchTxCount( - addressScriptHash: cryptoCurrency.addressToScriptHash( - address: address.value, - ), - ); - - if (count > 0) { - outgoingGapCounterSegwit = 0; - } else { - outgoingGapCounterSegwit++; - } - } - } - } await mainDB.updateOrPutAddresses(addresses); } Future
getMyNotificationAddress() async { - final storedAddress = - await mainDB - .getAddresses(walletId) - .filter() - .subTypeEqualTo(AddressSubType.paynymNotification) - .and() - .typeEqualTo(AddressType.p2pkh) - .and() - .not() - .typeEqualTo(AddressType.nonWallet) - .findFirst(); + final storedAddress = await mainDB + .getAddresses(walletId) + .filter() + .subTypeEqualTo(AddressSubType.paynymNotification) + .and() + .typeEqualTo(AddressType.p2pkh) + .and() + .not() + .typeEqualTo(AddressType.nonWallet) + .findFirst(); if (storedAddress != null) { return storedAddress; @@ -1539,19 +1556,20 @@ mixin PaynymInterface pubkey: paymentCode.notificationPublicKey(), ); - final addressString = - btc_dart.P2PKH(data: data, network: networkType).data.address!; + final addressString = btc_dart + .P2PKH(data: data, network: networkType) + .data + .address!; Address address = Address( walletId: walletId, value: addressString, publicKey: paymentCode.getPubKey(), derivationIndex: 0, - derivationPath: - DerivationPath() - ..value = _notificationDerivationPath( - testnet: info.coin.network.isTestNet, - ), + derivationPath: DerivationPath() + ..value = _notificationDerivationPath( + testnet: info.coin.network.isTestNet, + ), type: AddressType.p2pkh, subType: AddressSubType.paynymNotification, otherData: await storeCode(paymentCode.toString()), @@ -1562,17 +1580,16 @@ mixin PaynymInterface // beginning to see if there already was notification address. This would // lead to a Unique Index violation error await mainDB.isar.writeTxn(() async { - final storedAddress = - await mainDB - .getAddresses(walletId) - .filter() - .subTypeEqualTo(AddressSubType.paynymNotification) - .and() - .typeEqualTo(AddressType.p2pkh) - .and() - .not() - .typeEqualTo(AddressType.nonWallet) - .findFirst(); + final storedAddress = await mainDB + .getAddresses(walletId) + .filter() + .subTypeEqualTo(AddressSubType.paynymNotification) + .and() + .typeEqualTo(AddressType.p2pkh) + .and() + .not() + .typeEqualTo(AddressType.nonWallet) + .findFirst(); if (storedAddress == null) { await mainDB.isar.addresses.put(address); @@ -1650,21 +1667,19 @@ mixin PaynymInterface overrideAddresses ?? await fetchAddressesForElectrumXScan(); // Separate receiving and change addresses. - final Set receivingAddresses = - allAddressesOld - .where( - (e) => - e.subType == AddressSubType.receiving || - e.subType == AddressSubType.paynymNotification || - e.subType == AddressSubType.paynymReceive, - ) - .map((e) => e.value) - .toSet(); - final Set changeAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.change) - .map((e) => e.value) - .toSet(); + final Set receivingAddresses = allAddressesOld + .where( + (e) => + e.subType == AddressSubType.receiving || + e.subType == AddressSubType.paynymNotification || + e.subType == AddressSubType.paynymReceive, + ) + .map((e) => e.value) + .toSet(); + final Set changeAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.change) + .map((e) => e.value) + .toSet(); // Remove duplicates. final allAddressesSet = {...receivingAddresses, ...changeAddresses}; @@ -1674,16 +1689,15 @@ mixin PaynymInterface allAddressesSet, ); - final unconfirmedTxs = - await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .filter() - .heightIsNull() - .or() - .heightEqualTo(0) - .txidProperty() - .findAll(); + final unconfirmedTxs = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .heightIsNull() + .or() + .heightEqualTo(0) + .txidProperty() + .findAll(); allTxHashes.addAll(unconfirmedTxs.map((e) => {"tx_hash": e})); @@ -1715,13 +1729,12 @@ mixin PaynymInterface "'message': 'No such mempool or blockchain transaction", )) { await mainDB.isar.writeTxn( - () async => - await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .filter() - .txidEqualTo(txid) - .deleteFirst(), + () async => await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .txidEqualTo(txid) + .deleteFirst(), ); continue; } else { diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/sign_verify_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/sign_verify_interface.dart new file mode 100644 index 0000000000..fdceb34615 --- /dev/null +++ b/lib/wallets/wallet/wallet_mixin_interfaces/sign_verify_interface.dart @@ -0,0 +1,14 @@ +import '../../../models/isar/models/blockchain_data/address.dart'; + +mixin SignVerifyInterface { + Future signMessage( + final String message, { + required final Address address, + }); + + Future verifyMessage( + final String message, { + required final String address, + required final String signature, + }); +} diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index d9059a5bb1..193bb133f1 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -1,8 +1,11 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:isolate'; import 'dart:math'; import 'package:bitcoindart/bitcoindart.dart' as btc; +import 'package:bitcoindart/src/utils/script.dart' as bscript; +import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib; import 'package:decimal/decimal.dart'; import 'package:flutter/foundation.dart'; import 'package:isar_community/isar.dart'; @@ -11,11 +14,13 @@ import 'package:logger/logger.dart'; import '../../../db/drift/database.dart' show Drift; import '../../../db/sqlite/firo_cache.dart'; import '../../../models/balance.dart'; +import '../../../models/electrumx_response/spark_models.dart'; import '../../../models/input.dart'; import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../models/isar/models/isar_models.dart'; +import '../../../models/keys/view_only_wallet_data.dart'; import '../../../services/event_bus/events/global/refresh_percent_changed_event.dart'; import '../../../services/event_bus/global_event_bus.dart'; import '../../../services/spark_names_service.dart'; @@ -33,11 +38,13 @@ import '../../models/tx_data.dart'; import '../intermediate/bip39_hd_wallet.dart'; import 'cpfp_interface.dart'; import 'electrumx_interface.dart'; +import 'spark_spend_planner.dart'; const kDefaultSparkIndex = 1; // TODO dart style constants. Maybe move to spark lib? -const MAX_STANDARD_TX_WEIGHT = 400000; +// https://github.com/firoorg/firo/pull/1457/files#diff-1fc0f6b5081e8ed5dfa8bf230744ad08cc6f4c1147e98552f1f424b0492fe9bdR28 +const MAX_NEW_TX_WEIGHT = 1000000; //https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/include/spark.h#L16 const SPARK_OUT_LIMIT_PER_TX = 16; @@ -45,6 +52,49 @@ const SPARK_OUT_LIMIT_PER_TX = 16; const OP_SPARKMINT = 0xd1; const OP_SPARKSMINT = 0xd2; const OP_SPARKSPEND = 0xd3; +const OP_SPARKNAMEID = 0xe1; +const OP_DROP = 0x75; + +const _maxSingleInputSparkTransactions = 50; +const _sparkChaumV2ActivationHeight = 1371000; + +@visibleForTesting +LibSparkSpendVersion sparkSpendVersionForNextBlock({ + required CryptoCurrencyNetwork network, + required int nextBlockHeight, +}) { + if (network != .main) return .chaumV1; + + return libSpark.getSpendVersionForBlockHeight( + nextBlockHeight: nextBlockHeight, + chaumV2ActivationHeight: _sparkChaumV2ActivationHeight, + ); +} + +@visibleForTesting +bool isChaumV2SparkTransactionVersion(int transactionVersion) => + transactionVersion == LibSparkSpendVersion.chaumV2.transactionVersion; + +LibSparkNameProofInput _sparkNameProofInput({ + required LibSparkSpendVersion spendVersion, + required String inputHex, +}) => switch (spendVersion) { + .chaumV1 => .chaumV1(scalarHex: inputHex), + .chaumV2 => .chaumV2(ownershipDigest: inputHex), +}; + +int _compareSparkCoinsForSingleInputSpend(SparkCoin a, SparkCoin b) { + int result = b.value.compareTo(a.value); + if (result != 0) return result; + + result = a.height!.compareTo(b.height!); + if (result != 0) return result; + + result = a.txHash.compareTo(b.txHash); + if (result != 0) return result; + + return a.lTagHash.compareTo(b.lTagHash); +} /// top level function for use with [compute] String _hashTag(String tag) { @@ -56,6 +106,28 @@ String _hashTag(String tag) { return hash; } +@visibleForTesting +Uint8List sparkNameFeeScript({ + required Uint8List baseScript, + required String name, + required String sparkAddress, +}) => Uint8List.fromList([ + ...baseScript, + ...bscript.compile([ + OP_SPARKNAMEID, + Uint8List.fromList(utf8.encode(name)), + OP_DROP, + Uint8List.fromList(utf8.encode(sparkAddress)), + OP_DROP, + ]), +]); + +@visibleForTesting +bool shouldSubtractSparkFeeFromAmount({ + required bool isSparkNameRegistration, + required bool spendsAll, +}) => !isSparkNameRegistration && spendsAll; + void initSparkLogging(Level level) => libSpark.initSparkLogging(level); abstract class _SparkIsolate { @@ -63,7 +135,18 @@ abstract class _SparkIsolate { static SendPort? _sendPort; static final ReceivePort _receivePort = ReceivePort(); + static Completer? completer; + static Future initialize() async { + if (completer != null) { + if (!completer!.isCompleted) { + await completer!.future; + } + + return; + } + completer = Completer(); + final level = Prefs.instance.logLevel; _isolate = await Isolate.spawn((SendPort sendPort) { @@ -85,6 +168,7 @@ abstract class _SparkIsolate { }); }, _receivePort.sendPort); _sendPort = await _receivePort.first as SendPort; + completer!.complete(); } static Future run(ComputeCallback task, M argument) async { @@ -105,20 +189,169 @@ Future computeWithLibSparkLogging( mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { - String? _sparkChangeAddressCached; + Address? _currentSparkAddress; + + String? _viewKeyHex; + String? get sparkViewKey => _viewKeyHex!; + + Address? _sparkChangeAddress; + + String? get sparkChangeAddress => _sparkChangeAddress?.value; - /// Spark change address. Should generally not be exposed to end users. - String get sparkChangeAddress { - if (_sparkChangeAddressCached == null) { - throw Exception("_sparkChangeAddressCached was not initialized"); + bool get isTestNet { + return cryptoCurrency.network.isTestNet; + } + + // This is the BIP44 derivation path for the spark private key; spark public + // keys will have their own derivation path. + String get sparkDerivationPath { + // NOTE: This is reusing the sparkIndex for backwards compatibility, but + // these are actually distinct things which do not have to be the same. + // sparkIndex has nothing at all to do with the derivation path. + if (isTestNet) { + return "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex"; + } else { + return "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex"; } - return _sparkChangeAddressCached!; + } + + // This is the index for the spark key, which is NOT the diversifier or the + // BIP44 derivation path (which generates the private key data). + int get sparkIndex => kDefaultSparkIndex; + + Future
_generateSparkAddress(int diversifier) async { + if (isViewOnly && viewOnlyType != .spark) { + throw Exception( + "Cannot generate a spark address for a non spark view only firo wallet", + ); + } + + final sparkAddress = + await computeWithLibSparkLogging(_getAddressFromFullViewKey, ( + fullViewKeyHex: _viewKeyHex!, + index: sparkIndex, + diversifier: diversifier, + isTestNet: isTestNet, + )); + + return Address( + walletId: walletId, + value: sparkAddress, + publicKey: [], + derivationIndex: diversifier, + derivationPath: DerivationPath()..value = sparkDerivationPath, + type: AddressType.spark, + subType: diversifier == libSpark.sparkChange ? .change : .receiving, + ); } static bool validateSparkAddress({ required String address, required bool isTestNet, - }) => libSpark.validateAddress(address: address, isTestNet: isTestNet); + }) { + return libSpark.validateAddress(address: address, isTestNet: isTestNet); + } + + Future> identifyCoins({ + required List anonymitySetCoins, + required int groupId, + }) async { + return await computeWithLibSparkLogging(identifyCoinsStatic, ( + walletId_: walletId, + viewKeyHex_: _viewKeyHex!, + isTestNet_: isTestNet, + anonymitySetCoins: anonymitySetCoins, + groupId: groupId, + )); + } + + static Future> identifyCoinsStatic( + ({ + List anonymitySetCoins, + int groupId, + bool isTestNet_, + String viewKeyHex_, + String walletId_, + }) + args, + ) async { + final List myCoins = []; + + for (final dynData in args.anonymitySetCoins) { + final data = List.from(dynData as List); + + if (data.length != 3) { + Logging.instance.e( + "Unexpected serialized coin info found", + error: data, + ); + continue; + } + + final serializedCoinB64 = data[0]; + final txHash = data[1].toHexReversedFromBase64; + final contextB64 = data[2]; + + final WrappedLibSparkCoin? coin; + try { + coin = libSpark.identifyAndRecoverCoinByFullViewKey( + serializedCoinB64, + fullViewKeyHex: args.viewKeyHex_, + context: base64Decode(contextB64), + isTestNet: args.isTestNet_, + ); + } catch (e) { + Logging.instance.e( + "Error identifying coin in tx $txHash (this is not expected)", + error: e, + ); + continue; + } + + // its ours + if (coin != null) { + final SparkCoinType coinType; + switch (coin.type.value) { + case 0: + coinType = SparkCoinType.mint; + case 1: + coinType = SparkCoinType.spend; + default: + Logging.instance.e( + "Unknown spark coin type detected", + error: coin.type.value, + ); + continue; + } + + myCoins.add( + SparkCoin( + walletId: args.walletId_, + type: coinType, + // isUsed is a placeholder value here; its value is incorrect. + isUsed: false, + groupId: args.groupId, + nonce: coin.nonceHex?.toUint8ListFromHex, + address: coin.address!, + txHash: txHash, + valueIntString: coin.value!.toString(), + memo: coin.memo, + serialContext: coin.serialContext, + diversifierIntString: coin.diversifier!.toString(), + encryptedDiversifier: coin.encryptedDiversifier, + serial: coin.serial, + tag: coin.tag, + lTagHash: coin.lTagHash!, + height: coin.height, + serializedCoinB64: serializedCoinB64, + contextB64: contextB64, + ), + ); + } + } + + return myCoins; + } Future hashTag(String tag) async { try { @@ -130,11 +363,16 @@ mixin SparkInterface @override Future init() async { + if (isViewOnly && viewOnlyType != .spark) { + return super.init(); + } + try { final sparkUsedTagsResetVersion = info.otherData[WalletInfoKeys.firoSparkUsedTagsCacheResetVersion] as int? ?? 0; + if (sparkUsedTagsResetVersion == 0) { await info.updateOtherData( newEntries: {WalletInfoKeys.firoSparkUsedTagsCacheResetVersion: 1}, @@ -146,47 +384,53 @@ mixin SparkInterface ); } + if (isViewOnly) { + final walletData = await getViewOnlyWalletData(); + if (walletData is SparkViewOnlyWalletData) { + _viewKeyHex = walletData.viewKey; + } else { + // TODO anything needed here? + } + } else { + final root = await getRootHDNode(); + final privateKey = root.derivePath(sparkDerivationPath).privateKey.data; + _viewKeyHex = libSpark.getFullViewKeyHexFromPrivateKeyData( + privateKeyHex: privateKey.toHex, + index: sparkIndex, + ); + } + Address? address = await getCurrentReceivingSparkAddress(); if (address == null) { - address = await generateNextSparkAddress(); + address = await _generateSparkAddress(1); await mainDB.putAddress(address); - } // TODO add other address types to wallet info? - - if (_sparkChangeAddressCached == null) { - final root = await getRootHDNode(); - final String derivationPath; - if (cryptoCurrency.network.isTestNet) { - derivationPath = - "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex"; - } else { - derivationPath = - "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex"; + if (isViewOnly && + viewOnlyType == .spark && + info.mainAddressType == .spark) { + await info.updateReceivingAddress( + newAddress: address.value, + isar: mainDB.isar, + ); } - final keys = root.derivePath(derivationPath); + } - _sparkChangeAddressCached = await libSpark.getAddress( - privateKey: keys.privateKey.data, - index: kDefaultSparkIndex, - diversifier: libSpark.sparkChange, - isTestNet: cryptoCurrency.network.isTestNet, - ); + if (address.derivationIndex == -1) { + throw Exception("Error finding spark receiving address"); } + + _currentSparkAddress = address; + _sparkChangeAddress = await _generateSparkAddress(libSpark.sparkChange); } catch (e, s) { // do nothing, still allow user into wallet Logging.instance.e("$runtimeType init() failed", error: e, stackTrace: s); } - // await info.updateReceivingAddress( - // newAddress: address.value, - // isar: mainDB.isar, - // ); - await super.init(); } @override Future> fetchAddressesForElectrumXScan() async { - final allAddresses = await mainDB + return await mainDB .getAddresses(walletId) .filter() .not() @@ -199,63 +443,56 @@ mixin SparkInterface .subTypeEqualTo(AddressSubType.nonWallet), ) .findAll(); - return allAddresses; } Future getCurrentReceivingSparkAddress() async { - return await mainDB.isar.addresses - .where() - .walletIdEqualTo(walletId) - .filter() - .typeEqualTo(AddressType.spark) - .sortByDerivationIndexDesc() - .findFirst(); + try { + // if _currentSparkAddress is not initialized, this will throw. + return _currentSparkAddress!; + } catch (e) { + return await mainDB.isar.addresses + .where() + .walletIdEqualTo(walletId) + .filter() + .typeEqualTo(AddressType.spark) + .sortByDerivationIndexDesc() + .findFirst(); + } } - Future
generateNextSparkAddress() async { - final highestStoredDiversifier = + Future
generateNextSparkAddress({required bool saveToDB}) async { + final currentDiversifier = (await getCurrentReceivingSparkAddress())?.derivationIndex; - - // default to starting at 1 if none found - int diversifier = (highestStoredDiversifier ?? 0) + 1; - // change address check + // if current is null, start at index 1 + int diversifier = (currentDiversifier ?? 0) + 1; if (diversifier == libSpark.sparkChange) { - diversifier++; + diversifier++; // ensure only receiving addresses are shown } - - final root = await getRootHDNode(); - final String derivationPath; - if (cryptoCurrency.network.isTestNet) { - derivationPath = - "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex"; - } else { - derivationPath = "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex"; + final newAddress = await _generateSparkAddress(diversifier); + _currentSparkAddress = newAddress; + if (saveToDB) { + await mainDB.updateOrPutAddresses([newAddress]); + if (isViewOnly && + viewOnlyType == .spark && + info.mainAddressType == .spark) { + await info.updateReceivingAddress( + newAddress: newAddress.value, + isar: mainDB.isar, + ); + } } - final keys = root.derivePath(derivationPath); - final String addressString = await libSpark.getAddress( - privateKey: keys.privateKey.data, - index: kDefaultSparkIndex, - diversifier: diversifier, - isTestNet: cryptoCurrency.network.isTestNet, - ); - - return Address( - walletId: walletId, - value: addressString, - publicKey: keys.publicKey.data, - derivationIndex: diversifier, - derivationPath: DerivationPath()..value = derivationPath, - type: AddressType.spark, - subType: AddressSubType.receiving, - ); + return newAddress; } Future estimateFeeForSpark(Amount amount) async { - final spendAmount = amount.raw.toInt(); - if (spendAmount == 0) { + if (isViewOnly) { + throw Exception("Fee estimation is not supported for view only wallets"); + } + + if (amount.raw <= BigInt.zero) { return Amount( - rawValue: BigInt.from(0), + rawValue: BigInt.zero, fractionDigits: cryptoCurrency.fractionDigits, ); } else { @@ -271,6 +508,13 @@ mixin SparkInterface .not() .valueIntStringEqualTo("0") .findAll(); + if (coins.isEmpty) { + return Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + } + coins.sort(_compareSparkCoinsForSingleInputSpend); final available = coins .map((e) => e.value) @@ -278,59 +522,352 @@ mixin SparkInterface if (amount.raw > available) { return Amount( - rawValue: BigInt.from(0), + rawValue: BigInt.zero, fractionDigits: cryptoCurrency.fractionDigits, ); } - // prepare coin data for ffi - final serializedCoins = coins - .map( - (e) => ( - serializedCoin: e.serializedCoinB64!, - serializedCoinContext: e.contextB64!, - groupId: e.groupId, - height: e.height!, - ), - ) - .toList(); - final root = await getRootHDNode(); - final String derivationPath; - if (cryptoCurrency.network.isTestNet) { - derivationPath = - "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex"; - } else { - derivationPath = - "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex"; - } - final privateKey = root.derivePath(derivationPath).privateKey.data; - int estimate = await _asyncSparkFeesWrapper( - privateKeyHex: privateKey.toHex, - index: kDefaultSparkIndex, - sendAmount: spendAmount, - subtractFeeFromAmount: true, - serializedCoins: serializedCoins, - // privateRecipientsCount: (txData.sparkRecipients?.length ?? 0), - privateRecipientsCount: 1, // ROUGHLY! - utxoNum: 0, // TODO not zero? - additionalTxSize: 0, // spark name script size + final privateKey = root.derivePath(sparkDerivationPath).privateKey.data; + final chainTipHeight = await fetchChainHeight(); + final spendVersion = sparkSpendVersionForNextBlock( + network: cryptoCurrency.network, + nextBlockHeight: chainTipHeight + 1, ); - if (estimate < 0) { - estimate = 0; + if (spendVersion.allowsMultipleInputs) { + final serializedCoins = coins + .map( + (e) => ( + serializedCoin: e.serializedCoinB64!, + serializedCoinContext: e.contextB64!, + groupId: e.groupId, + height: e.height!, + ), + ) + .toList(); + int estimate = await _asyncSparkFeesWrapper( + privateKeyHex: privateKey.toHex, + index: sparkIndex, + sendAmount: amount.raw.toInt(), + subtractFeeFromAmount: true, + serializedCoins: serializedCoins, + privateRecipientsCount: 1, + utxoNum: 0, + additionalTxSize: 0, + spendVersion: spendVersion, + ); + if (estimate < 0) estimate = 0; + + return Amount( + rawValue: BigInt.from(estimate), + fractionDigits: cryptoCurrency.fractionDigits, + ); + } + + final serializedCoins = [ + ( + serializedCoin: coins.first.serializedCoinB64!, + serializedCoinContext: coins.first.contextB64!, + groupId: coins.first.groupId, + height: coins.first.height!, + ), + ]; + BigInt singleInputFee = BigInt.from( + await _asyncSparkFeesWrapper( + privateKeyHex: privateKey.toHex, + index: sparkIndex, + sendAmount: 1, + subtractFeeFromAmount: true, + serializedCoins: serializedCoins, + privateRecipientsCount: 1, + utxoNum: 0, + additionalTxSize: 0, + spendVersion: spendVersion, + ), + ); + + if (singleInputFee < BigInt.zero) singleInputFee = BigInt.zero; + + int transactionCount = 0; + BigInt remaining = amount.raw; + if (remaining == available) { + transactionCount = coins.length; + } else { + for (final coin in coins) { + final capacity = coin.value - singleInputFee; + if (capacity <= BigInt.zero) continue; + + transactionCount++; + remaining -= remaining < capacity ? remaining : capacity; + if (remaining == BigInt.zero) break; + } + } + if (remaining > BigInt.zero && amount.raw != available) { + return Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); } return Amount( - rawValue: BigInt.from(estimate), + rawValue: singleInputFee * BigInt.from(transactionCount), fractionDigits: cryptoCurrency.fractionDigits, ); } } /// Spark to Spark/Transparent (spend) creation - Future prepareSendSpark({required TxData txData}) async { - // There should be at least one output. + Future prepareSendSpark({ + required TxData txData, + bool requireChaumV2 = false, + }) async { + if (isViewOnly) { + throw Exception("Spending is not supported for view only wallets"); + } + + final transparentRecipients = txData.recipients ?? []; + final privateRecipients = txData.sparkRecipients ?? []; + if (transparentRecipients.isEmpty && privateRecipients.isEmpty) { + throw Exception("No recipients provided."); + } + if (transparentRecipients.any((e) => e.amount.raw <= BigInt.zero) || + privateRecipients.any((e) => e.amount.raw <= BigInt.zero)) { + throw Exception("Recipient has invalid amount."); + } + + final lockTime = await fetchChainHeight(); + final spendVersion = sparkSpendVersionForNextBlock( + network: cryptoCurrency.network, + nextBlockHeight: lockTime + 1, + ); + if (requireChaumV2 && !spendVersion.allowsMultipleInputs) { + throw Exception( + "Sending private Firo funds to an exchange address is temporarily " + "unavailable.", + ); + } + + final coins = await mainDB.isar.sparkCoins + .where() + .walletIdEqualToAnyLTagHash(walletId) + .filter() + .isUsedEqualTo(false) + .and() + .heightIsNotNull() + .and() + .not() + .valueIntStringEqualTo("0") + .findAll(); + + if (coins.isEmpty) { + throw Exception("No spendable Spark coins found"); + } + coins.sort(_compareSparkCoinsForSingleInputSpend); + + final txAmount = transparentRecipients + .map((e) => e.amount.raw) + .followedBy(privateRecipients.map((e) => e.amount.raw)) + .fold(BigInt.zero, (sum, amount) => sum + amount); + final available = coins + .map((e) => e.value) + .fold(BigInt.zero, (sum, value) => sum + value); + if (txAmount > available) { + throw Exception("Insufficient Spark balance"); + } + + final isSendAll = shouldSubtractSparkFeeFromAmount( + isSparkNameRegistration: txData.sparkNameInfo != null, + spendsAll: available == txAmount, + ); + + final root = await getRootHDNode(); + final privateKeyHex = root + .derivePath(sparkDerivationPath) + .privateKey + .data + .toHex; + + if (spendVersion.allowsMultipleInputs) { + return _prepareSparkSpend( + txData: txData, + coins: coins, + subtractFeeFromAmount: isSendAll, + privateKeyHex: privateKeyHex, + lockTime: lockTime, + spendVersion: spendVersion, + ); + } + + if (txData.sparkNameInfo != null) { + final spend = await _prepareSparkSpend( + txData: txData, + coins: [coins.first], + subtractFeeFromAmount: false, + privateKeyHex: privateKeyHex, + lockTime: lockTime, + spendVersion: spendVersion, + ); + return spend.copyWith(sparkSpends: [spend]); + } + + if (isSendAll) { + if (coins.length != 1) { + throw Exception( + "Subtracting the fee is temporarily unavailable when a Spark " + "payment requires multiple transactions.", + ); + } + + final spend = await _prepareSparkSpend( + txData: txData, + coins: [coins.single], + subtractFeeFromAmount: true, + privateKeyHex: privateKeyHex, + lockTime: lockTime, + spendVersion: spendVersion, + ); + return spend.copyWith(sparkSpends: [spend]); + } + + final requests = [ + for (int i = 0; i < transparentRecipients.length; i++) + SparkSpendRecipientRequest( + type: SparkSpendRecipientType.transparent, + index: i, + amount: transparentRecipients[i].amount.raw, + ), + for (int i = 0; i < privateRecipients.length; i++) + SparkSpendRecipientRequest( + type: SparkSpendRecipientType.private, + index: i, + amount: privateRecipients[i].amount.raw, + ), + ]; + final serializedFeeCoin = [ + ( + serializedCoin: coins.first.serializedCoinB64!, + serializedCoinContext: coins.first.contextB64!, + groupId: coins.first.groupId, + height: coins.first.height!, + ), + ]; + final feeCache = <(int, int), BigInt>{}; + Future estimateSingleInputFee({ + required int privateRecipientCount, + required int transparentRecipientCount, + }) async { + final key = (privateRecipientCount, transparentRecipientCount); + final cached = feeCache[key]; + if (cached != null) return cached; + + final fee = BigInt.from( + await _asyncSparkFeesWrapper( + privateKeyHex: privateKeyHex, + index: sparkIndex, + sendAmount: 1, + subtractFeeFromAmount: true, + serializedCoins: serializedFeeCoin, + privateRecipientsCount: privateRecipientCount, + utxoNum: transparentRecipientCount, + additionalTxSize: 0, + spendVersion: spendVersion, + ), + ); + feeCache[key] = fee; + return fee; + } + + final maxTransparentAmount = Amount.fromDecimal( + Decimal.parse("50000"), + fractionDigits: cryptoCurrency.fractionDigits, + ).raw; + final plans = await planSingleInputSparkSpends( + coinValues: coins.map((e) => e.value).toList(), + recipients: requests, + estimateFee: estimateSingleInputFee, + maxTransparentAmount: maxTransparentAmount, + maxPrivateRecipients: SPARK_OUT_LIMIT_PER_TX - 2, + maxTransactions: _maxSingleInputSparkTransactions, + maxTransactionWeight: MAX_NEW_TX_WEIGHT, + ); + + final spends = []; + for (final plan in plans) { + final batchTransparentRecipients = []; + final batchPrivateRecipients = + <({String address, Amount amount, String memo, bool isChange})>[]; + + for (final fragment in plan.recipients) { + final amount = Amount( + rawValue: fragment.amount, + fractionDigits: cryptoCurrency.fractionDigits, + ); + switch (fragment.type) { + case SparkSpendRecipientType.transparent: + batchTransparentRecipients.add( + transparentRecipients[fragment.index].copyWith(amount: amount), + ); + case SparkSpendRecipientType.private: + final recipient = privateRecipients[fragment.index]; + batchPrivateRecipients.add(( + address: recipient.address, + amount: amount, + memo: recipient.memo, + isChange: recipient.isChange, + )); + } + } + + final spend = await _prepareSparkSpend( + txData: txData.copyWith( + recipients: batchTransparentRecipients, + sparkRecipients: batchPrivateRecipients, + ), + coins: [coins[plan.coinIndex]], + subtractFeeFromAmount: false, + privateKeyHex: privateKeyHex, + lockTime: lockTime, + spendVersion: spendVersion, + ); + if (spend.fee?.raw != plan.fee) { + throw Exception("Spark transaction fee changed during creation."); + } + spends.add(spend); + } + + if (spends.length == 1) { + return spends.single.copyWith(sparkSpends: List.unmodifiable(spends)); + } + + final totalFee = spends + .map((e) => e.fee!.raw) + .fold(BigInt.zero, (sum, fee) => sum + fee); + final usedCoins = spends + .expand((e) => e.usedSparkCoins!) + .toList(growable: false); + return txData.copyWith( + fee: Amount( + rawValue: totalFee, + fractionDigits: cryptoCurrency.fractionDigits, + ), + vSize: spends.fold(0, (sum, spend) => sum + spend.vSize!), + sparkSpends: List.unmodifiable(spends), + usedSparkCoins: usedCoins, + ); + } + + Future _prepareSparkSpend({ + required TxData txData, + required List coins, + required bool subtractFeeFromAmount, + required String privateKeyHex, + required int lockTime, + required LibSparkSpendVersion spendVersion, + }) async { + if (coins.isEmpty) { + throw Exception("No spendable Spark coins found"); + } if (!(txData.recipients?.isNotEmpty == true || txData.sparkRecipients?.isNotEmpty == true)) { throw Exception("No recipients provided."); @@ -354,13 +891,15 @@ mixin SparkInterface // See SPARK_VALUE_SPEND_LIMIT_PER_TRANSACTION at https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/include/spark.h#L17 // and COIN https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/bitcoin/amount.h#L17 // Note that as MAX_MONEY is greater than this limit, we can ignore it. See https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/bitcoin/amount.h#L31 + // NOTE: This was updated to 5x what is was before (previously 10k) if (transparentSumOut > Amount.fromDecimal( - Decimal.parse("10000"), + Decimal.parse("50000"), fractionDigits: cryptoCurrency.fractionDigits, )) { throw Exception( - "Spend to transparent address limit exceeded (10,000 Firo per transaction).", + "Spend to transparent address limit exceeded " + "(50,000 Firo per transaction).", ); } @@ -375,27 +914,7 @@ mixin SparkInterface ); final txAmount = transparentSumOut + sparkSumOut; - - // fetch spendable spark coins - final coins = await mainDB.isar.sparkCoins - .where() - .walletIdEqualToAnyLTagHash(walletId) - .filter() - .isUsedEqualTo(false) - .and() - .heightIsNotNull() - .and() - .not() - .valueIntStringEqualTo("0") - .findAll(); - - final available = info.cachedBalanceTertiary.spendable; - - if (txAmount > available) { - throw Exception("Insufficient Spark balance"); - } - - final bool isSendAll = available == txAmount; + final isSendAll = subtractFeeFromAmount; // prepare coin data for ffi final serializedCoins = coins @@ -409,10 +928,11 @@ mixin SparkInterface ) .toList(); - final currentId = await electrumXClient.getSparkLatestCoinId(); + final myCoinGroupIds = coins.map((e) => e.groupId).toSet(); + final List> setMaps = []; final List<({int groupId, String blockHash})> idAndBlockHashes = []; - for (int i = 1; i <= currentId; i++) { + for (final i in myCoinGroupIds) { final resultSet = await FiroCacheCoordinator.getSetCoinsForGroupId( i, network: cryptoCurrency.network, @@ -461,19 +981,9 @@ mixin SparkInterface ) .toList(); - final root = await getRootHDNode(); - final String derivationPath; - if (cryptoCurrency.network.isTestNet) { - derivationPath = - "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex"; - } else { - derivationPath = "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex"; - } - final privateKey = root.derivePath(derivationPath).privateKey.data; - final txb = btc.TransactionBuilder(network: _bitcoinDartNetwork); - txb.setLockTime(await chainHeight); - txb.setVersion(3 | (9 << 16)); + txb.setLockTime(lockTime); + txb.setVersion(spendVersion.transactionVersion); List? recipientsWithFeeSubtracted; List<({String address, Amount amount, String memo, bool isChange})>? @@ -486,14 +996,15 @@ mixin SparkInterface final BigInt estimatedFee; if (isSendAll) { final estFee = await _asyncSparkFeesWrapper( - privateKeyHex: privateKey.toHex, - index: kDefaultSparkIndex, + privateKeyHex: privateKeyHex, + index: sparkIndex, sendAmount: txAmount.raw.toInt(), subtractFeeFromAmount: true, serializedCoins: serializedCoins, privateRecipientsCount: (txData.sparkRecipients?.length ?? 0), utxoNum: recipientCount, additionalTxSize: 0, // name script size + spendVersion: spendVersion, ); estimatedFee = BigInt.from(estFee); } else { @@ -517,7 +1028,8 @@ mixin SparkInterface fractionDigits: cryptoCurrency.fractionDigits, ), memo: txData.sparkRecipients![i].memo, - isChange: sparkChangeAddress == txData.sparkRecipients![i].address, + isChange: + _sparkChangeAddress!.value == txData.sparkRecipients![i].address, )); } @@ -525,6 +1037,7 @@ mixin SparkInterface final List tempInputs = []; final List tempOutputs = []; + var sparkNameFeeScriptSizeDelta = 0; for (int i = 0; i < (txData.recipients?.length ?? 0); i++) { if (txData.recipients![i].amount.raw == BigInt.zero) { continue; @@ -540,10 +1053,19 @@ mixin SparkInterface ), ); - final scriptPubKey = btc.Address.addressToOutputScript( + var scriptPubKey = btc.Address.addressToOutputScript( txData.recipients![i].address, _bitcoinDartNetwork, ); + if (txData.sparkNameInfo != null) { + final baseScript = scriptPubKey; + scriptPubKey = sparkNameFeeScript( + baseScript: scriptPubKey, + name: txData.sparkNameInfo!.name, + sparkAddress: txData.sparkNameInfo!.sparkAddress.value, + ); + sparkNameFeeScriptSizeDelta += scriptPubKey.length - baseScript.length; + } txb.addOutput( scriptPubKey, recipientsWithFeeSubtracted[i].amount.raw.toInt(), @@ -604,9 +1126,12 @@ mixin SparkInterface sparkNameValidityBlocks: txData.sparkNameInfo!.validBlocks, name: txData.sparkNameInfo!.name, additionalInfo: txData.sparkNameInfo!.additionalInfo, - scalarHex: extractedTx.getId(), - privateKeyHex: privateKey.toHex, - spendKeyIndex: kDefaultSparkIndex, + proofInput: _sparkNameProofInput( + spendVersion: spendVersion, + inputHex: extractedTx.getId(), + ), + privateKeyHex: privateKeyHex, + spendKeyIndex: sparkIndex, diversifier: txData.sparkNameInfo!.sparkAddress.derivationIndex, isTestNet: cryptoCurrency.network != CryptoCurrencyNetwork.main, ignoreProof: true, @@ -614,9 +1139,16 @@ mixin SparkInterface ); } + final extensionCommitment = + spendVersion == .chaumV2 && noProofNameTxData != null + ? libSpark.getSparkNameCommitment( + serializedSparkNameData: noProofNameTxData.script, + ) + : null; + final spend = await computeWithLibSparkLogging(_createSparkSend, ( - privateKeyHex: privateKey.toHex, - index: kDefaultSparkIndex, + privateKeyHex: privateKeyHex, + index: sparkIndex, recipients: txData.recipients ?.map( @@ -648,9 +1180,16 @@ mixin SparkInterface txHash: extractedTx.getHash(), additionalTxSize: txData.sparkNameInfo == null ? 0 - : noProofNameTxData!.size, + : noProofNameTxData!.size + sparkNameFeeScriptSizeDelta, + spendVersion: spendVersion, + extensionCommitment: extensionCommitment, )); + if (spend.usedCoins.isEmpty || + (!spendVersion.allowsMultipleInputs && spend.usedCoins.length != 1)) { + throw Exception("Unable to create a single-input Spark transaction."); + } + for (final outputScript in spend.outputScripts) { extractedTx.addOutput(outputScript, 0); } @@ -677,9 +1216,12 @@ mixin SparkInterface sparkNameValidityBlocks: txData.sparkNameInfo!.validBlocks, name: txData.sparkNameInfo!.name, additionalInfo: txData.sparkNameInfo!.additionalInfo, - scalarHex: hash, - privateKeyHex: privateKey.toHex, - spendKeyIndex: kDefaultSparkIndex, + proofInput: _sparkNameProofInput( + spendVersion: spendVersion, + inputHex: hash, + ), + privateKeyHex: privateKeyHex, + spendKeyIndex: sparkIndex, diversifier: txData.sparkNameInfo!.sparkAddress.derivationIndex, isTestNet: cryptoCurrency.network != CryptoCurrencyNetwork.main, ignoreProof: false, @@ -687,7 +1229,8 @@ mixin SparkInterface ); break; } catch (e) { - if (e.toString() != "Exception: hash fail") { + if (spendVersion == .chaumV2 || + e.toString() != "Exception: hash fail") { rethrow; } hashFailSafe++; @@ -752,10 +1295,15 @@ mixin SparkInterface ); } catch (_) { throw Exception( - "Unexpectedly did not find used spark coin. This should never happen.", + "Unexpectedly did not find used spark coin. " + "This should never happen.", ); } } + if (usedSparkCoins.isEmpty || + (!spendVersion.allowsMultipleInputs && usedSparkCoins.length != 1)) { + throw Exception("Unable to create a single-input Spark transaction."); + } return txData.copyWith( raw: rawTxHex, @@ -781,35 +1329,90 @@ mixin SparkInterface ); } - // this may not be needed for either mints or spends or both Future confirmSendSpark({required TxData txData}) async { + if (isViewOnly) { + throw Exception("Spending is not supported for view only wallets"); + } + try { Logging.instance.d("confirmSend txData: $txData"); - final txHash = await electrumXClient.broadcastTransaction( - rawTx: txData.raw!, - ); - Logging.instance.d("Sent txHash: $txHash"); + final transactions = txData.sparkSpends ?? [txData]; + if (transactions.isEmpty || + transactions.any( + (e) => + e.raw == null || + e.usedSparkCoins == null || + e.usedSparkCoins!.isEmpty, + )) { + throw Exception("Refusing to broadcast an invalid Spark transaction."); + } + for (final transaction in transactions) { + if (transaction.usedSparkCoins!.length > 1) { + final transactionVersion = btc.Transaction.fromHex( + transaction.raw!, + ).version; + if (!isChaumV2SparkTransactionVersion(transactionVersion)) { + throw Exception( + "Refusing to broadcast a multi-input Chaum V1 transaction.", + ); + } + } + } + final coinIds = transactions + .expand((e) => e.usedSparkCoins!) + .map((e) => e.lTagHash) + .toList(growable: false); + if (coinIds.toSet().length != coinIds.length) { + throw Exception( + "A Spark coin cannot be used by multiple transactions.", + ); + } - txData = txData.copyWith( - // TODO revisit setting these both - txHash: txHash, - txid: txHash, - ); + final confirmed = []; + for (int i = 0; i < transactions.length; i++) { + try { + final txHash = await electrumXClient.broadcastTransaction( + rawTx: transactions[i].raw!, + ); + Logging.instance.d("Sent txHash: $txHash"); - // Update used spark coins as used in database. They should already have - // been marked as isUsed. - // TODO: [prio=med] Could (probably should) throw an exception here if txData.usedSparkCoins is null or empty - if (txData.usedSparkCoins != null && txData.usedSparkCoins!.isNotEmpty) { - await mainDB.isar.writeTxn(() async { - await mainDB.isar.sparkCoins.putAll(txData.usedSparkCoins!); - }); + TxData confirmedTx = transactions[i].copyWith( + txHash: txHash, + txid: txHash, + ); + confirmed.add(confirmedTx); + + await mainDB.isar.writeTxn(() async { + await mainDB.isar.sparkCoins.putAll(confirmedTx.usedSparkCoins!); + }); + confirmedTx = await updateSentCachedTxData(txData: confirmedTx); + confirmed[confirmed.length - 1] = confirmedTx; + } catch (e) { + if (confirmed.isNotEmpty) { + final txids = confirmed.map((e) => e.txid).join(", "); + throw Exception( + "Spark transaction ${i + 1} of ${transactions.length} failed: " + "$e ${confirmed.length} transaction(s) were already sent: " + "$txids. Do not retry the full payment.", + ); + } + rethrow; + } + } + + if (txData.sparkSpends == null) { + return confirmed.single; } - return await updateSentCachedTxData(txData: txData); + return txData.copyWith( + txHash: confirmed.first.txHash, + txid: confirmed.first.txid, + sparkSpends: List.unmodifiable(confirmed), + ); } catch (e, s) { Logging.instance.e( - "Exception rethrown from confirmSend(): ", + "Exception rethrown from confirmSendSpark(): ", error: e, stackTrace: s, ); @@ -822,77 +1425,63 @@ mixin SparkInterface Set _mempoolTxidsChecked = {}; Future> _refreshSparkCoinsMempoolCheck({ - required Set privateKeyHexSet, required int groupId, }) async { final start = DateTime.now(); - try { - // update cache - _mempoolTxids = await electrumXClient.getMempoolTxids(); - // remove any checked txids that are not in the mempool anymore - _mempoolTxidsChecked = _mempoolTxidsChecked.intersection(_mempoolTxids); + // update cache + _mempoolTxids = await electrumXClient.getMempoolTxids(); - // get all unchecked txids currently in mempool - final txidsToCheck = _mempoolTxids.difference(_mempoolTxidsChecked); - if (txidsToCheck.isEmpty) { - return []; - } + // remove any checked txids that are not in the mempool anymore + _mempoolTxidsChecked = _mempoolTxidsChecked.intersection(_mempoolTxids); - // fetch spark data to scan if we own any unconfirmed spark coins - final sparkDataToCheck = await electrumXClient.getMempoolSparkData( + // get all unchecked txids currently in mempool + final txidsToCheck = _mempoolTxids.difference(_mempoolTxidsChecked); + if (txidsToCheck.isEmpty) { + return []; + } + + // fetch spark data to scan if we own any unconfirmed spark coins + List sparkDataToCheck = []; + try { + sparkDataToCheck = await electrumXClient.getMempoolSparkData( txids: txidsToCheck.toList(), ); + } catch (e, s) { + Logging.instance.e( + "Exception rethrown from _refreshSparkCoinsMempoolCheck(): ", + error: e, + stackTrace: s, + ); + return []; + } - final Set checkedTxids = {}; - final List> rawCoins = []; - - for (final data in sparkDataToCheck) { - for (int i = 0; i < data.coins.length; i++) { - rawCoins.add([data.coins[i], data.txid, data.serialContext.first]); - } + final Set checkedTxids = {}; + final List> rawCoins = []; - checkedTxids.add(data.txid); + for (final data in sparkDataToCheck) { + for (int i = 0; i < data.coins.length; i++) { + rawCoins.add([data.coins[i], data.txid, data.serialContext.first]); } - final result = []; + checkedTxids.add(data.txid); + } - // if there is new data we try and identify the coins - if (rawCoins.isNotEmpty) { - // run identify off main isolate - final myCoins = await computeWithLibSparkLogging(_identifyCoins, ( - anonymitySetCoins: rawCoins, - groupId: groupId, - privateKeyHexSet: privateKeyHexSet, - walletId: walletId, - isTestNet: cryptoCurrency.network.isTestNet, - )); + // if there is new data we try and identify the coins + final List myCoins = await identifyCoins( + anonymitySetCoins: rawCoins, + groupId: groupId, + ); - // add checked txids after identification - _mempoolTxidsChecked.addAll(checkedTxids); + // add checked txids after identification + _mempoolTxidsChecked.addAll(checkedTxids); - for (final coin in myCoins) { - final match = sparkDataToCheck.firstWhere( - (e) => e.serialContext.contains(coin.contextB64!), - ); - result.add(coin.copyWith(isLocked: match.isLocked)); - } - } + Logging.instance.d( + "Finished _refreshSparkCoinsMempoolCheck(). " + "Duration=${DateTime.now().difference(start)}", + ); - return result; - } catch (e, s) { - Logging.instance.e( - "_refreshSparkCoinsMempoolCheck() failed", - error: e, - stackTrace: s, - ); - return []; - } finally { - Logging.instance.d( - "$walletId ${info.name} _refreshSparkCoinsMempoolCheck() run " - "duration: ${DateTime.now().difference(start)}", - ); - } + return myCoins; } // returns next percent @@ -902,15 +1491,18 @@ mixin SparkInterface return current + increment; } - // Linearly make calls so there is less chance of timing out or otherwise breaking + // Linearly make calls so there is less chance of timing out or otherwise + // breaking Future refreshSparkData( (double startingPercent, double endingPercent)? refreshProgressRange, ) async { final start = DateTime.now(); + try { // start by checking if any previous sets are missing from db and add the // missing groupIds to the list if sets to check and update final latestGroupId = await electrumXClient.getSparkLatestCoinId(); + final List groupIds = []; if (latestGroupId > 1) { for (int id = 1; id < latestGroupId; id++) { @@ -1016,33 +1608,15 @@ mixin SparkInterface currentPercent = _triggerEventHelper(currentPercent, percentIncrement); } - // get address(es) to get the private key hex strings required for - // identifying spark coins - final sparkAddresses = await mainDB.isar.addresses - .where() - .walletIdEqualTo(walletId) - .filter() - .typeEqualTo(AddressType.spark) - .findAll(); - final root = await getRootHDNode(); - final Set privateKeyHexSet = sparkAddresses - .map( - (e) => - root.derivePath(e.derivationPath!.value).privateKey.data.toHex, - ) - .toSet(); - // try to identify any coins in the unchecked set data final List newlyIdCoins = []; for (final groupId in rawCoinsBySetId.keys) { - final myCoins = await computeWithLibSparkLogging(_identifyCoins, ( - anonymitySetCoins: rawCoinsBySetId[groupId]!, - groupId: groupId, - privateKeyHexSet: privateKeyHexSet, - walletId: walletId, - isTestNet: cryptoCurrency.network.isTestNet, - )); - newlyIdCoins.addAll(myCoins); + newlyIdCoins.addAll( + await identifyCoins( + anonymitySetCoins: rawCoinsBySetId[groupId]!, + groupId: groupId, + ), + ); } // if any were found, add to database if (newlyIdCoins.isNotEmpty) { @@ -1064,10 +1638,9 @@ mixin SparkInterface } // check for spark coins in mempool - final mempoolMyCoins = await _refreshSparkCoinsMempoolCheck( - privateKeyHexSet: privateKeyHexSet, - groupId: latestGroupId, - ); + final List mempoolMyCoins = + await _refreshSparkCoinsMempoolCheck(groupId: latestGroupId); + // if any were found, add to database if (mempoolMyCoins.isNotEmpty) { await mainDB.isar.writeTxn(() async { @@ -1095,16 +1668,57 @@ mixin SparkInterface ); } + Logging.instance.d( + "refreshSparkData() coinsToCheck.length: " + "${coinsToCheck.length}", + ); + + // prepare data for next step + final coinsToCheckTxids = coinsToCheck + .where((e) => e.height == null) + .map((e) => e.txHash) + .toList(growable: false); + + final Map> coinsToCheckTransactions = {}; + if (coinsToCheckTxids.isNotEmpty) { + const batchSize = 100; + final remainder = coinsToCheckTxids.length % batchSize; + final batchCount = coinsToCheckTxids.length ~/ batchSize; + + for (int i = 0; i < batchCount; i++) { + final start = i * batchSize; + final end = start + batchSize; + Logging.instance.i("[coinsToCheck]: Fetching batch #$i"); + final txns = await electrumXCachedClient.getBatchTransactions( + txHashes: coinsToCheckTxids.sublist(start, end), + cryptoCurrency: cryptoCurrency, + ); + for (final tx in txns) { + coinsToCheckTransactions[tx["txid"] as String] = tx; + } + } + // handle remainder + if (remainder > 0) { + final txns = await electrumXCachedClient.getBatchTransactions( + txHashes: coinsToCheckTxids.sublist( + coinsToCheckTxids.length - remainder, + ), + cryptoCurrency: cryptoCurrency, + ); + for (final tx in txns) { + coinsToCheckTransactions[tx["txid"] as String] = tx; + } + } + } + // check and update coins if required final List checkedCoins = []; for (final coin in coinsToCheck) { final SparkCoin checked; if (coin.height == null) { - final tx = await electrumXCachedClient.getTransaction( - txHash: coin.txHash, - cryptoCurrency: info.coin, - ); + final tx = coinsToCheckTransactions[coin.txHash]!; + if (tx["height"] is int) { checked = coin.copyWith( height: tx["height"] as int, @@ -1213,12 +1827,6 @@ mixin SparkInterface /// Should only be called within the standard wallet [recover] function due to /// mutex locking. Otherwise behaviour MAY be undefined. Future recoverSparkWallet({required int latestSparkCoinId}) async { - // generate spark addresses if non existing - if (await getCurrentReceivingSparkAddress() == null) { - final address = await generateNextSparkAddress(); - await mainDB.putAddress(address); - } - try { await refreshSparkData(null); } catch (e, s) { @@ -1231,14 +1839,16 @@ mixin SparkInterface } } + Future<({String address, int validUntil, String additionalInfo})> + getSparkNameData({required String sparkName}) async { + return await electrumXClient.getSparkNameData(sparkName: sparkName); + } + Future refreshSparkNames() async { try { Logging.instance.i("Refreshing spark names for $walletId ${info.name}"); final db = Drift.get(walletId); - final myNameStrings = await db.managers.sparkNames - .map((e) => e.name) - .get(); final names = await electrumXClient.getSparkNames(); // start update shared cache of all names @@ -1260,26 +1870,13 @@ mixin SparkInterface .toSet(); // some look ahead - // TODO revisit this and clean up (track pre gen'd addresses instead of generating every time) - // arbitrary number of addresses + // TODO revisit this and clean up (track pre gen'd addresses instead of + // generating every time) arbitrary number of addresses const lookAheadCount = 100; - final highestStoredDiversifier = - (await getCurrentReceivingSparkAddress())?.derivationIndex; - - final root = await getRootHDNode(); - final String derivationPath; - if (cryptoCurrency.network.isTestNet) { - derivationPath = - "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex"; - } else { - derivationPath = - "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex"; - } - final keys = root.derivePath(derivationPath); - - // default to starting at 1 if none found - int diversifier = (highestStoredDiversifier ?? 0) + 1; + // force unwrap optional should be fine here. If not then the + // eclosing function is being called somewhere it probably shouldn't be. + int diversifier = _currentSparkAddress!.derivationIndex; final maxDiversifier = diversifier + lookAheadCount; while (diversifier < maxDiversifier) { @@ -1287,23 +1884,14 @@ mixin SparkInterface if (diversifier == libSpark.sparkChange) { diversifier++; } - final addressString = await libSpark.getAddress( - privateKey: keys.privateKey.data, - index: kDefaultSparkIndex, - diversifier: diversifier, - isTestNet: cryptoCurrency.network.isTestNet, - ); - - myAddresses.add(addressString); + final addressString = await _generateSparkAddress(diversifier); + myAddresses.add(addressString.value); diversifier++; } - names.retainWhere( - (e) => - myAddresses.contains(e.address) && !myNameStrings.contains(e.name), - ); - Logging.instance.d("Found $names new spark names"); + names.retainWhere((e) => myAddresses.contains(e.address)); + Logging.instance.d("Found $names spark names"); if (names.isNotEmpty) { final List< @@ -1317,9 +1905,7 @@ mixin SparkInterface data = []; for (final name in names) { - final info = await electrumXClient.getSparkNameData( - sparkName: name.name, - ); + final info = await getSparkNameData(sparkName: name.name); data.add(( name: name.name, @@ -1350,6 +1936,10 @@ mixin SparkInterface required bool subtractFeeFromAmount, required bool autoMintAll, }) async { + if (isViewOnly) { + throw Exception("Minting is not supported for view only wallets"); + } + // pre checks if (outputs.isEmpty) { throw Exception("Cannot mint without some recipients"); @@ -1386,10 +1976,64 @@ mixin SparkInterface .map((e) => MutableSparkRecipient(e.address, e.value, e.memo)) .toList(); // deep copy final feesObject = await fees; + final minRelayFeeRatePerKB = BigInt.from(1000); + final mintFeeRatePerKB = feesObject.medium < minRelayFeeRatePerKB + ? minRelayFeeRatePerKB + : feesObject.medium; final currentHeight = await chainHeight; final random = Random.secure(); final List results = []; + final String? autoMintSparkAddress = autoMintAll + ? (await getCurrentReceivingSparkAddress())?.value + : null; + if (autoMintAll && autoMintSparkAddress == null) { + throw Exception("No current Spark receiving address found."); + } + + // Cache signing keys lazily for selected inputs. This mirrors the subset + // of addSigningKeys used by Firo Spark mints; Firo currently supports only + // BIP44 transparent inputs, so caching from the wallet root is valid here. + final root = await getRootHDNode(); + final Map signingKeyCache = {}; + Future<_SparkMintSigningKey> getCachedSigningKey(String address) async { + final existing = signingKeyCache[address]; + if (existing != null) { + return existing; + } + + final derivePathType = cryptoCurrency.addressType(address: address); + final dbAddress = await mainDB.getAddress(walletId, address); + if (dbAddress?.derivationPath == null) { + throw Exception( + "Signing key not found for address $address. " + "Local db may be corrupt. Rescan wallet.", + ); + } + + final key = root.derivePath(dbAddress!.derivationPath!.value); + final cached = (derivePathType: derivePathType, key: key); + signingKeyCache[address] = cached; + return cached; + } + + Address? cachedChangeAddress; + Future
getMintChangeAddress() async { + cachedChangeAddress ??= await getCurrentChangeAddress(); + if (cachedChangeAddress == null) { + throw Exception("No current change address found."); + } + return cachedChangeAddress!; + } + + // Pre-fetch wallet-owned addresses for output ownership checks. + final walletAddresses = await mainDB.isar.addresses + .where() + .walletIdEqualTo(walletId) + .valueProperty() + .findAll(); + final walletAddressSet = walletAddresses.toSet(); + valueAndUTXOs.shuffle(random); while (valueAndUTXOs.isNotEmpty) { @@ -1430,7 +2074,7 @@ mixin SparkInterface } // if (!MoneyRange(mintedValue) || mintedValue == 0) { - if (mintedValue == BigInt.zero) { + if (mintedValue <= BigInt.zero) { valueAndUTXOs.remove(itr); skipCoin = true; break; @@ -1449,11 +2093,7 @@ mixin SparkInterface if (autoMintAll) { singleTxOutputs.add( - MutableSparkRecipient( - (await getCurrentReceivingSparkAddress())!.value, - mintedValue, - "", - ), + MutableSparkRecipient(autoMintSparkAddress!, mintedValue, ""), ); } else { BigInt remainingMintValue = BigInt.parse(mintedValue.toString()); @@ -1481,25 +2121,42 @@ mixin SparkInterface } } - if (subtractFeeFromAmount) { - final BigInt singleFee = - nFeeRet ~/ BigInt.from(singleTxOutputs.length); - BigInt remainder = nFeeRet % BigInt.from(singleTxOutputs.length); - - for (int i = 0; i < singleTxOutputs.length; ++i) { - if (singleTxOutputs[i].value <= singleFee) { - singleTxOutputs.removeAt(i); - remainder += singleTxOutputs[i].value - singleFee; - --i; + if (subtractFeeFromAmount && nFeeRet > BigInt.zero) { + var remainingFee = nFeeRet; + var outputIndex = 0; + while (singleTxOutputs.isNotEmpty && remainingFee > BigInt.zero) { + if (outputIndex >= singleTxOutputs.length) { + outputIndex = 0; + } + + final outputsLeft = BigInt.from( + singleTxOutputs.length - outputIndex, + ); + var feeShare = remainingFee ~/ outputsLeft; + if (remainingFee % outputsLeft != BigInt.zero) { + feeShare += BigInt.one; + } + + if (singleTxOutputs[outputIndex].value <= feeShare) { + remainingFee -= singleTxOutputs[outputIndex].value; + singleTxOutputs.removeAt(outputIndex); + continue; } - singleTxOutputs[i].value -= singleFee; - if (remainder > BigInt.zero && - singleTxOutputs[i].value > - nFeeRet % BigInt.from(singleTxOutputs.length)) { - // first receiver pays the remainder not divisible by output count - singleTxOutputs[i].value -= remainder; - remainder = BigInt.zero; + + singleTxOutputs[outputIndex].value -= feeShare; + remainingFee -= feeShare; + ++outputIndex; + } + + if (singleTxOutputs.isEmpty) { + if (autoMintAll) { + throw Exception( + "UTXO value is too small to cover Spark mint fee", + ); } + valueAndUTXOs.remove(itr); + skipCoin = true; + break; } } @@ -1534,11 +2191,13 @@ mixin SparkInterface BigInt nValueIn = BigInt.zero; for (final utxo in itr) { if (nValueToSelect > nValueIn) { - setCoins.add( - (await addSigningKeys([ - StandardInput(utxo), - ])).whereType().first, + final cached = await getCachedSigningKey(utxo.address!); + final input = StandardInput( + utxo, + derivePathType: cached.derivePathType, ); + input.key = cached.key; + setCoins.add(input); nValueIn += BigInt.from(utxo.value); } } @@ -1560,9 +2219,9 @@ mixin SparkInterface throw Exception("Change index out of range"); } - final changeAddress = await getCurrentChangeAddress(); + final changeAddress = await getMintChangeAddress(); vout.insert(nChangePosInOut, ( - changeAddress!.value, + changeAddress.value, nChange.toInt(), null, )); @@ -1628,7 +2287,7 @@ mixin SparkInterface sd.utxo.txid, sd.utxo.vout, 0xffffffff - - 1, // minus 1 is important. 0xffffffff on its own will burn funds + 1, // - 1 is important. 0xffffffff on its own will burn funds data!.output!, ); } @@ -1644,7 +2303,8 @@ mixin SparkInterface ), witnessValue: setCoins[i].utxo.value, - // maybe not needed here as this was originally copied from btc? We'll find out... + // maybe not needed here as this was originally copied from btc? + // We'll find out... // redeemScript: setCoins[i].redeemScript, ); } @@ -1652,13 +2312,23 @@ mixin SparkInterface final dummyTx = dummyTxb.build(); final nBytes = dummyTx.virtualSize(); - if (dummyTx.weight() > MAX_STANDARD_TX_WEIGHT) { + if (dummyTx.weight() > MAX_NEW_TX_WEIGHT) { throw Exception("Transaction too large"); } + // ECDSA DER signatures are not fixed-size. Even with low-S + // normalization, the encoded signature length can vary across + // signatures, so the dummy signed transaction used for fee estimation + // can be smaller than the final signed transaction. Use a per-input + // safety margin so fee estimation remains an upper bound for many-input + // Spark mints. + final nBytesBuffer = 10 + 4 * setCoins.length; final nFeeNeeded = BigInt.from( - estimateTxFee(vSize: nBytes, feeRatePerKB: feesObject.medium), - ); // One day we'll do this properly + estimateTxFee( + vSize: nBytes + nBytesBuffer, + feeRatePerKB: mintFeeRatePerKB, + ), + ); if (nFeeRet >= nFeeNeeded) { for (final usedCoin in setCoins) { @@ -1819,19 +2489,11 @@ mixin SparkInterface addresses: [ if (addressOrScript is String) addressOrScript.toString(), ], - walletOwns: - (await mainDB.isar.addresses - .where() - .walletIdEqualTo(walletId) - .filter() - .valueEqualTo( - addressOrScript is Uint8List - ? output.$3! - : addressOrScript as String, - ) - .valueProperty() - .findFirst()) != - null, + walletOwns: walletAddressSet.contains( + addressOrScript is Uint8List + ? output.$3! + : addressOrScript as String, + ), ), ); } @@ -1847,7 +2509,8 @@ mixin SparkInterface ), witnessValue: vin[i].utxo.value, - // maybe not needed here as this was originally copied from btc? We'll find out... + // maybe not needed here as this was originally copied from btc? + // We'll find out... // redeemScript: setCoins[i].redeemScript, ); } @@ -1860,6 +2523,18 @@ mixin SparkInterface rethrow; } final builtTx = txb.build(); + final actualFee = + vin + .map((e) => BigInt.from(e.utxo.value)) + .fold(BigInt.zero, (p, e) => p + e) - + vout.map((e) => BigInt.from(e.$2)).fold(BigInt.zero, (p, e) => p + e); + if (actualFee != nFeeRet) { + Logging.instance.e( + "Spark mint fee accounting mismatch: " + "expected=$nFeeRet, actual=$actualFee", + ); + throw Exception("Spark mint fee accounting mismatch"); + } // TODO: see todo at top of this function assert(outputs.length == 1); @@ -1869,9 +2544,10 @@ mixin SparkInterface .where((e) => e.$1 is Uint8List) // ignore change .map( (e) => ( - address: outputs - .first - .address, // for display purposes on confirm tx screen. See todos above + // for display purposes on confirm tx screen. + // See todos above + address: outputs.first.address, + memo: "", amount: Amount( rawValue: BigInt.from(e.$2), @@ -1908,8 +2584,15 @@ mixin SparkInterface ), ); + Logging.instance.i("nFeeRet=$nFeeRet, vSize=${data.vSize}"); + // Sanity check: with the fee rate clamped to at least 1 sat/vbyte, this + // should only fire if fee accounting or size estimation regresses. if (nFeeRet.toInt() < data.vSize!) { - throw Exception("fee is less than vSize"); + Logging.instance.w( + "Fee rate below 1 sat/byte minimum relay fee: " + "fee=$nFeeRet sats, vSize=${data.vSize} bytes", + ); + throw Exception("Fee rate below 1 sat/byte minimum relay fee"); } results.add(data); @@ -1959,10 +2642,18 @@ mixin SparkInterface throw Exception("Failed to mint expected amounts"); } + if (autoMintAll && results.isEmpty) { + throw Exception("No Spark mint transactions were created"); + } + return results; } Future anonymizeAllSpark() async { + if (isViewOnly) { + throw Exception("Anonymizing is not supported for view only wallets"); + } + try { const subtractFeeFromAmount = true; // must be true for mint all final currentHeight = await chainHeight; @@ -2020,6 +2711,10 @@ mixin SparkInterface /// /// See https://docs.google.com/document/d/1RG52GoYTZDvKlZz_3G4sQu-PpT6JWSZGHLNswWcrE3o Future prepareSparkMintTransaction({required TxData txData}) async { + if (isViewOnly) { + throw Exception("Minting is not supported for view only wallets"); + } + try { if (txData.sparkRecipients?.isNotEmpty != true) { throw Exception("Missing spark recipients."); @@ -2123,6 +2818,10 @@ mixin SparkInterface } Future confirmSparkMintTransactions({required TxData txData}) async { + if (isViewOnly) { + throw Exception("Minting is not supported for view only wallets"); + } + final futures = txData.sparkMints!.map((e) => confirmSend(txData: e)); return txData.copyWith(sparkMints: await Future.wait(futures)); } @@ -2155,7 +2854,9 @@ mixin SparkInterface if (additionalInfo.toUint8ListFromUtf8.length > libSpark.maxAdditionalInfoLengthBytes) { throw Exception( - "Additional info exceeds ${libSpark.maxAdditionalInfoLengthBytes} bytes.", + "Additional info exceeds " + "${libSpark.maxAdditionalInfoLengthBytes}" + " bytes.", ); } @@ -2177,16 +2878,18 @@ mixin SparkInterface final String destinationAddress; switch (cryptoCurrency.network) { case CryptoCurrencyNetwork.main: - destinationAddress = libSpark.stage3DevelopmentFundAddressMainNet; + destinationAddress = libSpark.stage3CommunityFundAddressMainNet; break; case CryptoCurrencyNetwork.test: - destinationAddress = libSpark.stage3DevelopmentFundAddressTestNet; + destinationAddress = libSpark.stage3CommunityFundAddressTestNet; break; default: throw Exception( - "Invalid network '${cryptoCurrency.network}' for spark name registration.", + "Invalid network " + "'${cryptoCurrency.network}'" + " for spark name registration.", ); } @@ -2290,6 +2993,8 @@ _createSparkSend( List<({int setId, Uint8List blockHash})> idAndBlockHashes, Uint8List txHash, int additionalTxSize, + LibSparkSpendVersion spendVersion, + Uint8List? extensionCommitment, }) args, ) async { @@ -2303,84 +3008,13 @@ _createSparkSend( idAndBlockHashes: args.idAndBlockHashes, txHash: args.txHash, additionalTxSize: args.additionalTxSize, + spendVersion: args.spendVersion, + extensionCommitment: args.extensionCommitment, ); return spend; } -/// Top level function which should be called wrapped in [compute] -Future> _identifyCoins( - ({ - List anonymitySetCoins, - int groupId, - Set privateKeyHexSet, - String walletId, - bool isTestNet, - }) - args, -) async { - final List myCoins = []; - - for (final privateKeyHex in args.privateKeyHexSet) { - for (final dynData in args.anonymitySetCoins) { - final data = List.from(dynData as List); - - if (data.length != 3) { - throw Exception("Unexpected serialized coin info found"); - } - - final serializedCoinB64 = data[0]; - final txHash = data[1].toHexReversedFromBase64; - final contextB64 = data[2]; - - final coin = libSpark.identifyAndRecoverCoin( - serializedCoinB64, - privateKeyHex: privateKeyHex, - index: kDefaultSparkIndex, - context: base64Decode(contextB64), - isTestNet: args.isTestNet, - ); - - // its ours - if (coin != null) { - final SparkCoinType coinType; - switch (coin.type.value) { - case 0: - coinType = SparkCoinType.mint; - case 1: - coinType = SparkCoinType.spend; - default: - throw Exception("Unknown spark coin type detected"); - } - myCoins.add( - SparkCoin( - walletId: args.walletId, - type: coinType, - isUsed: false, - groupId: args.groupId, - nonce: coin.nonceHex?.toUint8ListFromHex, - address: coin.address!, - txHash: txHash, - valueIntString: coin.value!.toString(), - memo: coin.memo, - serialContext: coin.serialContext, - diversifierIntString: coin.diversifier!.toString(), - encryptedDiversifier: coin.encryptedDiversifier, - serial: coin.serial, - tag: coin.tag, - lTagHash: coin.lTagHash!, - height: coin.height, - serializedCoinB64: serializedCoinB64, - contextB64: contextB64, - ), - ); - } - } - } - - return myCoins; -} - BigInt _min(BigInt a, BigInt b) { if (a <= b) { return a; @@ -2393,6 +3027,11 @@ BigInt _sum(List utxos) => utxos .map((e) => BigInt.from(e.value)) .fold(BigInt.zero, (previousValue, element) => previousValue + element); +typedef _SparkMintSigningKey = ({ + DerivePathType derivePathType, + coinlib.HDPrivateKey key, +}); + class MutableSparkRecipient { String address; BigInt value; @@ -2402,7 +3041,11 @@ class MutableSparkRecipient { @override String toString() { - return 'MutableSparkRecipient{ address: $address, value: $value, memo: $memo }'; + return 'MutableSparkRecipient{ ' + 'address: $address, ' + 'value: $value,' + ' memo: $memo' + ' }'; } } @@ -2422,6 +3065,7 @@ Future _asyncSparkFeesWrapper({ required int privateRecipientsCount, required int utxoNum, required int additionalTxSize, + required LibSparkSpendVersion spendVersion, }) async { return await computeWithLibSparkLogging(_estSparkFeeComputeFunc, ( privateKeyHex: privateKeyHex, @@ -2432,6 +3076,7 @@ Future _asyncSparkFeesWrapper({ privateRecipientsCount: privateRecipientsCount, utxoNum: utxoNum, additionalTxSize: additionalTxSize, + spendVersion: spendVersion, )); } @@ -2445,6 +3090,7 @@ int _estSparkFeeComputeFunc( int privateRecipientsCount, int utxoNum, int additionalTxSize, + LibSparkSpendVersion spendVersion, }) args, ) { @@ -2457,7 +3103,17 @@ int _estSparkFeeComputeFunc( privateRecipientsCount: args.privateRecipientsCount, utxoNum: args.utxoNum, additionalTxSize: args.additionalTxSize, + spendVersion: args.spendVersion, ); return est; } + +Future _getAddressFromFullViewKey( + ({String fullViewKeyHex, int index, int diversifier, bool isTestNet}) args, +) => libSpark.getAddressFromFullViewKey( + fullViewKeyHex: args.fullViewKeyHex, + index: args.index, + diversifier: args.diversifier, + isTestNet: args.isTestNet, +); diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_spend_planner.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_spend_planner.dart new file mode 100644 index 0000000000..9a5592c87d --- /dev/null +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_spend_planner.dart @@ -0,0 +1,183 @@ +typedef SparkSpendFeeEstimator = + Future Function({ + required int privateRecipientCount, + required int transparentRecipientCount, + }); + +const _sparkBaseSize = 924; +const _sparkInputSize = 1803; +const _sparkPrivateOutputSize = 322; +const _transparentOutputSize = 34; +const _witnessScaleFactor = 4; + +enum SparkSpendRecipientType { transparent, private } + +final class SparkSpendRecipientRequest { + final SparkSpendRecipientType type; + final int index; + final BigInt amount; + + const SparkSpendRecipientRequest({ + required this.type, + required this.index, + required this.amount, + }); +} + +final class SparkSpendRecipientFragment { + final SparkSpendRecipientType type; + final int index; + final BigInt amount; + + const SparkSpendRecipientFragment({ + required this.type, + required this.index, + required this.amount, + }); +} + +final class SingleInputSparkSpendPlan { + final int coinIndex; + final BigInt fee; + final List recipients; + + const SingleInputSparkSpendPlan({ + required this.coinIndex, + required this.fee, + required this.recipients, + }); +} + +final class _RemainingSparkRecipient { + final SparkSpendRecipientRequest recipient; + BigInt amount; + + _RemainingSparkRecipient(this.recipient) : amount = recipient.amount; +} + +Future> planSingleInputSparkSpends({ + required List coinValues, + required List recipients, + required SparkSpendFeeEstimator estimateFee, + required BigInt maxTransparentAmount, + required int maxPrivateRecipients, + required int maxTransactions, + required int maxTransactionWeight, +}) async { + if (recipients.isEmpty) { + throw Exception("No recipients provided."); + } + if (recipients.any((e) => e.amount <= BigInt.zero)) { + throw Exception("Recipient has invalid amount."); + } + + final remaining = recipients.map(_RemainingSparkRecipient.new).toList(); + final plans = []; + int recipientIndex = 0; + + for ( + int coinIndex = 0; + coinIndex < coinValues.length && recipientIndex < remaining.length; + coinIndex++ + ) { + final coinValue = coinValues[coinIndex]; + final fragments = []; + BigInt amount = BigInt.zero; + BigInt fee = BigInt.zero; + BigInt transparentAmount = BigInt.zero; + int privateRecipientCount = 0; + int transparentRecipientCount = 0; + + while (recipientIndex < remaining.length) { + final current = remaining[recipientIndex]; + final isPrivate = + current.recipient.type == SparkSpendRecipientType.private; + final nextPrivateCount = privateRecipientCount + (isPrivate ? 1 : 0); + final nextTransparentCount = + transparentRecipientCount + (isPrivate ? 0 : 1); + + if (nextPrivateCount > maxPrivateRecipients || + (!isPrivate && transparentAmount >= maxTransparentAmount)) { + break; + } + final estimatedSize = + _sparkBaseSize + + _sparkInputSize + + _sparkPrivateOutputSize * (nextPrivateCount + 1) + + _transparentOutputSize * nextTransparentCount; + if (estimatedSize * _witnessScaleFactor >= maxTransactionWeight) { + break; + } + + final nextFee = await estimateFee( + privateRecipientCount: nextPrivateCount, + transparentRecipientCount: nextTransparentCount, + ); + if (nextFee < BigInt.zero) { + throw Exception("Invalid Spark transaction fee."); + } + + BigInt available = coinValue - amount - nextFee; + if (!isPrivate) { + final transparentAvailable = maxTransparentAmount - transparentAmount; + if (available > transparentAvailable) { + available = transparentAvailable; + } + } + if (available <= BigInt.zero) { + break; + } + + final fragmentAmount = current.amount < available + ? current.amount + : available; + fragments.add( + SparkSpendRecipientFragment( + type: current.recipient.type, + index: current.recipient.index, + amount: fragmentAmount, + ), + ); + amount += fragmentAmount; + fee = nextFee; + if (isPrivate) { + privateRecipientCount = nextPrivateCount; + } else { + transparentRecipientCount = nextTransparentCount; + transparentAmount += fragmentAmount; + } + + current.amount -= fragmentAmount; + if (current.amount == BigInt.zero) { + recipientIndex++; + } else { + break; + } + } + + if (fragments.isEmpty) { + continue; + } + + plans.add( + SingleInputSparkSpendPlan( + coinIndex: coinIndex, + fee: fee, + recipients: List.unmodifiable(fragments), + ), + ); + if (plans.length == maxTransactions && recipientIndex < remaining.length) { + throw Exception( + "A Spark payment may use at most $maxTransactions transactions.", + ); + } + } + + if (recipientIndex != remaining.length) { + throw Exception( + "The available Spark coins cannot cover the amount and transaction fees.", + ); + } + + return List.unmodifiable(plans); +} diff --git a/lib/widgets/animated_widgets/rotating_arrows.dart b/lib/widgets/animated_widgets/rotating_arrows.dart index 3da54f63aa..b0d81e49b0 100644 --- a/lib/widgets/animated_widgets/rotating_arrows.dart +++ b/lib/widgets/animated_widgets/rotating_arrows.dart @@ -10,6 +10,7 @@ import 'package:flutter/material.dart'; import 'package:lottie/lottie.dart'; + import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; @@ -56,6 +57,18 @@ class _RotatingArrowsState extends State super.initState(); } + @override + void didUpdateWidget(RotatingArrows oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.spinByDefault != widget.spinByDefault) { + if (widget.spinByDefault) { + animationController.repeat(); + } else { + animationController.stop(); + } + } + } + @override void dispose() { animationController.dispose(); @@ -76,12 +89,14 @@ class _RotatingArrowsState extends State values: [ ValueDelegate.color( const ["**"], - value: widget.color ?? + value: + widget.color ?? Theme.of(context).extension()!.accentColorDark, ), ValueDelegate.strokeColor( const ["**"], - value: widget.color ?? + value: + widget.color ?? Theme.of(context).extension()!.accentColorDark, ), ], diff --git a/lib/widgets/custom_buttons/app_bar_icon_button.dart b/lib/widgets/custom_buttons/app_bar_icon_button.dart index 5147f132d7..3041668fbc 100644 --- a/lib/widgets/custom_buttons/app_bar_icon_button.dart +++ b/lib/widgets/custom_buttons/app_bar_icon_button.dart @@ -14,6 +14,7 @@ import 'package:flutter_svg/svg.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/util.dart'; +import '../conditional_parent.dart'; class AppBarIconButton extends StatelessWidget { const AppBarIconButton({ @@ -25,6 +26,7 @@ class AppBarIconButton extends StatelessWidget { this.size = 36.0, this.shadows = const [], this.semanticsLabel = "Button", + this.tooltip, }); final Widget icon; @@ -34,29 +36,35 @@ class AppBarIconButton extends StatelessWidget { final double size; final List shadows; final String semanticsLabel; + final String? tooltip; @override Widget build(BuildContext context) { - return Container( - height: size, - width: size, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(1000), - color: color ?? Theme.of(context).extension()!.background, - boxShadow: shadows, - ), - child: Semantics( - excludeSemantics: true, - label: semanticsLabel, - child: MaterialButton( - splashColor: Theme.of(context).extension()!.highlight, - padding: EdgeInsets.zero, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(1000), + return ConditionalParent( + condition: tooltip != null, + builder: (child) => Tooltip(message: tooltip, child: child), + child: Container( + height: size, + width: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(1000), + color: + color ?? Theme.of(context).extension()!.background, + boxShadow: shadows, + ), + child: Semantics( + excludeSemantics: true, + label: semanticsLabel, + child: MaterialButton( + splashColor: Theme.of(context).extension()!.highlight, + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(1000), + ), + onPressed: onPressed, + child: icon, ), - onPressed: onPressed, - child: icon, ), ), ); @@ -84,18 +92,16 @@ class AppBarBackButton extends StatelessWidget { final isDesktop = Util.isDesktop; return Padding( padding: isDesktop - ? const EdgeInsets.symmetric( - vertical: 20, - horizontal: 24, - ) + ? const EdgeInsets.symmetric(vertical: 20, horizontal: 24) : const EdgeInsets.all(10), child: AppBarIconButton( semanticsLabel: semanticsLabel, - size: size ?? + size: + size ?? (isDesktop ? isCompact - ? 42 - : 56 + ? 42 + : 56 : 32), color: isDesktop ? Theme.of(context).extension()!.textFieldDefaultBG diff --git a/lib/widgets/custom_buttons/blue_text_button.dart b/lib/widgets/custom_buttons/blue_text_button.dart index eee64d8e00..8304498bee 100644 --- a/lib/widgets/custom_buttons/blue_text_button.dart +++ b/lib/widgets/custom_buttons/blue_text_button.dart @@ -10,6 +10,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; + import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; @@ -25,6 +26,7 @@ class _CustomTextButton extends StatefulWidget { this.onTap, this.enabled = true, this.textSize, + required this.overflow, }); final String text; @@ -33,6 +35,7 @@ class _CustomTextButton extends StatefulWidget { final double? textSize; final Color enabledColor; final Color disabledColor; + final TextOverflow overflow; @override State<_CustomTextButton> createState() => _CustomTextButtonState(); @@ -103,22 +106,22 @@ class _CustomTextButtonState extends State<_CustomTextButton> }, child: RichText( textAlign: TextAlign.center, + overflow: widget.overflow, text: TextSpan( text: widget.text, style: widget.textSize == null - ? STextStyles.link2(context).copyWith( - color: color, - ) - : STextStyles.link2(context).copyWith( - color: color, - fontSize: widget.textSize, - ), + ? STextStyles.link2(context).copyWith(color: color) + : STextStyles.link2( + context, + ).copyWith(color: color, fontSize: widget.textSize), recognizer: widget.enabled ? (TapGestureRecognizer() - ..onTap = () { - widget.onTap?.call(); - controller?.forward().then((value) => controller?.reverse()); - }) + ..onTap = () { + widget.onTap?.call(); + controller?.forward().then( + (value) => controller?.reverse(), + ); + }) : null, ), ), @@ -133,26 +136,29 @@ class CustomTextButton extends StatelessWidget { this.onTap, this.enabled = true, this.textSize, + this.overflow = .clip, }); final String text; final VoidCallback? onTap; final bool enabled; final double? textSize; + final TextOverflow overflow; @override Widget build(BuildContext context) { return _CustomTextButton( key: UniqueKey(), text: text, - enabledColor: Theme.of(context) - .extension()! - .customTextButtonEnabledText, - disabledColor: Theme.of(context) - .extension()! - .customTextButtonDisabledText, + enabledColor: Theme.of( + context, + ).extension()!.customTextButtonEnabledText, + disabledColor: Theme.of( + context, + ).extension()!.customTextButtonDisabledText, enabled: enabled, textSize: textSize, + overflow: overflow, onTap: onTap, ); } diff --git a/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart b/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart index a2c4db1003..c2a4c6cb57 100644 --- a/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart +++ b/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart @@ -29,13 +29,9 @@ import '../desktop/primary_button.dart'; import '../desktop/secondary_button.dart'; import '../loading_indicator.dart'; -enum PaynymFollowToggleButtonStyle { - primary, - detailsPopup, - detailsDesktop, -} +enum PaynymFollowToggleButtonStyle { primary, detailsPopup, detailsDesktop } -const kDisableFollowing = true; +const kDisableFollowing = false; class PaynymFollowToggleButton extends ConsumerStatefulWidget { const PaynymFollowToggleButton({ @@ -63,12 +59,8 @@ class _PaynymFollowToggleButtonState unawaited( showDialog( context: context, - builder: (context) => const LoadingIndicator( - width: 200, - ), - ).then( - (_) => loadingPopped = true, - ), + builder: (context) => const LoadingIndicator(width: 200), + ).then((_) => loadingPopped = true), ); // get wallet to access paynym calls @@ -81,29 +73,35 @@ class _PaynymFollowToggleButtonState final myPCode = await wallet.getPaymentCode(isSegwit: false); - PaynymResponse token = - await ref.read(paynymAPIProvider).token(myPCode.toString()); + PaynymResponse token = await ref + .read(paynymAPIProvider) + .token(myPCode.toString()); // sign token with notification private key String signature = await wallet.signStringWithNotificationKey(token.value!); - var result = await ref.read(paynymAPIProvider).follow( + var result = await ref + .read(paynymAPIProvider) + .follow( token.value!, signature, followedAccount.value!.nonSegwitPaymentCode.code, ); int i = 0; - for (; - i < 10 && - result.statusCode == 401; //"401 Unauthorized - Bad signature"; - i++) { + for ( + ; + i < 10 && result.statusCode == 401; //"401 Unauthorized - Bad signature"; + i++ + ) { token = await ref.read(paynymAPIProvider).token(myPCode.toString()); // sign token with notification private key signature = await wallet.signStringWithNotificationKey(token.value!); - result = await ref.read(paynymAPIProvider).follow( + result = await ref + .read(paynymAPIProvider) + .follow( token.value!, signature, followedAccount.value!.nonSegwitPaymentCode.code, @@ -115,7 +113,11 @@ class _PaynymFollowToggleButtonState Logging.instance.d("Follow result: $result on try $i"); - if (result.value!.following == followedAccount.value!.nymID) { + final followSuccess = + result.statusCode == 200 || + result.value?.following == followedAccount.value?.nymID; + + if (followSuccess && followedAccount.value != null) { if (!loadingPopped && mounted) { Navigator.of(context, rootNavigator: isDesktop).pop(); } @@ -138,6 +140,9 @@ class _PaynymFollowToggleButtonState followedAccount.value!.nymName, followedAccount.value!.nonSegwitPaymentCode.code, followedAccount.value!.segwit, + taproot: PaynymAccountLite.inferTaproot( + followedAccount.value!.nonSegwitPaymentCode.code, + ), ), ); @@ -157,7 +162,8 @@ class _PaynymFollowToggleButtonState unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: "Failed to follow ${followedAccount.value!.nymName}", + message: + "Failed to follow ${followedAccount.value?.nymName ?? "PayNym"}", context: context, ), ); @@ -172,12 +178,8 @@ class _PaynymFollowToggleButtonState unawaited( showDialog( context: context, - builder: (context) => const LoadingIndicator( - width: 200, - ), - ).then( - (_) => loadingPopped = true, - ), + builder: (context) => const LoadingIndicator(width: 200), + ).then((_) => loadingPopped = true), ); final wallet = @@ -189,29 +191,35 @@ class _PaynymFollowToggleButtonState final myPCode = await wallet.getPaymentCode(isSegwit: false); - PaynymResponse token = - await ref.read(paynymAPIProvider).token(myPCode.toString()); + PaynymResponse token = await ref + .read(paynymAPIProvider) + .token(myPCode.toString()); // sign token with notification private key String signature = await wallet.signStringWithNotificationKey(token.value!); - var result = await ref.read(paynymAPIProvider).unfollow( + var result = await ref + .read(paynymAPIProvider) + .unfollow( token.value!, signature, followedAccount.value!.nonSegwitPaymentCode.code, ); int i = 0; - for (; - i < 10 && - result.statusCode == 401; //"401 Unauthorized - Bad signature"; - i++) { + for ( + ; + i < 10 && result.statusCode == 401; //"401 Unauthorized - Bad signature"; + i++ + ) { token = await ref.read(paynymAPIProvider).token(myPCode.toString()); // sign token with notification private key signature = await wallet.signStringWithNotificationKey(token.value!); - result = await ref.read(paynymAPIProvider).unfollow( + result = await ref + .read(paynymAPIProvider) + .unfollow( token.value!, signature, followedAccount.value!.nonSegwitPaymentCode.code, @@ -222,7 +230,11 @@ class _PaynymFollowToggleButtonState Logging.instance.d("Unfollow result: $result on try $i"); - if (result.value!.unfollowing == followedAccount.value!.nymID) { + final unfollowSuccess = + result.statusCode == 200 || + result.value?.unfollowing == followedAccount.value?.nymID; + + if (unfollowSuccess && followedAccount.value != null) { if (!loadingPopped && mounted) { Navigator.of(context, rootNavigator: isDesktop).pop(); } @@ -239,8 +251,9 @@ class _PaynymFollowToggleButtonState final myAccount = ref.read(myPaynymAccountStateProvider.state).state!; - myAccount.following - .removeWhere((e) => e.nymId == followedAccount.value!.nymID); + myAccount.following.removeWhere( + (e) => e.nymId == followedAccount.value!.nymID, + ); ref.read(myPaynymAccountStateProvider.state).state = myAccount.copyWith(); @@ -258,7 +271,8 @@ class _PaynymFollowToggleButtonState unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: "Failed to unfollow ${followedAccount.value!.nymName}", + message: + "Failed to unfollow ${followedAccount.value?.nymName ?? "PayNym"}", context: context, ), ); @@ -314,8 +328,9 @@ class _PaynymFollowToggleButtonState isFollowing ? Assets.svg.userMinus : Assets.svg.userPlus, width: 16, height: 16, - color: - Theme.of(context).extension()!.buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), onPressed: kDisableFollowing ? null : _onPressed, ); @@ -328,8 +343,9 @@ class _PaynymFollowToggleButtonState isFollowing ? Assets.svg.userMinus : Assets.svg.userPlus, width: 16, height: 16, - color: - Theme.of(context).extension()!.buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), iconSpacing: 6, onPressed: kDisableFollowing ? null : _onPressed, diff --git a/lib/widgets/date_picker/date_picker.dart b/lib/widgets/date_picker/date_picker.dart index 328e2c0960..9655fe262f 100644 --- a/lib/widgets/date_picker/date_picker.dart +++ b/lib/widgets/date_picker/date_picker.dart @@ -2,9 +2,13 @@ import 'dart:math'; import 'package:calendar_date_picker2/calendar_date_picker2.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; +import '../../utilities/format.dart'; +import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../conditional_parent.dart'; import '../desktop/primary_button.dart'; @@ -12,7 +16,15 @@ import '../desktop/secondary_button.dart'; part 'sw_date_picker.dart'; -Future showSWDatePicker(BuildContext context) async { +/// [value] holds selected dates. One if [range] is false. Start and end dates +/// otherwise. +Future?> showSWDatePicker( + BuildContext context, { + DateTime? firstDate, + DateTime? lastDate, + List value = const [], + bool range = false, +}) async { final Size size; if (Util.isDesktop) { size = const Size(450, 450); @@ -26,27 +38,28 @@ Future showSWDatePicker(BuildContext context) async { final now = DateTime.now(); - final date = await _showDatePickerDialog( + final dates = await _showDatePickerDialog( context: context, - value: [now], + value: value, dialogSize: size, config: CalendarDatePicker2WithActionButtonsConfig( - firstDate: DateTime(2007), - lastDate: now, + firstDate: firstDate ?? DateTime(2007), + lastDate: lastDate ?? now, currentDate: now, - buttonPadding: const EdgeInsets.only( - right: 16, - ), + rangeBidirectional: range ? false : null, + calendarType: range ? .range : null, + buttonPadding: const EdgeInsets.only(right: 16), centerAlignModePicker: true, - selectedDayHighlightColor: - Theme.of(context).extension()!.accentColorDark, - daySplashColor: Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.6), + selectedDayHighlightColor: Theme.of( + context, + ).extension()!.accentColorDark, + daySplashColor: Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.6), ), ); - return date?.first; + + return dates; } Future?> _showDatePickerDialog({ @@ -63,10 +76,7 @@ Future?> _showDatePickerDialog({ TransitionBuilder? builder, }) { final dialog = Dialog( - insetPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, - ), + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), backgroundColor: Theme.of(context).extension()!.popupBG, surfaceTintColor: Colors.transparent, shadowColor: Colors.transparent, @@ -105,3 +115,220 @@ Future?> _showDatePickerDialog({ useSafeArea: useSafeArea, ); } + +class StackDateRangePicker extends StatelessWidget { + const StackDateRangePicker({ + super.key, + required this.fromDate, + required this.toDate, + this.firstDate, + this.lastDate, + required this.onChanged, + }); + + final DateTime? fromDate; + final DateTime? toDate; + final DateTime? firstDate, lastDate; + final void Function(DateTime? from, DateTime? to) onChanged; + + @override + Widget build(BuildContext context) { + const middleSeparatorPadding = 2.0; + const middleSeparatorWidth = 12.0; + final isDesktop = Util.isDesktop; + + final String fromDateString = switch (fromDate) { + null => "", + final d => Format.formatDate(d), + }; + final String toDateString = switch (toDate) { + null => "", + final d => Format.formatDate(d), + }; + + return Row( + children: [ + Expanded( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + key: const Key("transactionSearchViewFromDatePickerKey"), + onTap: () async { + // check and hide keyboard + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 125)); + } + + if (context.mounted) { + final date = (await showSWDatePicker( + context, + firstDate: firstDate, + lastDate: lastDate, + ))?.first; + if (date != null) { + final newFrom = date; + DateTime? newTo = toDate; + + // flag to adjust date so from date is always before to date + if (newTo != null && !newFrom.isBefore(newTo)) { + newTo = DateTime.fromMillisecondsSinceEpoch( + newFrom.millisecondsSinceEpoch, + ); + } + + onChanged(newFrom, newTo); + } + } + }, + child: Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + border: Border.all( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + width: 1, + ), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: isDesktop ? 17 : 12, + ), + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.calendar, + height: 20, + width: 20, + color: Theme.of( + context, + ).extension()!.textSubtitle2, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + fromDateString.isEmpty ? "From..." : fromDateString, + style: STextStyles.fieldLabel(context).copyWith( + color: fromDateString.isEmpty + ? Theme.of( + context, + ).extension()!.textSubtitle2 + : Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: middleSeparatorPadding, + ), + child: Container( + width: middleSeparatorWidth, + // height: 1, + // color: CFColors.smoke, + ), + ), + Expanded( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + key: const Key("transactionSearchViewToDatePickerKey"), + onTap: () async { + // check and hide keyboard + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 125)); + } + + if (context.mounted) { + final date = (await showSWDatePicker( + context, + firstDate: firstDate, + lastDate: lastDate, + ))?.first; + if (date != null) { + final newTo = date; + DateTime? newFrom = fromDate; + + // flag to adjust date so from date is always before to date + if (newFrom != null && !newTo.isAfter(newFrom)) { + newFrom = DateTime.fromMillisecondsSinceEpoch( + newTo.millisecondsSinceEpoch, + ); + } + + onChanged(newFrom, newTo); + } + } + }, + child: Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + border: Border.all( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + width: 1, + ), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: isDesktop ? 17 : 12, + ), + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.calendar, + height: 20, + width: 20, + color: Theme.of( + context, + ).extension()!.textSubtitle2, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + toDateString.isEmpty ? "To..." : toDateString, + style: STextStyles.fieldLabel(context).copyWith( + color: toDateString.isEmpty + ? Theme.of( + context, + ).extension()!.textSubtitle2 + : Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/desktop/desktop_fee_dialog.dart b/lib/widgets/desktop/desktop_fee_dialog.dart index 82f92a5a25..3ec5124bd7 100644 --- a/lib/widgets/desktop/desktop_fee_dialog.dart +++ b/lib/widgets/desktop/desktop_fee_dialog.dart @@ -15,7 +15,7 @@ import '../../utilities/text_styles.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; -import '../../wl_gen/interfaces/cs_monero_interface.dart'; +import '../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../animated_text.dart'; import '../conditional_parent.dart'; import 'desktop_dialog.dart'; @@ -60,10 +60,10 @@ class _DesktopFeeDialogState extends ConsumerState { if (widget.isToken == false) { final wallet = ref.read(pWallets).getWallet(walletId); - if (coin is Monero || coin is Wownero) { + if (wallet is CryptonoteWallet) { final fee = await wallet.estimateFeeFor( amount, - BigInt.from(csMonero.getTxPriorityHigh()), + BigInt.from(wallet.getTxPriorityHigh()), ); ref.read(feeSheetSessionCacheProvider).fast[amount] = fee; } else if (coin is Firo) { @@ -110,10 +110,10 @@ class _DesktopFeeDialogState extends ConsumerState { if (widget.isToken == false) { final wallet = ref.read(pWallets).getWallet(walletId); - if (coin is Monero || coin is Wownero) { + if (wallet is CryptonoteWallet) { final fee = await wallet.estimateFeeFor( amount, - BigInt.from(csMonero.getTxPriorityMedium()), + BigInt.from(wallet.getTxPriorityMedium()), ); ref.read(feeSheetSessionCacheProvider).average[amount] = fee; } else if (coin is Firo) { @@ -160,10 +160,10 @@ class _DesktopFeeDialogState extends ConsumerState { if (widget.isToken == false) { final wallet = ref.read(pWallets).getWallet(walletId); - if (coin is Monero || coin is Wownero) { + if (wallet is CryptonoteWallet) { final fee = await wallet.estimateFeeFor( amount, - BigInt.from(csMonero.getTxPriorityNormal()), + BigInt.from(wallet.getTxPriorityNormal()), ); ref.read(feeSheetSessionCacheProvider).slow[amount] = fee; } else if (coin is Firo) { diff --git a/lib/widgets/desktop/primary_button.dart b/lib/widgets/desktop/primary_button.dart index c2f1269984..3e6efd32f1 100644 --- a/lib/widgets/desktop/primary_button.dart +++ b/lib/widgets/desktop/primary_button.dart @@ -28,6 +28,7 @@ class PrimaryButton extends StatelessWidget { this.enabled = true, this.buttonHeight, this.iconSpacing = 10, + this.horizontalContentPadding, }); final double? width; @@ -38,6 +39,7 @@ class PrimaryButton extends StatelessWidget { final Widget? icon; final ButtonHeight? buttonHeight; final double? iconSpacing; + final double? horizontalContentPadding; TextStyle getStyle(bool isDesktop, BuildContext context) { if (isDesktop) { @@ -54,9 +56,9 @@ class PrimaryButton extends StatelessWidget { return STextStyles.desktopTextExtraExtraSmall(context).copyWith( color: enabled ? Theme.of(context).extension()!.buttonTextPrimary - : Theme.of(context) - .extension()! - .buttonTextPrimaryDisabled, + : Theme.of( + context, + ).extension()!.buttonTextPrimaryDisabled, ); case ButtonHeight.m: @@ -64,9 +66,9 @@ class PrimaryButton extends StatelessWidget { return STextStyles.desktopTextExtraSmall(context).copyWith( color: enabled ? Theme.of(context).extension()!.buttonTextPrimary - : Theme.of(context) - .extension()! - .buttonTextPrimaryDisabled, + : Theme.of( + context, + ).extension()!.buttonTextPrimaryDisabled, ); case ButtonHeight.xl: @@ -81,17 +83,17 @@ class PrimaryButton extends StatelessWidget { fontSize: 10, color: enabled ? Theme.of(context).extension()!.buttonTextPrimary - : Theme.of(context) - .extension()! - .buttonTextPrimaryDisabled, + : Theme.of( + context, + ).extension()!.buttonTextPrimaryDisabled, ); } return STextStyles.button(context).copyWith( color: enabled ? Theme.of(context).extension()!.buttonTextPrimary - : Theme.of(context) - .extension()! - .buttonTextPrimaryDisabled, + : Theme.of( + context, + ).extension()!.buttonTextPrimaryDisabled, ); } } @@ -145,37 +147,34 @@ class PrimaryButton extends StatelessWidget { textButton: TextButton( onPressed: enabled ? onPressed : null, style: enabled - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context) + ? Theme.of( + context, + ).extension()!.getPrimaryEnabledButtonStyle(context) : Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle(context), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (icon != null) icon!, - if (icon != null && label != null) - SizedBox( - width: iconSpacing, - ), - if (label != null) - Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - label!, - style: getStyle(isDesktop, context), - ), - if (buttonHeight != null && buttonHeight == ButtonHeight.s) - const SizedBox( - height: 2, - ), - ], - ), - ], + .extension()! + .getPrimaryDisabledButtonStyle(context), + child: Padding( + padding: horizontalContentPadding == null + ? .zero + : .symmetric(horizontal: horizontalContentPadding!), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) icon!, + if (icon != null && label != null) SizedBox(width: iconSpacing), + if (label != null) + Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(label!, style: getStyle(isDesktop, context)), + if (buttonHeight != null && buttonHeight == ButtonHeight.s) + const SizedBox(height: 2), + ], + ), + ], + ), ), ), ); diff --git a/lib/widgets/detail_item.dart b/lib/widgets/detail_item.dart index 91a662538d..af6c62c6f5 100644 --- a/lib/widgets/detail_item.dart +++ b/lib/widgets/detail_item.dart @@ -12,12 +12,15 @@ class DetailItem extends StatelessWidget { required this.title, required this.detail, this.button, + this.titleStyle, this.overrideDetailTextColor, this.showEmptyDetail = true, this.horizontal = false, this.disableSelectableText = false, this.borderColor, this.expandDetail = false, + this.detailPlaceholder, + this.noPadding = false, }); final String title; @@ -29,6 +32,9 @@ class DetailItem extends StatelessWidget { final Color? overrideDetailTextColor; final Color? borderColor; final bool expandDetail; + final String? detailPlaceholder; + final TextStyle? titleStyle; + final bool noPadding; @override Widget build(BuildContext context) { @@ -41,7 +47,7 @@ class DetailItem extends StatelessWidget { } if (detail.isEmpty && showEmptyDetail) { - _detail = "$title will appear here"; + _detail = detailPlaceholder ?? "$title will appear here"; detailStyle = detailStyle.copyWith( color: Theme.of(context).extension()!.textSubtitle3, ); @@ -51,14 +57,17 @@ class DetailItem extends StatelessWidget { horizontal: horizontal, borderColor: borderColor, expandDetail: expandDetail, - title: - disableSelectableText - ? Text(title, style: STextStyles.itemSubtitle(context)) - : SelectableText(title, style: STextStyles.itemSubtitle(context)), - detail: - disableSelectableText - ? Text(_detail, style: detailStyle) - : SelectableText(_detail, style: detailStyle), + noPadding: noPadding, + title: disableSelectableText + ? Text(title, style: titleStyle ?? STextStyles.itemSubtitle(context)) + : SelectableText( + title, + style: titleStyle ?? STextStyles.itemSubtitle(context), + ), + detail: disableSelectableText + ? Text(_detail, style: detailStyle) + : SelectableText(_detail, style: detailStyle), + button: button, ); } } @@ -72,6 +81,9 @@ class DetailItemBase extends StatelessWidget { this.horizontal = false, this.borderColor, this.expandDetail = false, + this.noPadding = false, + this.crossAxisAlignment, + this.mainAxisAlignment, }); final Widget title; @@ -80,53 +92,59 @@ class DetailItemBase extends StatelessWidget { final bool horizontal; final Color? borderColor; final bool expandDetail; + final bool noPadding; + final CrossAxisAlignment? crossAxisAlignment; + final MainAxisAlignment? mainAxisAlignment; @override Widget build(BuildContext context) { return ConditionalParent( condition: !Util.isDesktop || borderColor != null, - builder: - (child) => RoundedWhiteContainer( - padding: - Util.isDesktop - ? const EdgeInsets.all(16) - : const EdgeInsets.all(12), - borderColor: borderColor, - child: child, - ), + builder: (child) => RoundedWhiteContainer( + padding: noPadding + ? EdgeInsets.zero + : Util.isDesktop + ? const EdgeInsets.all(16) + : const EdgeInsets.all(12), + borderColor: borderColor, + child: child, + ), child: ConditionalParent( condition: Util.isDesktop && borderColor == null, - builder: - (child) => Padding(padding: const EdgeInsets.all(16), child: child), - child: - horizontal - ? Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - title, - if (expandDetail) const SizedBox(width: 16), - ConditionalParent( - condition: expandDetail, - builder: (child) => Expanded(child: child), - child: detail, - ), - ], - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [title, button ?? Container()], - ), - const SizedBox(height: 5), - ConditionalParent( - condition: expandDetail, - builder: (child) => Expanded(child: child), - child: detail, - ), - ], - ), + builder: (child) => Padding( + padding: noPadding ? EdgeInsets.zero : const EdgeInsets.all(16), + child: child, + ), + child: horizontal + ? Row( + mainAxisAlignment: mainAxisAlignment ?? .spaceBetween, + crossAxisAlignment: crossAxisAlignment ?? .center, + children: [ + title, + if (expandDetail) const SizedBox(width: 16), + ConditionalParent( + condition: expandDetail, + builder: (child) => Expanded(child: child), + child: detail, + ), + ], + ) + : Column( + mainAxisAlignment: mainAxisAlignment ?? .start, + crossAxisAlignment: crossAxisAlignment ?? .start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [title, button ?? Container()], + ), + const SizedBox(height: 5), + ConditionalParent( + condition: expandDetail, + builder: (child) => Expanded(child: child), + child: detail, + ), + ], + ), ), ); } diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart new file mode 100644 index 0000000000..14f788bd0d --- /dev/null +++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; + +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../desktop/primary_button.dart'; +import '../../desktop/secondary_button.dart'; +import '../s_dialog.dart'; +import 'nested_navigator_dialog_route_generator.dart'; + +class NestedNavigatorDialog extends StatefulWidget { + const NestedNavigatorDialog({ + super.key, + required this.initialRoute, + this.initialRouteArgs, + this.navigatorKey, + }); + + final String initialRoute; + final Object? initialRouteArgs; + final GlobalKey? navigatorKey; + + /// Grabs the nearest [NestedNavigatorDialogState]. Use [maybeOf] if you're + /// not sure one exists. + static NestedNavigatorDialogState of(BuildContext context) { + final NestedNavigatorDialogState? state = maybeOf(context); + assert(state != null, "No NestedNavigatorDialog found above this context."); + return state!; + } + + static NestedNavigatorDialogState? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType<_NestedNavigatorDialogScope>() + ?.state; + } + + @override + State createState() => NestedNavigatorDialogState(); +} + +class NestedNavigatorDialogState extends State { + late final _CloseOnEmptyObserver _observer; + late final GlobalKey _navigatorKey; + + NavigatorState? _parentNavigator; + + Future close({ + NestedNavigatorDialogCloseArgs args = const .genericWarning(), + }) async { + if (!mounted) return; + + final bool proceed = switch (args) { + _NoWarning() => true, + _GenericWarning() => await _showGenericWarning(), + _CustomWarning(:final shouldClose) => await shouldClose(), + }; + + if (proceed && mounted) _parentNavigator?.pop(); + } + + Future _showGenericWarning() async { + final confirmed = await showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: true, + builder: (context) { + assert(Util.isDesktop, ""); + + return SDialog( + padding: const .all(32), + child: SizedBox( + width: 500, + child: Column( + crossAxisAlignment: .start, + mainAxisSize: .min, + children: [ + Text("Discard changes?", style: STextStyles.desktopH3(context)), + const SizedBox(height: 16), + Text( + "Are you sure you want to close?", + style: STextStyles.desktopTextSmall(context), + ), + const SizedBox(height: 40), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: ButtonHeight.l, + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(false), + ), + ), + const SizedBox(width: 24), + Expanded( + child: PrimaryButton( + label: "Discard", + buttonHeight: ButtonHeight.l, + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(true), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + + return confirmed ?? false; + } + + @override + void initState() { + super.initState(); + _observer = _CloseOnEmptyObserver(() => close(args: const .noWarning())); + _navigatorKey = widget.navigatorKey ?? GlobalKey(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _parentNavigator = Navigator.of(context); + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + insetPadding: EdgeInsets.zero, + child: _NestedNavigatorDialogScope( + state: this, + child: Navigator( + key: _navigatorKey, + observers: [_observer], + onGenerateRoute: NestedNavigatorDialogRouteGenerator.generateRoute, + onGenerateInitialRoutes: (_, _) => [ + NestedNavigatorDialogRouteGenerator.generateRoute( + RouteSettings( + name: widget.initialRoute, + arguments: widget.initialRouteArgs, + ), + ), + ], + ), + ), + ); + } +} + +class _NestedNavigatorDialogScope extends InheritedWidget { + const _NestedNavigatorDialogScope({ + required this.state, + required super.child, + }); + + final NestedNavigatorDialogState state; + + @override + bool updateShouldNotify(_NestedNavigatorDialogScope oldWidget) { + return state != oldWidget.state; + } +} + +class _CloseOnEmptyObserver extends NavigatorObserver { + _CloseOnEmptyObserver(this.onEmpty); + + final VoidCallback onEmpty; + + @override + void didPop(Route route, Route? previousRoute) { + if (previousRoute == null) onEmpty(); + } +} + +sealed class NestedNavigatorDialogCloseArgs { + const NestedNavigatorDialogCloseArgs(); + + const factory NestedNavigatorDialogCloseArgs.noWarning() = _NoWarning; + const factory NestedNavigatorDialogCloseArgs.genericWarning() = + _GenericWarning; + const factory NestedNavigatorDialogCloseArgs.customWarning( + Future Function() shouldClose, + ) = _CustomWarning; +} + +class _NoWarning extends NestedNavigatorDialogCloseArgs { + const _NoWarning(); +} + +class _GenericWarning extends NestedNavigatorDialogCloseArgs { + const _GenericWarning(); +} + +class _CustomWarning extends NestedNavigatorDialogCloseArgs { + const _CustomWarning(this.shouldClose); + final Future Function() shouldClose; +} diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart new file mode 100644 index 0000000000..9c0cbc249f --- /dev/null +++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart @@ -0,0 +1,305 @@ +import 'package:flutter/material.dart'; + +import '../../../db/drift/shared_db/shared_database.dart'; +import '../../../models/shopinbit/shopinbit_enums.dart'; +import '../../../models/shopinbit/shopinbit_request_draft.dart'; +import '../../../pages/cakepay/cakepay_card_detail_view.dart'; +import '../../../pages/cakepay/cakepay_order_view.dart'; +import '../../../pages/cakepay/cakepay_orders_view.dart'; +import '../../../pages/cakepay/cakepay_vendors_view.dart'; +import '../../../pages/shopinbit/shopinbit_car_fee_view.dart'; +import '../../../pages/shopinbit/shopinbit_car_research_payment_view.dart'; +import '../../../pages/shopinbit/shopinbit_offer_view.dart'; +import '../../../pages/shopinbit/shopinbit_order_created.dart'; +import '../../../pages/shopinbit/shopinbit_payment_view.dart'; +import '../../../pages/shopinbit/shopinbit_shipping_view.dart'; +import '../../../pages/shopinbit/shopinbit_step_2.dart'; +import '../../../pages/shopinbit/shopinbit_step_3.dart'; +import '../../../pages/shopinbit/shopinbit_step_4.dart'; +import '../../../pages/shopinbit/shopinbit_ticket_detail.dart'; +import '../../../pages/shopinbit/shopinbit_tickets_view.dart'; +import '../../../pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart'; +import '../../../services/cakepay/src/models/card.dart'; +import '../../../services/cakepay/src/models/order.dart'; +import '../../../services/shopinbit/src/models/models.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../conditional_parent.dart'; +import '../../desktop/desktop_dialog_close_button.dart'; +import '../s_dialog.dart'; + +abstract final class NestedNavigatorDialogRouteGenerator { + static Route generateRoute(RouteSettings settings) { + final args = settings.arguments; + + switch (settings.name) { + case DesktopShopinBitFirstRun.routeName: + return getRoute( + builder: (_) => const DesktopShopinBitFirstRun(), + settings: RouteSettings(name: settings.name), + ); + + case ShopInBitStep2.routeName: + if (args is bool) { + return getRoute( + builder: (_) => ShopInBitStep2(isActuallyFirstStep: args), + settings: RouteSettings(name: settings.name), + ); + } + return getRoute( + builder: (_) => const ShopInBitStep2(), + settings: RouteSettings(name: settings.name), + ); + + case ShopInBitStep3.routeName: + if (args is ({ShopInBitCategory category, String customerKey})) { + return getRoute( + builder: (_) => ShopInBitStep3( + category: args.category, + customerKey: args.customerKey, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected ({ShopInBitCategory category, String customerKey})", + ); + + case ShopInBitStep4.routeName: + if (args is ShopInBitCategory) { + return getRoute( + builder: (_) => ShopInBitStep4(category: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected ShopInBitCategory", + ); + + case ShopInBitTicketsView.routeName: + return getRoute( + builder: (_) => const ShopInBitTicketsView(), + settings: RouteSettings(name: settings.name), + ); + + case ShopInBitOrderCreated.routeName: + if (args is int) { + return getRoute( + builder: (_) => ShopInBitOrderCreated(apiTicketId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected int apiTicketId", + ); + + case ShopInBitCarFeeView.routeName: + if (args is ShopinbitRequestDraft) { + return getRoute( + builder: (_) => ShopInBitCarFeeView(draft: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected ShopinbitRequestDraft", + ); + + case ShopInBitCarResearchPaymentView.routeName: + if (args is ({CarResearchInvoice invoice, String customerKey})) { + return getRoute( + builder: (_) => ShopInBitCarResearchPaymentView( + invoice: args.invoice, + customerKey: args.customerKey, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected CarResearchInvoice", + ); + + case ShopInBitTicketDetail.routeName: + if (args is int) { + return getRoute( + builder: (_) => ShopInBitTicketDetail(apiTicketId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected int apiTicketId", + ); + + case ShopInBitOfferView.routeName: + if (args is int) { + return getRoute( + builder: (_) => ShopInBitOfferView(apiTicketId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected int apiTicketId", + ); + + case ShopInBitShippingView.routeName: + if (args + is ({ + ShopInBitTicket ticket, + List> countries, + })) { + return getRoute( + builder: (_) => ShopInBitShippingView( + ticket: args.ticket, + countries: args.countries, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected ({int apiTicketId, String deliveryCountry, " + "List> countries})", + ); + + case ShopInBitPaymentView.routeName: + if (args is ({int apiTicketId, PaymentInfo paymentInfo})) { + return getRoute( + builder: (_) => ShopInBitPaymentView( + apiTicketId: args.apiTicketId, + paymentInfo: args.paymentInfo, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected ({int apiTicketId, PaymentInfo paymentInfo})", + ); + + case CakePayVendorsView.routeName: + return getRoute( + builder: (_) => const CakePayVendorsView(), + settings: RouteSettings(name: settings.name), + ); + + case CakePayOrdersView.routeName: + return getRoute( + builder: (_) => const CakePayOrdersView(), + settings: RouteSettings(name: settings.name), + ); + + case CakePayCardDetailView.routeName: + if (args is CakePayCard) { + return getRoute( + builder: (_) => CakePayCardDetailView(card: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected CakePayCard", + ); + + case CakePayOrderView.routeName: + if (args is CakePayOrder) { + return getRoute( + builder: (_) => CakePayOrderView(order: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected CakePayOrder", + ); + + default: + return _routeError("Unknown route name: ${settings.name}"); + } + } + + static Route getRoute({ + required WidgetBuilder builder, + RouteSettings? settings, + }) { + return PageRouteBuilder( + settings: settings, + opaque: false, + barrierColor: Colors.transparent, + transitionDuration: const Duration(milliseconds: 220), + reverseTransitionDuration: const Duration(milliseconds: 220), + pageBuilder: (BuildContext context, _, __) => builder(context), + transitionsBuilder: + ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return FadeTransition( + opacity: animation, + child: FadeTransition( + opacity: Tween( + begin: 1, + end: 0, + ).animate(secondaryAnimation), + child: child, + ), + ); + }, + ); + } + + static Route _routeError(String message) { + return getRoute( + builder: (context) => SDialog( + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Navigation Error", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + child, + const SizedBox(height: 32), + ], + ), + ), + child: SelectableText( + "Error handling route, this is not supposed to happen. " + "Contact developers.\n$message", + ), + ), + ), + ); + } +} diff --git a/lib/widgets/dialogs/request_external_link_navigation_dialog.dart b/lib/widgets/dialogs/request_external_link_navigation_dialog.dart new file mode 100644 index 0000000000..a5491a555a --- /dev/null +++ b/lib/widgets/dialogs/request_external_link_navigation_dialog.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../conditional_parent.dart'; +import '../desktop/desktop_dialog_close_button.dart'; +import '../desktop/primary_button.dart'; +import '../desktop/secondary_button.dart'; +import 's_dialog.dart'; + +Future showRequestExternalLinkAndMaybeLaunch( + BuildContext context, { + required Uri uri, +}) async { + final shouldContinue = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => RequestExternalLinkNavigationDialog(uri: uri), + ); + + if (shouldContinue == true) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } +} + +class RequestExternalLinkNavigationDialog extends StatefulWidget { + const RequestExternalLinkNavigationDialog({super.key, required this.uri}); + + final Uri uri; + + @override + State createState() => + _RequestExternalLinkNavigationDialogState(); +} + +class _RequestExternalLinkNavigationDialogState + extends State { + @override + Widget build(BuildContext context) { + return SDialog( + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => SizedBox(width: 500, child: child), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Padding( + padding: .only( + left: Util.isDesktop ? 32 : 16, + top: Util.isDesktop ? 0 : 16, + bottom: Util.isDesktop ? 16 : 8, + ), + child: Row( + mainAxisAlignment: .spaceBetween, + children: [ + SelectableText( + "Attention", + style: Util.isDesktop + ? STextStyles.desktopH3(context) + : STextStyles.pageTitleH2(context), + ), + if (Util.isDesktop) const DesktopDialogCloseButton(), + ], + ), + ), + Padding( + padding: .symmetric(horizontal: Util.isDesktop ? 32 : 16), + child: Text( + "You are about to open " + "${widget.uri.scheme}://${widget.uri.host} " + "in your browser.", + style: Util.isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.smallMed14(context), + ), + ), + Padding( + padding: .only( + top: Util.isDesktop ? 32 : 24, + left: Util.isDesktop ? 32 : 16, + right: Util.isDesktop ? 32 : 16, + bottom: Util.isDesktop ? 32 : 16, + ), + child: Row( + mainAxisAlignment: .spaceBetween, + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: Util.isDesktop ? .l : null, + onPressed: Navigator.of(context).pop, + ), + ), + Util.isDesktop + ? const SizedBox(width: 32) + : const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Continue", + buttonHeight: Util.isDesktop ? .l : null, + onPressed: () => Navigator.of(context).pop(true), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/dialogs/s_dialog.dart b/lib/widgets/dialogs/s_dialog.dart index a6b32148c4..6bf66ecf4a 100644 --- a/lib/widgets/dialogs/s_dialog.dart +++ b/lib/widgets/dialogs/s_dialog.dart @@ -29,30 +29,26 @@ class SDialog extends StatelessWidget { return Padding( padding: margin ?? EdgeInsets.all(Util.isDesktop ? 32 : 16), child: Column( - mainAxisAlignment: mainAxisAlignment ?? + mainAxisAlignment: + mainAxisAlignment ?? (Util.isDesktop ? MainAxisAlignment.center : MainAxisAlignment.end), crossAxisAlignment: crossAxisAlignment ?? CrossAxisAlignment.center, + mainAxisSize: .min, children: [ Flexible( child: Material( borderRadius: BorderRadius.circular(20), child: Container( decoration: BoxDecoration( - color: background ?? + color: + background ?? Theme.of(context).extension()!.popupBG, - borderRadius: BorderRadius.circular( - 20, - ), + borderRadius: BorderRadius.circular(20), ), child: ConditionalParent( condition: contentCanScroll, - builder: (child) => SingleChildScrollView( - child: child, - ), - child: Padding( - padding: padding, - child: child, - ), + builder: (child) => SingleChildScrollView(child: child), + child: Padding(padding: padding, child: child), ), ), ), diff --git a/lib/widgets/epic_txs_method_toggle.dart b/lib/widgets/epic_txs_method_toggle.dart new file mode 100644 index 0000000000..f08f086f16 --- /dev/null +++ b/lib/widgets/epic_txs_method_toggle.dart @@ -0,0 +1,67 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2026-01-12 + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/ui/preview_tx_button_state_provider.dart'; +import '../themes/stack_colors.dart'; +import '../utilities/assets.dart'; +import '../utilities/constants.dart'; +import '../utilities/enums/epic_transaction_method.dart'; +import '../utilities/util.dart'; +import 'toggle.dart'; + +class EpicTxsMethodToggle extends ConsumerWidget { + const EpicTxsMethodToggle({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + debugPrint("BUILD: $runtimeType"); + final isDesktop = Util.isDesktop; + + return Toggle( + onValueChanged: (value) { + ref.read(pSelectedEpicTransactionMethod.notifier).state = + value + ? EpicTransactionMethod.epicbox + : EpicTransactionMethod.slatepack; + }, + isOn: + ref.watch(pSelectedEpicTransactionMethod) == + EpicTransactionMethod.epicbox, + onColor: + isDesktop + ? Theme.of( + context, + ).extension()!.rateTypeToggleDesktopColorOn + : Theme.of( + context, + ).extension()!.rateTypeToggleColorOn, + offColor: + isDesktop + ? Theme.of( + context, + ).extension()!.rateTypeToggleDesktopColorOff + : Theme.of( + context, + ).extension()!.rateTypeToggleColorOff, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onIcon: Assets.svg.gear, + onText: "Slatepack", + offIcon: Assets.svg.radioSyncing, + offText: "Automatic", + ); + } +} diff --git a/lib/widgets/epicbox_card.dart b/lib/widgets/epicbox_card.dart new file mode 100644 index 0000000000..ca80683e81 --- /dev/null +++ b/lib/widgets/epicbox_card.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../providers/global/node_service_provider.dart'; +import '../themes/stack_colors.dart'; +import '../utilities/assets.dart'; +import '../utilities/default_epicboxes.dart'; +import '../utilities/test_epicbox_server_connection.dart'; +import '../utilities/text_styles.dart'; +import '../utilities/util.dart'; +import 'custom_buttons/blue_text_button.dart'; +import 'expandable.dart'; +import 'rounded_white_container.dart'; + +class EpicBoxCard extends ConsumerStatefulWidget { + const EpicBoxCard({ + super.key, + required this.epicBoxId, + required this.onConnect, + required this.onEdit, + this.testOnInit = false, + }); + + final String epicBoxId; + final VoidCallback onConnect; + final VoidCallback onEdit; + final bool testOnInit; + + @override + ConsumerState createState() => _EpicBoxCardState(); +} + +class _EpicBoxCardState extends ConsumerState { + bool _advancedIsExpanded = false; + bool _testing = false; + bool? _testResult; + + @override + void initState() { + super.initState(); + if (widget.testOnInit) { + WidgetsBinding.instance.addPostFrameCallback((_) => _testConnection()); + } + } + + @override + void didUpdateWidget(EpicBoxCard oldWidget) { + super.didUpdateWidget(oldWidget); + // Auto-test when testOnInit changes from false to true + if (widget.testOnInit && !oldWidget.testOnInit && _testResult == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _testConnection(); + }); + } + } + + Future _testConnection() async { + final epicBox = + ref + .read(nodeServiceChangeNotifierProvider) + .getEpicBoxById(id: widget.epicBoxId) ?? + DefaultEpicBoxes.all.firstWhere((e) => e.id == widget.epicBoxId); + + setState(() { + _testing = true; + _testResult = null; + }); + + final data = EpicBoxFormData() + ..host = epicBox.host + ..port = epicBox.port ?? 443 + ..useSSL = epicBox.useSSL; + + final result = await testEpicBoxServerConnection(data) != null; + + if (mounted) { + setState(() { + _testing = false; + _testResult = result; + }); + } + } + + @override + Widget build(BuildContext context) { + final epicBox = + ref.watch( + nodeServiceChangeNotifierProvider.select( + (value) => value.getEpicBoxById(id: widget.epicBoxId), + ), + ) ?? + DefaultEpicBoxes.all.firstWhere((e) => e.id == widget.epicBoxId); + + final primaryEpicBox = ref.watch( + nodeServiceChangeNotifierProvider.select( + (value) => value.getPrimaryEpicBox(), + ), + ); + + final isPrimary = primaryEpicBox?.id == epicBox.id; + final isDesktop = Util.isDesktop; + + String status; + Color? statusColor; + if (_testing) { + status = "Testing..."; + } else if (_testResult == true) { + status = isPrimary ? "Connected" : "Reachable"; + statusColor = Theme.of( + context, + ).extension()!.accentColorGreen; + } else if (_testResult == false) { + status = "Unreachable"; + statusColor = Theme.of(context).extension()!.accentColorRed; + } else { + status = isPrimary ? "Selected" : ""; + if (isPrimary) { + statusColor = Theme.of( + context, + ).extension()!.accentColorBlue; + } + } + + return RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + borderColor: isDesktop + ? Theme.of(context).extension()!.background + : null, + child: Expandable( + onExpandChanged: (state) { + setState(() { + _advancedIsExpanded = state == ExpandableState.expanded; + }); + }, + header: Padding( + padding: EdgeInsets.all(isDesktop ? 16 : 12), + child: Row( + children: [ + Container( + width: isDesktop ? 40 : 24, + height: isDesktop ? 40 : 24, + decoration: BoxDecoration( + color: epicBox.isDefault + ? Theme.of( + context, + ).extension()!.buttonBackSecondary + : Theme.of(context) + .extension()! + .infoItemIcons + .withOpacity(0.2), + borderRadius: BorderRadius.circular(100), + ), + child: Center( + child: SvgPicture.asset( + Assets.svg.node, + height: isDesktop ? 18 : 11, + width: isDesktop ? 20 : 14, + color: epicBox.isDefault + ? Theme.of( + context, + ).extension()!.accentColorDark + : Theme.of( + context, + ).extension()!.infoItemIcons, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(epicBox.name, style: STextStyles.titleBold12(context)), + const SizedBox(height: 2), + Text( + "${epicBox.host}:${epicBox.port ?? 443}", + style: STextStyles.label(context), + ), + ], + ), + ), + Text( + status, + style: STextStyles.label(context).copyWith(color: statusColor), + ), + const SizedBox(width: 12), + SvgPicture.asset( + _advancedIsExpanded + ? Assets.svg.chevronUp + : Assets.svg.chevronDown, + width: 12, + height: 6, + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ], + ), + ), + body: Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Row( + children: [ + const SizedBox(width: 66), + CustomTextButton( + text: "Test", + enabled: !_testing, + onTap: _testConnection, + ), + const SizedBox(width: 48), + CustomTextButton( + text: "Connect", + enabled: !isPrimary, + onTap: widget.onConnect, + ), + const SizedBox(width: 48), + if (!epicBox.isDefault) + CustomTextButton(text: "Edit", onTap: widget.onEdit), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/icon_widgets/credit_card_icon.dart b/lib/widgets/icon_widgets/credit_card_icon.dart new file mode 100644 index 0000000000..369792e562 --- /dev/null +++ b/lib/widgets/icon_widgets/credit_card_icon.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; + +class CreditCardIcon extends StatelessWidget { + const CreditCardIcon({ + super.key, + this.width = 32, + this.height = 32, + this.color, + }); + + final double width; + final double height; + final Color? color; + + @override + Widget build(BuildContext context) { + return SvgPicture.asset( + Assets.svg.creditCard, + width: width, + height: height, + colorFilter: ColorFilter.mode( + color ?? Theme.of(context).extension()!.textDark3, + BlendMode.srcIn, + ), + ); + } +} diff --git a/lib/widgets/icon_widgets/eth_token_icon.dart b/lib/widgets/icon_widgets/eth_token_icon.dart index 0b0104fbf9..856474283e 100644 --- a/lib/widgets/icon_widgets/eth_token_icon.dart +++ b/lib/widgets/icon_widgets/eth_token_icon.dart @@ -8,6 +8,8 @@ * */ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; @@ -18,6 +20,7 @@ import '../../services/exchange/change_now/change_now_exchange.dart'; import '../../services/exchange/exchange_data_loading_service.dart'; import '../../themes/coin_icon_provider.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../loading_indicator.dart'; class EthTokenIcon extends ConsumerStatefulWidget { const EthTokenIcon({ @@ -41,18 +44,14 @@ class _EthTokenIconState extends ConsumerState { super.initState(); ExchangeDataLoadingService.instance.isar.then((isar) async { - final currency = - await isar.currencies - .where() - .exchangeNameEqualTo(ChangeNowExchange.exchangeName) - .filter() - .tokenContractEqualTo( - widget.contractAddress, - caseSensitive: false, - ) - .and() - .imageIsNotEmpty() - .findFirst(); + final currency = await isar.currencies + .where() + .exchangeNameEqualTo(ChangeNowExchange.exchangeName) + .filter() + .tokenContractEqualTo(widget.contractAddress, caseSensitive: false) + .and() + .imageIsNotEmpty() + .findFirst(); if (mounted) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -69,8 +68,8 @@ class _EthTokenIconState extends ConsumerState { @override Widget build(BuildContext context) { if (imageUrl == null || imageUrl!.isEmpty) { - return SvgPicture.asset( - ref.watch(coinIconProvider(Ethereum(CryptoCurrencyNetwork.main))), + return SvgPicture.file( + File(ref.watch(coinIconProvider(Ethereum(.main)))), width: widget.size, height: widget.size, ); @@ -79,6 +78,7 @@ class _EthTokenIconState extends ConsumerState { imageUrl!, width: widget.size, height: widget.size, + placeholderBuilder: (_) => const LoadingIndicator(), ); } } diff --git a/lib/widgets/icon_widgets/exchange_icon.dart b/lib/widgets/icon_widgets/exchange_icon.dart new file mode 100644 index 0000000000..d9ceacd445 --- /dev/null +++ b/lib/widgets/icon_widgets/exchange_icon.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../services/exchange/exchange.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/util.dart'; + +class ExchangeIcon extends StatelessWidget { + const ExchangeIcon({super.key, required this.exchange}); + + final Exchange exchange; + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final asset = Assets.exchange + .getIconFor(exchangeName: exchange.name) + .toLowerCase(); + + if (asset.endsWith(".svg")) { + return SvgPicture.asset( + asset, + width: isDesktop ? 32 : 24, + height: isDesktop ? 32 : 24, + ); + } else { + return Image.asset( + asset, + width: isDesktop ? 32 : 24, + height: isDesktop ? 32 : 24, + ); + } + } +} diff --git a/lib/widgets/icon_widgets/sol_token_icon.dart b/lib/widgets/icon_widgets/sol_token_icon.dart new file mode 100644 index 0000000000..8907651c3b --- /dev/null +++ b/lib/widgets/icon_widgets/sol_token_icon.dart @@ -0,0 +1,95 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:isar_community/isar.dart'; + +import '../../models/isar/exchange_cache/currency.dart'; +import '../../services/exchange/change_now/change_now_exchange.dart'; +import '../../services/exchange/exchange_data_loading_service.dart'; +import '../../themes/coin_icon_provider.dart'; +import '../../utilities/logger.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../loading_indicator.dart'; + +/// Token icon widget for Solana tokens. +/// +/// Displays the token icon by attempting to fetch from exchange data service. +/// Falls back to generic Solana token icon if no icon is found. +class SolTokenIcon extends ConsumerStatefulWidget { + const SolTokenIcon({super.key, required this.mintAddress, this.size = 22}); + + /// The SOL token mint address. + final String mintAddress; + + final double size; + + @override + ConsumerState createState() => _SolTokenIconState(); +} + +class _SolTokenIconState extends ConsumerState { + String? imageUrl; + + @override + void initState() { + super.initState(); + _loadTokenIcon(); + } + + Future _loadTokenIcon() async { + try { + final isar = await ExchangeDataLoadingService.instance.isar; + final currency = await isar.currencies + .where() + .exchangeNameEqualTo(ChangeNowExchange.exchangeName) + .filter() + .tokenContractEqualTo(widget.mintAddress, caseSensitive: false) + .and() + .imageIsNotEmpty() + .findFirst(); + + if (mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + imageUrl = currency?.image; + }); + } + }); + } + } catch (e, s) { + Logging.instance.e("", error: e, stackTrace: s); + } + } + + @override + Widget build(BuildContext context) { + if (imageUrl == null || imageUrl!.isEmpty) { + // Fallback to Solana coin icon from theme. + return SvgPicture.file( + File(ref.watch(coinIconProvider(Solana(.main)))), + width: widget.size, + height: widget.size, + ); + } else { + // Display token icon from network. + return SvgPicture.network( + imageUrl!, + width: widget.size, + height: widget.size, + placeholderBuilder: (_) => const LoadingIndicator(), + ); + } + } +} diff --git a/lib/widgets/infinite_scroll_list_view.dart b/lib/widgets/infinite_scroll_list_view.dart new file mode 100644 index 0000000000..d027d6c054 --- /dev/null +++ b/lib/widgets/infinite_scroll_list_view.dart @@ -0,0 +1,405 @@ +import "package:flutter/widgets.dart"; + +/// A generic infinite-scroll [ListView]. +/// +/// Works correctly with [shrinkWrap] as long as the parent provides bounded +/// height (e.g. inside a [Flexible] or sized container). +/// +/// Search/filter changes should be applied by updating any state your +/// [fetchPage] closure reads, then calling +/// [InfiniteScrollListController.refresh]. +class InfiniteScrollListView extends StatefulWidget { + const InfiniteScrollListView({ + super.key, + required this.firstPageKey, + required this.fetchPage, + required this.itemBuilder, + this.controller, + this.separatorBuilder, + this.firstPageProgressBuilder, + this.newPageProgressBuilder, + this.firstPageErrorBuilder, + this.newPageErrorBuilder, + this.emptyBuilder, + this.noMoreItemsBuilder, + this.padding, + this.shrinkWrap = false, + this.physics, + this.prefetchThreshold = 200, + }); + + /// Key passed to [fetchPage] for the very first page. + final K firstPageKey; + + /// Fetches a page. Return an [InfiniteScrollPage] with + /// [InfiniteScrollPage.nextPageKey] set to null on the last page. + final Future> Function(K pageKey) fetchPage; + + /// Builds a single data item. + final Widget Function(BuildContext context, T item, int index) itemBuilder; + + final InfiniteScrollListController? controller; + + /// Optional separator builder. Called between data items only (not around + /// the footer). + final Widget Function(BuildContext context, int index)? separatorBuilder; + + final WidgetBuilder? firstPageProgressBuilder; + final WidgetBuilder? newPageProgressBuilder; + final Widget Function(BuildContext context, Object error, VoidCallback retry)? + firstPageErrorBuilder; + final Widget Function(BuildContext context, Object error, VoidCallback retry)? + newPageErrorBuilder; + final WidgetBuilder? emptyBuilder; + final WidgetBuilder? noMoreItemsBuilder; + + final EdgeInsetsGeometry? padding; + final bool shrinkWrap; + final ScrollPhysics? physics; + + /// Pixels from the bottom at which the next page begins fetching. + final double prefetchThreshold; + + @override + State> createState() => + _InfiniteScrollListViewState(); +} + +class _InfiniteScrollListViewState + extends State> { + final ScrollController _scrollController = ScrollController(); + final List _items = []; + + _Status _status = _LoadingFirstPageStatus(); + + /// Incremented on every refresh. Each fetch captures the value at its start; + /// if the captured value differs from the current value when the fetch + /// completes, the result is discarded. + int _generation = 0; + + /// Transition status to a loading variant and start a fetch. + void _fetch(K pageKey) { + setState(() { + _status = _items.isEmpty + ? _LoadingFirstPageStatus() + : _LoadingMoreStatus(); + }); + _runFetch(pageKey); + } + + /// Run a fetch without changing status. Used for the initial fetch and + /// when auto-continuing past an empty page (status is already loading). + Future _runFetch(K pageKey) async { + final generation = _generation; + final wasFirstPage = _items.isEmpty; + + try { + final result = await widget.fetchPage(pageKey); + if (!mounted || generation != _generation) return; + + // Empty page but more pages remain: continue immediately, staying in + // the loading state. (A buggy backend returning unbounded empty pages + // will hammer the API here.) + if (result.items.isEmpty && result.nextPageKey != null) { + return _runFetch(result.nextPageKey as K); + } + + setState(() { + _items.addAll(result.items); + _status = _IdleStatus(nextPageKey: result.nextPageKey); + }); + + // First page may not fill the viewport. After layout, if the list + // still isn't scrollable and more pages exist, fetch the next. + _maybeFetchIfUnderfilled(); + } catch (error) { + if (!mounted || generation != _generation) return; + setState(() { + _status = wasFirstPage + ? _FailedFirstPageStatus(error: error, pageKey: pageKey) + : _FailedMoreStatus(error: error, pageKey: pageKey); + }); + + if (!wasFirstPage) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + }); + } + } + } + + void _maybeFetchIfUnderfilled() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_status case _IdleStatus(nextPageKey: final next?) + when _scrollController.hasClients && + _scrollController.position.maxScrollExtent <= 0) { + _fetch(next); + } + }); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + if (_status case _IdleStatus(nextPageKey: final next?)) { + final position = _scrollController.position; + if (position.pixels >= + position.maxScrollExtent - widget.prefetchThreshold) { + _fetch(next); + } + } + } + + void _refresh() { + _generation++; + setState(() { + _items.clear(); + _status = _LoadingFirstPageStatus(); + }); + _runFetch(widget.firstPageKey); + } + + void _retry() { + if (_status + case _FailedFirstPageStatus(:final pageKey) || + _FailedMoreStatus(:final pageKey)) { + _fetch(pageKey); + } + } + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + widget.controller?._attach(onRefresh: _refresh, onRetry: _retry); + // Status defaults to _LoadingFirstPage so _runFetch can be called directly. + _runFetch(widget.firstPageKey); + } + + @override + void didUpdateWidget(covariant InfiniteScrollListView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller?._detach(); + widget.controller?._attach(onRefresh: _refresh, onRetry: _retry); + } + } + + @override + void dispose() { + widget.controller?._detach(); + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_items.isEmpty) { + return switch (_status) { + _LoadingFirstPageStatus() => + widget.firstPageProgressBuilder?.call(context) ?? + const _DefaultFirstPageProgress(), + _FailedFirstPageStatus(:final error) => + widget.firstPageErrorBuilder?.call(context, error, _retry) ?? + _DefaultErrorView(error: error, onRetry: _retry), + _IdleStatus() => + widget.emptyBuilder?.call(context) ?? const _DefaultEmpty(), + // Defensive: these variants cannot occur with no items. + _LoadingMoreStatus() || + _FailedMoreStatus() => const SizedBox.shrink(), + }; + } + + final Widget? footer = switch (_status) { + _LoadingMoreStatus() => + widget.newPageProgressBuilder?.call(context) ?? + const _DefaultNewPageProgress(), + _FailedMoreStatus(:final error) => + widget.newPageErrorBuilder?.call(context, error, _retry) ?? + _DefaultErrorView(error: error, onRetry: _retry), + _IdleStatus(nextPageKey: null) => widget.noMoreItemsBuilder?.call( + context, + ), + _IdleStatus() => + widget.newPageProgressBuilder?.call(context) ?? + const _DefaultNewPageProgress(), + // Defensive: these variants cannot occur with items present. + _LoadingFirstPageStatus() || _FailedFirstPageStatus() => null, + }; + + final itemCount = _items.length + (footer != null ? 1 : 0); + + return NotificationListener( + onNotification: (_) { + _maybeFetchIfUnderfilled(); + return false; + }, + child: ListView.separated( + controller: _scrollController, + primary: false, + shrinkWrap: widget.shrinkWrap, + physics: widget.physics, + padding: widget.padding, + itemCount: itemCount, + separatorBuilder: (context, index) { + if (index == _items.length - 1 && footer != null) { + return const SizedBox.shrink(); + } + return widget.separatorBuilder?.call(context, index) ?? + const SizedBox.shrink(); + }, + itemBuilder: (context, index) { + if (index < _items.length) { + return widget.itemBuilder(context, _items[index], index); + } + return footer!; + }, + ), + ); + } +} + +// ============================================================================= +// ========= Supporting ======================================================== + +/// A page of results returned from [InfiniteScrollListView.fetchPage]. +/// +/// Set [nextPageKey] to null to signal that this is the last page. +class InfiniteScrollPage { + InfiniteScrollPage({required this.items, required this.nextPageKey}); + + final List items; + final K? nextPageKey; +} + +/// Triggers refresh and retry on an [InfiniteScrollListView] from outside. +/// +/// Create one in the parent's state, pass it to +/// [InfiniteScrollListView.controller], and call [refresh] when search/filter +/// state changes. In-flight fetches from before the refresh are discarded +/// when they complete. +class InfiniteScrollListController { + VoidCallback? _onRefresh; + VoidCallback? _onRetry; + + void _attach({ + required VoidCallback onRefresh, + required VoidCallback onRetry, + }) { + _onRefresh = onRefresh; + _onRetry = onRetry; + } + + void _detach() { + _onRefresh = null; + _onRetry = null; + } + + /// Discard current items and reload from the first page. + void refresh() => _onRefresh?.call(); + + /// Retry the last failed fetch. + void retry() => _onRetry?.call(); +} + +/// The load lifecycle of an [InfiniteScrollListView]. A sealed type so all +/// transitions are explicit and the compiler enforces exhaustive handling. +sealed class _Status { + const _Status(); +} + +class _LoadingFirstPageStatus extends _Status { + const _LoadingFirstPageStatus(); +} + +class _LoadingMoreStatus extends _Status { + const _LoadingMoreStatus(); +} + +class _IdleStatus extends _Status { + const _IdleStatus({required this.nextPageKey}); + + /// Null means there are no more pages. + final K? nextPageKey; +} + +class _FailedFirstPageStatus extends _Status { + const _FailedFirstPageStatus({required this.error, required this.pageKey}); + final Object error; + final K pageKey; +} + +class _FailedMoreStatus extends _Status { + const _FailedMoreStatus({required this.error, required this.pageKey}); + final Object error; + final K pageKey; +} + +// ============================================================================= +// ========= Default widgets =================================================== + +class _DefaultFirstPageProgress extends StatelessWidget { + const _DefaultFirstPageProgress(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding(padding: EdgeInsets.all(24), child: Text("Loading...")), + ); + } +} + +class _DefaultNewPageProgress extends StatelessWidget { + const _DefaultNewPageProgress(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: Text("Loading more..."), + ), + ); + } +} + +class _DefaultEmpty extends StatelessWidget { + const _DefaultEmpty(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding(padding: EdgeInsets.all(24), child: Text("No items")), + ); + } +} + +class _DefaultErrorView extends StatelessWidget { + const _DefaultErrorView({required this.error, required this.onRetry}); + + final Object error; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text("$error"), + const SizedBox(height: 8), + GestureDetector(onTap: onRetry, child: const Text("Retry")), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/node_card.dart b/lib/widgets/node_card.dart index 85f16a1354..7d7a829698 100644 --- a/lib/widgets/node_card.dart +++ b/lib/widgets/node_card.dart @@ -59,8 +59,10 @@ class _NodeCardState extends ConsumerState { bool _advancedIsExpanded = false; Future _notifyWalletsOfUpdatedNode(WidgetRef ref) async { - final wallets = - ref.read(pWallets).wallets.where((e) => e.info.coin == widget.coin); + final wallets = ref + .read(pWallets) + .wallets + .where((e) => e.info.coin == widget.coin); final prefs = ref.read(prefsChangeNotifierProvider); switch (prefs.syncType) { @@ -100,12 +102,14 @@ class _NodeCardState extends ConsumerState { @override Widget build(BuildContext context) { final node = ref.watch( - nodeServiceChangeNotifierProvider - .select((value) => value.getPrimaryNodeFor(currency: widget.coin)), + nodeServiceChangeNotifierProvider.select( + (value) => value.getPrimaryNodeFor(currency: widget.coin), + ), ); final _node = ref.watch( - nodeServiceChangeNotifierProvider - .select((value) => value.getNodeById(id: nodeId)), + nodeServiceChangeNotifierProvider.select( + (value) => value.getNodeById(id: nodeId), + ), )!; if (node?.name == _node.name) { @@ -155,14 +159,10 @@ class _NodeCardState extends ConsumerState { }, header: child, body: Padding( - padding: const EdgeInsets.only( - bottom: 24, - ), + padding: const EdgeInsets.only(bottom: 24), child: Row( children: [ - const SizedBox( - width: 66, - ), + const SizedBox(width: 66), CustomTextButton( text: "Connect", enabled: _status == "Disconnected", @@ -190,12 +190,12 @@ class _NodeCardState extends ConsumerState { ); if (context.mounted) { - final canConnect = await testNodeConnection( - context: context, - nodeFormData: nodeFormData, - cryptoCurrency: widget.coin, - ref: ref, - ); + final canConnect = + await ref.read(testNodeConnectionProvider)( + context: context, + nodeFormData: nodeFormData, + cryptoCurrency: widget.coin, + ); if (!canConnect) { if (context.mounted) { @@ -223,9 +223,7 @@ class _NodeCardState extends ConsumerState { } }, ), - const SizedBox( - width: 48, - ), + const SizedBox(width: 48), CustomTextButton( text: "Details", onTap: () { @@ -253,13 +251,13 @@ class _NodeCardState extends ConsumerState { height: isDesktop ? 40 : 24, decoration: BoxDecoration( color: _node.id.startsWith(DefaultNodes.defaultNodeIdPrefix) - ? Theme.of(context) - .extension()! - .buttonBackSecondary + ? Theme.of( + context, + ).extension()!.buttonBackSecondary : Theme.of(context) - .extension()! - .infoItemIcons - .withOpacity(0.2), + .extension()! + .infoItemIcons + .withOpacity(0.2), borderRadius: BorderRadius.circular(100), ), child: Center( @@ -269,32 +267,22 @@ class _NodeCardState extends ConsumerState { width: isDesktop ? 20 : 14, color: _node.id.startsWith(DefaultNodes.defaultNodeIdPrefix) - ? Theme.of(context) - .extension()! - .accentColorDark - : Theme.of(context) - .extension()! - .infoItemIcons, + ? Theme.of( + context, + ).extension()!.accentColorDark + : Theme.of( + context, + ).extension()!.infoItemIcons, ), ), ), - const SizedBox( - width: 12, - ), + const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - _node.name, - style: STextStyles.titleBold12(context), - ), - const SizedBox( - height: 2, - ), - Text( - _status, - style: STextStyles.label(context), - ), + Text(_node.name, style: STextStyles.titleBold12(context)), + const SizedBox(height: 2), + Text(_status, style: STextStyles.label(context)), ], ), const Spacer(), @@ -302,12 +290,12 @@ class _NodeCardState extends ConsumerState { SvgPicture.asset( Assets.svg.network, color: _status == "Connected" - ? Theme.of(context) - .extension()! - .accentColorGreen - : Theme.of(context) - .extension()! - .buttonBackSecondary, + ? Theme.of( + context, + ).extension()!.accentColorGreen + : Theme.of( + context, + ).extension()!.buttonBackSecondary, width: 20, height: 20, ), @@ -318,9 +306,9 @@ class _NodeCardState extends ConsumerState { : Assets.svg.chevronDown, width: 12, height: 6, - color: Theme.of(context) - .extension()! - .textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), ], ), diff --git a/lib/widgets/node_options_sheet.dart b/lib/widgets/node_options_sheet.dart index 511be23958..201b802cbe 100644 --- a/lib/widgets/node_options_sheet.dart +++ b/lib/widgets/node_options_sheet.dart @@ -44,8 +44,10 @@ class NodeOptionsSheet extends ConsumerWidget { final String popBackToRoute; Future _notifyWalletsOfUpdatedNode(WidgetRef ref) async { - final wallets = - ref.read(pWallets).wallets.where((e) => e.info.coin == coin); + final wallets = ref + .read(pWallets) + .wallets + .where((e) => e.info.coin == coin); final prefs = ref.read(prefsChangeNotifierProvider); switch (prefs.syncType) { @@ -80,11 +82,13 @@ class NodeOptionsSheet extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final maxHeight = MediaQuery.of(context).size.height * 0.60; final node = ref.watch( - nodeServiceChangeNotifierProvider - .select((value) => value.getNodeById(id: nodeId)), + nodeServiceChangeNotifierProvider.select( + (value) => value.getNodeById(id: nodeId), + ), )!; - final status = ref + final status = + ref .watch( nodeServiceChangeNotifierProvider.select( (value) => value.getPrimaryNodeFor(currency: coin), @@ -98,9 +102,7 @@ class NodeOptionsSheet extends ConsumerWidget { return Container( decoration: BoxDecoration( color: Theme.of(context).extension()!.popupBG, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(20), - ), + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), child: LimitedBox( maxHeight: maxHeight, @@ -119,9 +121,9 @@ class NodeOptionsSheet extends ConsumerWidget { Center( child: Container( decoration: BoxDecoration( - color: Theme.of(context) - .extension()! - .textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -130,9 +132,7 @@ class NodeOptionsSheet extends ConsumerWidget { height: 4, ), ), - const SizedBox( - height: 36, - ), + const SizedBox(height: 36), Text( "Node options", style: STextStyles.pageTitleH2(context), @@ -146,15 +146,17 @@ class NodeOptionsSheet extends ConsumerWidget { width: 32, height: 32, decoration: BoxDecoration( - color: node.id - .startsWith(DefaultNodes.defaultNodeIdPrefix) - ? Theme.of(context) - .extension()! - .textSubtitle4 + color: + node.id.startsWith( + DefaultNodes.defaultNodeIdPrefix, + ) + ? Theme.of( + context, + ).extension()!.textSubtitle4 : Theme.of(context) - .extension()! - .infoItemIcons - .withOpacity(0.2), + .extension()! + .infoItemIcons + .withOpacity(0.2), borderRadius: BorderRadius.circular(100), ), child: Center( @@ -162,21 +164,20 @@ class NodeOptionsSheet extends ConsumerWidget { Assets.svg.node, height: 15, width: 19, - color: node.id.startsWith( - DefaultNodes.defaultNodeIdPrefix, - ) - ? Theme.of(context) - .extension()! - .accentColorDark - : Theme.of(context) - .extension()! - .infoItemIcons, + color: + node.id.startsWith( + DefaultNodes.defaultNodeIdPrefix, + ) + ? Theme.of( + context, + ).extension()!.accentColorDark + : Theme.of( + context, + ).extension()!.infoItemIcons, ), ), ), - const SizedBox( - width: 12, - ), + const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -184,25 +185,20 @@ class NodeOptionsSheet extends ConsumerWidget { node.name, style: STextStyles.titleBold12(context), ), - const SizedBox( - height: 2, - ), - Text( - status, - style: STextStyles.label(context), - ), + const SizedBox(height: 2), + Text(status, style: STextStyles.label(context)), ], ), const Spacer(), SvgPicture.asset( Assets.svg.network, color: status == "Connected" - ? Theme.of(context) - .extension()! - .accentColorGreen - : Theme.of(context) - .extension()! - .buttonBackSecondary, + ? Theme.of( + context, + ).extension()!.accentColorGreen + : Theme.of( + context, + ).extension()!.buttonBackSecondary, width: 18, ), ], @@ -220,36 +216,30 @@ class NodeOptionsSheet extends ConsumerWidget { Navigator.pop(context); Navigator.of(context).pushNamed( NodeDetailsView.routeName, - arguments: Tuple3( - coin, - node.id, - popBackToRoute, - ), + arguments: Tuple3(coin, node.id, popBackToRoute), ); }, child: Text( "Details", style: STextStyles.button(context).copyWith( - color: Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), ), // if (!node.id.startsWith("default")) - const SizedBox( - width: 12, - ), + const SizedBox(width: 12), Expanded( child: TextButton( style: status == "Connected" ? Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle(context) + .extension()! + .getPrimaryDisabledButtonStyle(context) : Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), + .extension()! + .getPrimaryEnabledButtonStyle(context), onPressed: status == "Connected" ? null : () async { @@ -267,21 +257,23 @@ class NodeOptionsSheet extends ConsumerWidget { } else { netOption = TorPlainNetworkOption.both; } - final canConnect = await testNodeConnection( - context: context, - nodeFormData: NodeFormData() - ..name = node.name - ..host = node.host - ..login = node.loginName - ..password = pw - ..port = node.port - ..useSSL = node.useSSL - ..isFailover = node.isFailover - ..netOption = netOption - ..trusted = node.trusted, - cryptoCurrency: coin, - ref: ref, - ); + final canConnect = + await ref.read( + testNodeConnectionProvider, + )( + context: context, + nodeFormData: NodeFormData() + ..name = node.name + ..host = node.host + ..login = node.loginName + ..password = pw + ..port = node.port + ..useSSL = node.useSSL + ..isFailover = node.isFailover + ..netOption = netOption + ..trusted = node.trusted, + cryptoCurrency: coin, + ); if (!canConnect) { return; } @@ -306,9 +298,7 @@ class NodeOptionsSheet extends ConsumerWidget { ), ], ), - const SizedBox( - height: 24, - ), + const SizedBox(height: 24), ], ), ), diff --git a/lib/widgets/options.dart b/lib/widgets/options.dart new file mode 100644 index 0000000000..119696c010 --- /dev/null +++ b/lib/widgets/options.dart @@ -0,0 +1,239 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../themes/stack_colors.dart'; +import '../utilities/text_styles.dart'; +import '../utilities/util.dart'; + +class Options extends StatefulWidget { + const Options({ + super.key, + this.icons, + this.texts, + this.onValueChanged, + required this.selectedIndex, + required this.onColor, + required this.offColor, + this.decoration, + }); + + final List? icons; + final List? texts; + final void Function(int)? onValueChanged; + final int selectedIndex; + final Color onColor; + final Color offColor; + final BoxDecoration? decoration; + + @override + OptionsState createState() => OptionsState(); +} + +class OptionsState extends State { + late final BoxDecoration? decoration; + late final Color onColor; + late final Color offColor; + + final bool isDesktop = Util.isDesktop; + + late int _selectedIndex; + int get selectedIndex => _selectedIndex; + + late ValueNotifier valueListener; + + final tapAnimationDuration = const Duration(milliseconds: 150); + bool _isDragging = false; + + @override + initState() { + onColor = widget.onColor; + offColor = widget.offColor; + decoration = widget.decoration; + _selectedIndex = widget.selectedIndex; + valueListener = ValueNotifier(_selectedIndex.toDouble()); + + super.initState(); + } + + @override + void dispose() { + valueListener.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + final optionsCount = widget.texts?.length ?? 1; + + return GestureDetector( + onTapDown: (details) { + final RenderBox box = context.findRenderObject() as RenderBox; + final localPosition = box.globalToLocal(details.globalPosition); + final optionsCount = widget.texts?.length ?? 1; + final optionWidth = box.size.width / optionsCount; + final tappedIndex = (localPosition.dx / optionWidth).floor().clamp( + 0, + optionsCount - 1, + ); + if (_selectedIndex != tappedIndex) { + _selectedIndex = tappedIndex; + widget.onValueChanged?.call(_selectedIndex); + valueListener.value = _selectedIndex.toDouble(); + setState(() {}); + } + }, + child: LayoutBuilder( + builder: (context, constraint) { + return Stack( + children: [ + AnimatedBuilder( + animation: valueListener, + builder: (context, child) { + return AnimatedContainer( + duration: tapAnimationDuration, + height: constraint.maxHeight, + width: constraint.maxWidth, + decoration: decoration?.copyWith(color: offColor), + ); + }, + ), + Builder( + builder: (context) { + final handle = GestureDetector( + key: const Key("draggableSwitchButtonSwitch"), + onHorizontalDragStart: (_) => _isDragging = true, + onHorizontalDragUpdate: (details) { + valueListener.value = + (valueListener.value + + details.delta.dx / + (constraint.maxWidth / optionsCount)) + .clamp(0.0, optionsCount - 1.0); + }, + onHorizontalDragEnd: (details) { + final int oldValue = _selectedIndex; + _selectedIndex = valueListener.value.round(); + if (_selectedIndex != oldValue) { + widget.onValueChanged?.call(_selectedIndex); + setState(() {}); + } + _isDragging = false; + }, + child: AnimatedBuilder( + animation: valueListener, + builder: (context, child) { + return AnimatedContainer( + duration: tapAnimationDuration, + height: constraint.maxHeight, + width: constraint.maxWidth / optionsCount, + decoration: decoration?.copyWith(color: onColor), + ); + }, + ), + ); + return AnimatedBuilder( + animation: valueListener, + builder: (context, child) { + return AnimatedAlign( + duration: _isDragging + ? Duration.zero + : tapAnimationDuration, + alignment: Alignment( + (valueListener.value * 2 / (optionsCount - 1)) - 1, + 0.5, + ), + child: child, + ); + }, + child: handle, + ); + }, + ), + IgnorePointer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(optionsCount, (index) { + return SizedBox( + width: constraint.maxWidth / optionsCount, + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.icons != null && + widget.icons!.length > index) + SvgPicture.asset( + widget.icons![index], + width: 12, + height: 14, + color: isDesktop + ? _selectedIndex != index + ? Theme.of(context) + .extension()! + .accentColorBlue + : Theme.of(context) + .extension()! + .buttonTextSecondary + : _selectedIndex != index + ? Theme.of( + context, + ).extension()!.textDark + : Theme.of( + context, + ).extension()!.textSubtitle1, + ), + if (widget.icons != null && + widget.icons!.length > index) + const SizedBox(width: 5), + Flexible( + child: Text( + widget.texts?[index] ?? "", + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: _selectedIndex != index + ? Theme.of(context) + .extension()! + .accentColorBlue + : Theme.of(context) + .extension()! + .buttonTextSecondary, + ) + : STextStyles.smallMed12(context).copyWith( + color: _selectedIndex != index + ? Theme.of(context) + .extension()! + .textDark + : Theme.of(context) + .extension()! + .textSubtitle1, + ), + ), + ), + ], + ), + ), + ); + }), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/widgets/ordinal_image.dart b/lib/widgets/ordinal_image.dart new file mode 100644 index 0000000000..abad5bd0c1 --- /dev/null +++ b/lib/widgets/ordinal_image.dart @@ -0,0 +1,81 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../app_config.dart'; +import '../networking/http.dart'; +import '../utilities/prefs.dart'; +import '../services/tor_service.dart'; + +/// Fetches and displays an image through the app's HTTP client, +/// respecting Tor proxy settings. Use this instead of [Image.network] +/// when the request must route through Tor. +class OrdinalImage extends StatefulWidget { + const OrdinalImage({ + super.key, + required this.url, + this.fit = BoxFit.cover, + this.filterQuality = FilterQuality.none, + }); + + final String url; + final BoxFit fit; + final FilterQuality filterQuality; + + @override + State createState() => _OrdinalImageState(); +} + +class _OrdinalImageState extends State { + late Future _future; + + @override + void initState() { + super.initState(); + _future = _fetchImage(); + } + + @override + void didUpdateWidget(OrdinalImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.url != widget.url) { + _future = _fetchImage(); + } + } + + Future _fetchImage() async { + final response = await const HTTP().get( + url: Uri.parse(widget.url), + proxyInfo: !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ); + + if (response.code != 200) { + throw Exception('Failed to load image: status=${response.code}'); + } + + return Uint8List.fromList(response.bodyBytes); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasData) { + return Image.memory( + snapshot.data!, + fit: widget.fit, + filterQuality: widget.filterQuality, + ); + } else if (snapshot.hasError) { + return const Center(child: Icon(Icons.broken_image)); + } + return const Center(child: CircularProgressIndicator()); + }, + ); + } +} diff --git a/lib/widgets/paginated_list_view.dart b/lib/widgets/paginated_list_view.dart new file mode 100644 index 0000000000..96a23f1b0d --- /dev/null +++ b/lib/widgets/paginated_list_view.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../themes/stack_colors.dart'; +import '../utilities/assets.dart'; +import '../utilities/text_styles.dart'; +import 'custom_buttons/app_bar_icon_button.dart'; + +enum PageItemPosition { first, last, solo, somewhere } + +class PaginatedListView extends StatefulWidget { + final List items; + final Widget Function(BuildContext context, T item, PageItemPosition position) + itemBuilder; + final int itemsPerPage; + final EdgeInsetsGeometry? padding; + + const PaginatedListView({ + super.key, + required this.items, + required this.itemBuilder, + this.itemsPerPage = 50, + this.padding, + }); + + @override + State> createState() => _PaginatedListViewState(); +} + +class _PaginatedListViewState extends State> { + int _currentPage = 0; + late int _totalPages; + late List _currentPageItems; + + void _updatePagination() { + _totalPages = (widget.items.length / widget.itemsPerPage).ceil(); + if (_totalPages == 0) _totalPages = 1; + + if (_currentPage >= _totalPages) { + _currentPage = _totalPages - 1; + } + + _updateCurrentPageItems(); + } + + void _updateCurrentPageItems() { + final startIndex = _currentPage * widget.itemsPerPage; + final endIndex = (startIndex + widget.itemsPerPage).clamp( + 0, + widget.items.length, + ); + _currentPageItems = widget.items.sublist(startIndex, endIndex); + } + + void _goToPage(int page) { + if (mounted && page >= 0 && page < _totalPages && page != _currentPage) { + setState(() { + _currentPage = page; + _updateCurrentPageItems(); + }); + } + } + + void _nextPage() => _goToPage(_currentPage + 1); + void _previousPage() => _goToPage(_currentPage - 1); + void _firstPage() => _goToPage(0); + void _lastPage() => _goToPage(_totalPages - 1); + + @override + void initState() { + super.initState(); + _updatePagination(); + } + + @override + void didUpdateWidget(PaginatedListView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.items != widget.items || + oldWidget.itemsPerPage != widget.itemsPerPage) { + _updatePagination(); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: ListView.separated( + itemCount: _currentPageItems.length, + separatorBuilder: (context, index) { + return Container( + width: double.infinity, + height: 2, + color: Theme.of(context).extension()!.background, + ); + }, + itemBuilder: (context, index) { + final PageItemPosition position; + if (_currentPageItems.length == 1) { + position = .solo; + } else if (index == _currentPageItems.length - 1) { + position = .last; + } else if (index == 0) { + position = .first; + } else { + position = .somewhere; + } + + return widget.itemBuilder( + context, + _currentPageItems[index], + position, + ); + }, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: .center, + crossAxisAlignment: .center, + children: [ + IconButton( + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + disabledColor: Theme.of( + context, + ).extension()!.topNavIconPrimary.withAlpha(100), + icon: const Icon(Icons.first_page), + onPressed: _currentPage > 0 ? _firstPage : null, + tooltip: "First page", + ), + const SizedBox(width: 8), + AppBarIconButton( + icon: Transform.flip( + flipX: true, + child: SvgPicture.asset( + Assets.svg.chevronRight, + width: 24, + height: 24, + color: Theme.of(context) + .extension()! + .topNavIconPrimary + .withAlpha(_currentPage > 0 ? 255 : 100), + ), + ), + tooltip: "Previous page", + onPressed: _currentPage > 0 ? _previousPage : null, + ), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + "${_currentPage + 1} / $_totalPages", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of( + context, + ).extension()!.topNavIconPrimary.withAlpha(190), + ), + ), + ), + + AppBarIconButton( + icon: SvgPicture.asset( + Assets.svg.chevronRight, + width: 24, + height: 24, + color: Theme.of(context) + .extension()! + .topNavIconPrimary + .withAlpha(_currentPage < _totalPages - 1 ? 255 : 100), + ), + tooltip: "Next page", + onPressed: _currentPage < _totalPages - 1 ? _nextPage : null, + ), + const SizedBox(width: 8), + IconButton( + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + disabledColor: Theme.of( + context, + ).extension()!.topNavIconPrimary.withAlpha(100), + icon: const Icon(Icons.last_page), + onPressed: _currentPage < _totalPages - 1 ? _lastPage : null, + tooltip: "Last page", + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/qr_scanner.dart b/lib/widgets/qr_scanner.dart index 66941ac9d3..cd19c839f5 100644 --- a/lib/widgets/qr_scanner.dart +++ b/lib/widgets/qr_scanner.dart @@ -1,45 +1,81 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart'; import '../themes/stack_colors.dart'; -import '../utilities/logger.dart'; +import '../utilities/if_not_already.dart'; import '../utilities/text_styles.dart'; import 'background.dart'; import 'custom_buttons/app_bar_icon_button.dart'; -class QrScanner extends ConsumerWidget { +class QrScanner extends StatefulWidget { const QrScanner({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + State createState() => _QrScannerState(); +} + +class _QrScannerState extends State { + final GlobalKey qrKey = GlobalKey(debugLabel: "QR Scan Key"); + + QRViewController? controller; + + StreamSubscription? sub; + + late final Future Function(String?) _onScanned; + + @override + void initState() { + super.initState(); + + _onScanned = IfNotAlreadyAsync.withArgs((data) async { + await sub?.cancel(); + if (mounted) { + Navigator.of(context).pop(data); + } + }).execute; + } + + // In order to get hot reload to work we need to pause the camera if the platform + // is android, or resume the camera if the platform is iOS. + @override + void reassemble() { + super.reassemble(); + if (Platform.isAndroid) { + controller!.pauseCamera(); + } else if (Platform.isIOS) { + controller!.resumeCamera(); + } + } + + @override + void dispose() { + sub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { return Background( child: Scaffold( backgroundColor: Theme.of(context).extension()!.background, appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.backgroundAppBar, + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, leading: const AppBarBackButton(), title: Text("Scan QR code", style: STextStyles.navBarTitle(context)), ), - body: MobileScanner( - onDetect: (capture) { - final data = - ((capture.raw as Map?)?["data"] as List?)?.firstOrNull as Map?; - - final value = - data?["rawValue"] as String? ?? - data?["displayValue"] as String?; - - Navigator.of(context).pop(value); - }, - onDetectError: (error, stackTrace) { - Logging.instance.w( - "Mobile scanner", - error: error, - stackTrace: stackTrace, - ); - Navigator.of(context).pop(); + body: QRView( + key: qrKey, + onQRViewCreated: (QRViewController p1) { + sub?.cancel(); + controller = p1; + sub = controller!.scannedDataStream.listen((data) { + _onScanned(data.code); + }); }, ), ), diff --git a/lib/widgets/refresh_control.dart b/lib/widgets/refresh_control.dart new file mode 100644 index 0000000000..faaef19a7b --- /dev/null +++ b/lib/widgets/refresh_control.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../themes/stack_colors.dart'; +import '../utilities/util.dart'; +import 'animated_widgets/rotating_arrows.dart'; +import 'custom_buttons/app_bar_icon_button.dart'; + +/// Wraps a scrollable [child] with a [RefreshIndicator] on mobile. On +/// desktop, returns [child] unchanged — desktop screens place a +/// [RefreshButton] in their dialog header instead. +class RefreshControl extends StatelessWidget { + const RefreshControl({ + super.key, + required this.onRefresh, + required this.child, + }); + + final Future Function() onRefresh; + final Widget child; + + @override + Widget build(BuildContext context) { + if (Util.isDesktop) return child; + return RefreshIndicator(onRefresh: onRefresh, child: child); + } +} + +/// Circular icon button for desktop screens. Shows a spinner while +/// [isRefreshing] is true; otherwise a refresh icon. Disabled while +/// refreshing so taps don't stack overlapping requests. +class RefreshButton extends StatelessWidget { + const RefreshButton({ + super.key, + required this.onPressed, + required this.isRefreshing, + // this.tooltip = "Refresh", + }); + + final VoidCallback onPressed; + final bool isRefreshing; + // final String tooltip; + + @override + Widget build(BuildContext context) { + return AppBarIconButton( + // Don't use tooltip to be consistent with rest of UI + // tooltip: tooltip,TODO revisit this if adding tooltips to other controls + // semanticsLabel: tooltip, + color: Theme.of(context).extension()!.textFieldDefaultBG, + size: 40, + onPressed: isRefreshing ? null : onPressed, + icon: RotatingArrows( + spinByDefault: isRefreshing, + width: Util.isDesktop ? 21 : 24, + height: Util.isDesktop ? 21 : 24, + ), + ); + } +} diff --git a/lib/widgets/rounded_white_container.dart b/lib/widgets/rounded_white_container.dart index a24059c8c1..46ffd5a3be 100644 --- a/lib/widgets/rounded_white_container.dart +++ b/lib/widgets/rounded_white_container.dart @@ -9,14 +9,16 @@ */ import 'package:flutter/material.dart'; + import '../themes/stack_colors.dart'; +import '../utilities/util.dart'; import 'rounded_container.dart'; class RoundedWhiteContainer extends StatelessWidget { const RoundedWhiteContainer({ super.key, this.child, - this.padding = const EdgeInsets.all(12), + this.padding, this.radiusMultiplier = 1.0, this.width, this.height, @@ -27,7 +29,7 @@ class RoundedWhiteContainer extends StatelessWidget { }); final Widget? child; - final EdgeInsets padding; + final EdgeInsets? padding; final double radiusMultiplier; final double? width; final double? height; @@ -40,7 +42,7 @@ class RoundedWhiteContainer extends StatelessWidget { Widget build(BuildContext context) { return RoundedContainer( color: Theme.of(context).extension()!.popupBG, - padding: padding, + padding: padding ?? (Util.isDesktop ? const .all(16) : const .all(12)), radiusMultiplier: radiusMultiplier, width: width, height: height, diff --git a/lib/widgets/stack_dialog.dart b/lib/widgets/stack_dialog.dart index 2c56aa7c03..64845b6496 100644 --- a/lib/widgets/stack_dialog.dart +++ b/lib/widgets/stack_dialog.dart @@ -20,12 +20,15 @@ class StackDialogBase extends StatelessWidget { this.child, this.padding = const EdgeInsets.all(24), this.keyboardPaddingAmount = 0, + this.width, }); final EdgeInsets padding; final Widget? child; final double keyboardPaddingAmount; + final double? width; + @override Widget build(BuildContext context) { return SafeArea( @@ -37,22 +40,25 @@ class StackDialogBase extends StatelessWidget { bottom: 16 + keyboardPaddingAmount, ), child: Column( - mainAxisAlignment: - !Util.isDesktop - ? MainAxisAlignment.end - : MainAxisAlignment.center, + mainAxisAlignment: !Util.isDesktop + ? MainAxisAlignment.end + : MainAxisAlignment.center, children: [ Flexible( - child: SingleChildScrollView( - child: Material( - borderRadius: BorderRadius.circular(20), - child: Container( - decoration: BoxDecoration( - color: - Theme.of(context).extension()!.popupBG, - borderRadius: BorderRadius.circular(20), + child: SizedBox( + width: width, + child: SingleChildScrollView( + child: Material( + borderRadius: BorderRadius.circular(20), + child: Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.popupBG, + borderRadius: BorderRadius.circular(20), + ), + child: Padding(padding: padding, child: child), ), - child: Padding(padding: padding, child: child), ), ), ), @@ -72,6 +78,8 @@ class StackDialog extends StatelessWidget { this.icon, required this.title, this.message, + this.width, + this.padding = const EdgeInsets.all(24), }); final Widget? leftButton; @@ -82,9 +90,14 @@ class StackDialog extends StatelessWidget { final String title; final String? message; + final double? width; + final EdgeInsets padding; + @override Widget build(BuildContext context) { return StackDialogBase( + width: width, + padding: padding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -119,7 +132,9 @@ class StackDialog extends StatelessWidget { leftButton == null ? const Spacer() : Expanded(child: leftButton!), - const SizedBox(width: 8), + Util.isDesktop + ? const SizedBox(width: 16) + : const SizedBox(width: 8), rightButton == null ? const Spacer() : Expanded(child: rightButton!), @@ -199,30 +214,26 @@ class StackOkDialog extends StatelessWidget { const SizedBox(width: 8), Expanded( child: TextButton( - onPressed: - !Util.isDesktop - ? () { - Navigator.of(context).pop(); - onOkPressed?.call("OK"); + onPressed: !Util.isDesktop + ? () { + Navigator.of(context).pop(); + onOkPressed?.call("OK"); + } + : () { + if (desktopPopRootNavigator) { + Navigator.of(context, rootNavigator: true).pop(); + } else { + int count = 0; + Navigator.of( + context, + ).popUntil((_) => count++ >= 2); + // onOkPressed?.call("OK"); } - : () { - if (desktopPopRootNavigator) { - Navigator.of( - context, - rootNavigator: true, - ).pop(); - } else { - int count = 0; - Navigator.of( - context, - ).popUntil((_) => count++ >= 2); - // onOkPressed?.call("OK"); - } - }, + }, style: Theme.of(context) .extension()! .getPrimaryEnabledButtonStyle(context), - child: Text("Ok", style: STextStyles.button(context)), + child: Text("OK", style: STextStyles.button(context)), ), ), ], diff --git a/lib/widgets/textfields/adaptive_text_field.dart b/lib/widgets/textfields/adaptive_text_field.dart new file mode 100644 index 0000000000..8963ce3cae --- /dev/null +++ b/lib/widgets/textfields/adaptive_text_field.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../utilities/constants.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../icon_widgets/clipboard_icon.dart'; +import '../icon_widgets/x_icon.dart'; +import '../stack_text_field.dart'; +import '../textfield_icon_button.dart'; + +class AdaptiveTextField extends StatefulWidget { + const AdaptiveTextField({ + super.key, + this.labelText, + this.hintText, + this.controller, + this.focusNode, + this.autocorrect, + this.readOnly = false, + this.enabled = true, + this.enableSuggestions = true, + this.onChanged, + this.onChangedComprehensive, + this.onSubmitted, + this.onTap, + this.suffixIcons, + this.suffixText, + this.errorText, + this.contentPadding, + this.minLines, + this.maxLines, + this.inputFormatters, + this.showPasteClearButton = false, + this.keyboardType, + }); + + final String? labelText; + final String? hintText; + + final TextEditingController? controller; + final FocusNode? focusNode; + final bool? autocorrect; + final EdgeInsets? contentPadding; + final int? minLines; + final int? maxLines; + + final bool readOnly; + final bool enabled; + final bool enableSuggestions; + + final void Function(String)? onChanged; + final void Function(String)? onChangedComprehensive; + final void Function(String)? onSubmitted; + final VoidCallback? onTap; + + /// This will be ignored if [suffixIcons] is not null! + final bool showPasteClearButton; + + /// If this is not null, [showPasteClearButton] will be ignored. + final List? suffixIcons; + + /// Optional trailing text rendered in the decoration's suffixText slot. + /// Ignored when [suffixIcons] is non-empty or [showPasteClearButton] is + /// true, since those occupy the same visual space. + final String? suffixText; + + final String? errorText; + + final List? inputFormatters; + + final TextInputType? keyboardType; + + @override + State createState() => _AdaptiveTextFieldState(); +} + +class _AdaptiveTextFieldState extends State { + late final FocusNode _focusNode; + late final bool _focusFlag; + + TextEditingController? _controller; + TextEditingController get controller => widget.controller ?? _controller!; + + String _cache = ""; + + @override + void initState() { + super.initState(); + + if (widget.controller == null) { + _controller = TextEditingController(); + } else if (widget.onChangedComprehensive != null) { + widget.controller!.addListener(() { + if (widget.controller!.text != _cache) { + _cache = widget.controller!.text; + widget.onChangedComprehensive!.call(_cache); + } + }); + } + + if (widget.focusNode == null) { + _focusFlag = true; + _focusNode = FocusNode(); + } else { + _focusFlag = false; + _focusNode = widget.focusNode!; + } + } + + @override + void dispose() { + if (_focusFlag) _focusNode.dispose(); + _controller?.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + minLines: widget.minLines, + maxLines: widget.maxLines, + style: Util.isDesktop + ? STextStyles.field(context).copyWith(fontSize: 16) + : STextStyles.field(context), + controller: controller, + focusNode: _focusNode, + onChanged: widget.onChanged, + onTap: widget.onTap, + readOnly: widget.readOnly, + enabled: widget.enabled, + autocorrect: widget.autocorrect, + enableSuggestions: widget.enableSuggestions, + onSubmitted: widget.onSubmitted, + keyboardType: widget.keyboardType, + inputFormatters: widget.inputFormatters, + decoration: + standardInputDecoration( + widget.labelText, + _focusNode, + context, + ).copyWith( + alignLabelWithHint: (widget.minLines ?? 1) > 2 ? true : null, + hintText: widget.hintText, + suffixText: + (widget.suffixIcons?.isNotEmpty != true && + !widget.showPasteClearButton) + ? widget.suffixText + : null, + contentPadding: + widget.contentPadding ?? + (Util.isDesktop + ? const EdgeInsets.only( + left: 12, + top: 11, + bottom: 12, + right: 5, + ) + : const EdgeInsets.only( + left: 10, + top: 12, + bottom: 8, + right: 5, + )), + suffixIcon: widget.suffixIcons?.isNotEmpty == true + ? Padding( + padding: controller.text.isEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: widget.suffixIcons!, + ), + ), + ) + : widget.showPasteClearButton + ? TextFieldIconButton( + onTap: () async { + if (controller.text.isEmpty) { + final ClipboardData? data = + await Clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + final content = data.text!.trim(); + controller.text = content; + } + } else { + controller.text = ""; + } + + if (mounted) setState(() {}); + }, + child: controller.text.isNotEmpty + ? const XIcon() + : const ClipboardIcon(), + ) + : null, + ), + ), + ), + if (widget.errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6, left: 12), + child: Text( + widget.errorText!, + style: STextStyles.errorSmall(context), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/textfields/frost_step_field.dart b/lib/widgets/textfields/frost_step_field.dart index f94fac2b41..111ba88e5d 100644 --- a/lib/widgets/textfields/frost_step_field.dart +++ b/lib/widgets/textfields/frost_step_field.dart @@ -80,8 +80,9 @@ class _FrostStepFieldState extends ConsumerState { } final qrResult = await ref.read(pBarcodeScanner).scan(context: context); + if (qrResult.rawContent == null) return; - widget.controller.text = qrResult.rawContent; + widget.controller.text = qrResult.rawContent!; _changed(widget.controller.text); } else { @@ -128,15 +129,14 @@ class _FrostStepFieldState extends ConsumerState { Widget build(BuildContext context) { return ConditionalParent( condition: widget.label != null, - builder: - (child) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(widget.label!, style: STextStyles.w500_14(context)), - const SizedBox(height: 4), - child, - ], - ), + builder: (child) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(widget.label!, style: STextStyles.w500_14(context)), + const SizedBox(height: 4), + child, + ], + ), child: TextField( controller: widget.controller, focusNode: widget.focusNode, @@ -147,60 +147,55 @@ class _FrostStepFieldState extends ConsumerState { onChanged: _changed, decoration: InputDecoration( hintText: widget.hint, - fillColor: - widget.focusNode.hasFocus - ? Theme.of( - context, - ).extension()!.textFieldActiveBG - : Theme.of( - context, - ).extension()!.textFieldDefaultBG, - hintStyle: - Util.isDesktop - ? STextStyles.desktopTextFieldLabel(context) - : STextStyles.fieldLabel(context), + fillColor: widget.focusNode.hasFocus + ? Theme.of(context).extension()!.textFieldActiveBG + : Theme.of(context).extension()!.textFieldDefaultBG, + hintStyle: Util.isDesktop + ? STextStyles.desktopTextFieldLabel(context) + : STextStyles.fieldLabel(context), enabledBorder: _inputBorder, focusedBorder: _inputBorder, errorBorder: _inputBorder, disabledBorder: _inputBorder, focusedErrorBorder: _inputBorder, suffixIcon: Padding( - padding: - _isEmpty - ? const EdgeInsets.only(right: 8) - : const EdgeInsets.only(right: 0), + padding: _isEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), child: UnconstrainedBox( child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ !_isEmpty ? TextFieldIconButton( - semanticsLabel: - "Clear Button. Clears The Frost Step Field Input.", - key: _xKey, - onTap: () { - widget.controller.text = ""; - - _changed(widget.controller.text); - }, - child: const XIcon(), - ) + semanticsLabel: + "Clear Button. Clears The Frost Step Field Input.", + key: _xKey, + onTap: () { + widget.controller.text = ""; + + _changed(widget.controller.text); + }, + child: const XIcon(), + ) : TextFieldIconButton( - semanticsLabel: - "Paste Button. Pastes From Clipboard To Frost Step Field Input.", - key: _pasteKey, - onTap: () async { - final ClipboardData? data = await Clipboard.getData( - Clipboard.kTextPlain, - ); - if (data?.text != null && data!.text!.isNotEmpty) { - widget.controller.text = data.text!.trim(); - } - - _changed(widget.controller.text); - }, - child: _isEmpty ? const ClipboardIcon() : const XIcon(), - ), + semanticsLabel: + "Paste Button. Pastes From Clipboard To Frost Step Field Input.", + key: _pasteKey, + onTap: () async { + final ClipboardData? data = await Clipboard.getData( + Clipboard.kTextPlain, + ); + if (data?.text != null && data!.text!.isNotEmpty) { + widget.controller.text = data.text!.trim(); + } + + _changed(widget.controller.text); + }, + child: _isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), if (_isEmpty && widget.showQrScanOption) TextFieldIconButton( semanticsLabel: diff --git a/lib/widgets/transaction_card.dart b/lib/widgets/transaction_card.dart index 211776fe80..c8d23afa5e 100644 --- a/lib/widgets/transaction_card.dart +++ b/lib/widgets/transaction_card.dart @@ -18,7 +18,6 @@ import '../models/isar/models/isar_models.dart'; import '../notifications/show_flush_bar.dart'; import '../pages/wallet_view/sub_widgets/tx_icon.dart'; import '../pages/wallet_view/transaction_views/transaction_details_view.dart'; -import '../providers/db/main_db_provider.dart'; import '../providers/providers.dart'; import '../themes/stack_colors.dart'; import '../utilities/amount/amount.dart'; @@ -27,7 +26,6 @@ import '../utilities/constants.dart'; import '../utilities/format.dart'; import '../utilities/text_styles.dart'; import '../utilities/util.dart'; -import '../wallets/crypto_currency/coins/mimblewimblecoin.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; import 'desktop/desktop_dialog.dart'; @@ -117,10 +115,15 @@ class _TransactionCardState extends ConsumerState { @override void initState() { walletId = widget.walletId; - minConfirms = - ref.read(pWallets).getWallet(walletId).cryptoCurrency.minConfirms; + minConfirms = ref + .read(pWallets) + .getWallet(walletId) + .cryptoCurrency + .minConfirms; _transaction = widget.transaction; - isTokenTx = _transaction.subType == TransactionSubType.ethToken; + isTokenTx = + _transaction.subType == TransactionSubType.ethToken || + _transaction.subType == TransactionSubType.splToken; if (Util.isDesktop) { if (_transaction.type == TransactionType.outgoing) { prefix = "-"; @@ -152,17 +155,15 @@ class _TransactionCardState extends ConsumerState { prefsChangeNotifierProvider.select((value) => value.currency), ); - final price = - ref - .watch( - priceAnd24hChangeNotifierProvider.select( - (value) => - isTokenTx - ? value.getTokenPrice(_transaction.otherData!) - : value.getPrice(coin), - ), - ) - ?.value; + final price = ref + .watch( + priceAnd24hChangeNotifierProvider.select( + (value) => isTokenTx + ? value.getTokenPrice(_transaction.otherData!) + : value.getPrice(coin), + ), + ) + ?.value; final currentHeight = ref.watch( pWallets.select( @@ -215,16 +216,15 @@ class _TransactionCardState extends ConsumerState { if (Util.isDesktop) { await showDialog( context: context, - builder: - (context) => DesktopDialog( - maxHeight: MediaQuery.of(context).size.height - 64, - maxWidth: 580, - child: TransactionDetailsView( - transaction: _transaction, - coin: coin, - walletId: walletId, - ), - ), + builder: (context) => DesktopDialog( + maxHeight: MediaQuery.of(context).size.height - 64, + maxWidth: 580, + child: TransactionDetailsView( + transaction: _transaction, + coin: coin, + walletId: walletId, + ), + ), ); } else { unawaited( @@ -259,13 +259,13 @@ class _TransactionCardState extends ConsumerState { child: Text( _transaction.isCancelled ? coin is Ethereum - ? "Failed" - : "Cancelled" + ? "Failed" + : "Cancelled" : whatIsIt( - _transaction.type, - coin, - currentHeight, - ), + _transaction.type, + coin, + currentHeight, + ), style: STextStyles.itemSubtitle12(context), ), ), @@ -276,10 +276,15 @@ class _TransactionCardState extends ConsumerState { fit: BoxFit.scaleDown, child: Builder( builder: (_) { - final amount = _transaction.realAmount; + final formattedAmount = ref + .watch(pAmountFormatter(coin)) + .format( + _transaction.realAmount, + tokenContract: tokenContract, + ); return Text( - "$prefix${ref.watch(pAmountFormatter(coin)).format(amount, ethContract: tokenContract)}", + "$prefix$formattedAmount", style: STextStyles.itemSubtitle12(context), ); }, diff --git a/lib/widgets/tx_key_widget.dart b/lib/widgets/tx_key_widget.dart index f9e1fb4bc5..6872bbd43a 100644 --- a/lib/widgets/tx_key_widget.dart +++ b/lib/widgets/tx_key_widget.dart @@ -8,13 +8,13 @@ import '../pages_desktop_specific/password/request_desktop_auth_dialog.dart'; import '../providers/global/wallets_provider.dart'; import '../utilities/text_styles.dart'; import '../utilities/util.dart'; -import '../wallets/wallet/intermediate/lib_monero_wallet.dart'; +import '../wallets/wallet/intermediate/cryptonote_wallet.dart'; import 'custom_buttons/blue_text_button.dart'; import 'custom_buttons/simple_copy_button.dart'; import 'detail_item.dart'; class TxKeyWidget extends ConsumerStatefulWidget { - /// The [walletId] MUST be the id of a [LibMoneroWallet]! + /// The [walletId] MUST be the id of a [CryptonoteWallet]! const TxKeyWidget({super.key, required this.walletId, required this.txid}); final String walletId; @@ -38,26 +38,22 @@ class _TxKeyWidgetState extends ConsumerState { try { final verified = await showDialog( context: context, - builder: - (context) => - Util.isDesktop - ? const RequestDesktopAuthDialog( - title: "Show private view key", - ) - : const PinpadDialog( - biometricsAuthenticationTitle: "Show private view key", - biometricsLocalizedReason: - "Authenticate to show private view key", - biometricsCancelButtonString: "CANCEL", - ), + builder: (context) => Util.isDesktop + ? const RequestDesktopAuthDialog(title: "Show private view key") + : const PinpadDialog( + biometricsAuthenticationTitle: "Show private view key", + biometricsLocalizedReason: + "Authenticate to show private view key", + biometricsCancelButtonString: "CANCEL", + ), barrierDismissible: !Util.isDesktop, ); if (verified == "verified success" && mounted) { final wallet = - ref.read(pWallets).getWallet(widget.walletId) as LibMoneroWallet; + ref.read(pWallets).getWallet(widget.walletId) as CryptonoteWallet; - _private = wallet.getTxKeyFor(txid: widget.txid); + _private = await wallet.getTxKeyFor(txid: widget.txid); if (_private!.isEmpty) { _private = "Unavailable"; } @@ -76,16 +72,15 @@ class _TxKeyWidgetState extends ConsumerState { @override Widget build(BuildContext context) { return DetailItemBase( - button: - _private == null - ? CustomTextButton( - text: "Show", - onTap: _loadTxKey, - enabled: _private == null, - ) - : Util.isDesktop - ? tvd.IconCopyButton(data: _private!) - : SimpleCopyButton(data: _private!), + button: _private == null + ? CustomTextButton( + text: "Show", + onTap: _loadTxKey, + enabled: _private == null, + ) + : Util.isDesktop + ? tvd.IconCopyButton(data: _private!) + : SimpleCopyButton(data: _private!), title: Text("Private view key", style: STextStyles.itemSubtitle(context)), detail: SelectableText( // TODO diff --git a/lib/widgets/wallet_card.dart b/lib/widgets/wallet_card.dart index ed49e5ebb9..b6690b7046 100644 --- a/lib/widgets/wallet_card.dart +++ b/lib/widgets/wallet_card.dart @@ -14,20 +14,26 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/isar/models/ethereum/eth_contract.dart'; +import '../models/isar/models/solana/sol_contract.dart'; +import '../pages/token_view/sol_token_view.dart'; import '../pages/token_view/token_view.dart'; import '../pages/wallet_view/wallet_view.dart'; +import '../pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart'; import '../pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart'; import '../pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; -import '../providers/db/main_db_provider.dart'; import '../providers/providers.dart'; import '../utilities/constants.dart'; import '../utilities/logger.dart'; import '../utilities/show_loading.dart'; import '../utilities/show_node_tor_settings_mismatch.dart'; import '../utilities/util.dart'; +import '../wallets/crypto_currency/coins/solana.dart'; import '../wallets/isar/providers/eth/current_token_wallet_provider.dart'; +import '../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../wallets/wallet/impl/ethereum_wallet.dart'; +import '../wallets/wallet/impl/solana_wallet.dart'; import '../wallets/wallet/impl/sub_wallets/eth_token_wallet.dart'; +import '../wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; import '../wallets/wallet/intermediate/external_wallet.dart'; import '../wallets/wallet/wallet.dart'; import 'conditional_parent.dart'; @@ -50,7 +56,7 @@ class SimpleWalletCard extends ConsumerWidget { final bool popPrevious; final NavigatorState? desktopNavigatorState; - Future _loadTokenWallet( + Future _loadEthTokenWallet( BuildContext context, WidgetRef ref, Wallet wallet, @@ -59,10 +65,12 @@ class SimpleWalletCard extends ConsumerWidget { final old = ref.read(tokenServiceStateProvider); // exit previous if there is one unawaited(old?.exit()); - ref.read(tokenServiceStateProvider.state).state = Wallet.loadTokenWallet( - ethWallet: wallet as EthereumWallet, - contract: contract, - ) as EthTokenWallet; + ref.read(tokenServiceStateProvider.state).state = + Wallet.loadTokenWallet( + ethWallet: wallet as EthereumWallet, + contract: contract, + ) + as EthTokenWallet; try { await ref.read(pCurrentTokenWallet)!.init(); @@ -91,6 +99,49 @@ class SimpleWalletCard extends ConsumerWidget { } } + Future _loadSolanaTokenWallet( + BuildContext context, + WidgetRef ref, + Wallet wallet, + SolContract token, + ) async { + final old = ref.read(solanaTokenServiceStateProvider); + // exit previous if there is one + unawaited(old?.exit()); + ref.read(solanaTokenServiceStateProvider.state).state = + Wallet.loadSolTokenWallet( + solWallet: wallet as SolanaWallet, + contract: token, + ) + as SolanaTokenWallet; + + try { + await ref.read(pCurrentSolanaTokenWallet)!.init(); + return true; + } catch (_) { + await showDialog( + barrierDismissible: false, + context: context, + builder: (context) => BasicDialog( + title: "Failed to load token data", + desktopHeight: double.infinity, + desktopWidth: 450, + rightButton: PrimaryButton( + label: "OK", + onPressed: () { + Navigator.of(context).pop(); + Navigator.of(context).pop(); + if (desktopNavigatorState == null) { + Navigator.of(context).pop(); + } + }, + ), + ), + ); + return false; + } + } + void _openWallet(BuildContext context, WidgetRef ref) async { final nav = Navigator.of(context); @@ -124,57 +175,104 @@ class SimpleWalletCard extends ConsumerWidget { ); if (popPrevious) nav.pop(); - if (desktopNavigatorState != null) { - unawaited( - desktopNavigatorState!.pushNamed( - DesktopWalletView.routeName, - arguments: walletId, - ), - ); - } else { - unawaited( - nav.pushNamed( - WalletView.routeName, - arguments: walletId, - ), - ); + if (contractAddress == null) { + if (desktopNavigatorState != null) { + unawaited( + desktopNavigatorState!.pushNamed( + DesktopWalletView.routeName, + arguments: walletId, + ), + ); + } else { + unawaited(nav.pushNamed(WalletView.routeName, arguments: walletId)); + } } if (contractAddress != null) { - final contract = - ref.read(mainDBProvider).getEthContractSync(contractAddress!)!; - - final success = await showLoading( - whileFuture: _loadTokenWallet( - desktopNavigatorState?.context ?? context, - ref, - wallet, - contract, - ), - context: desktopNavigatorState?.context ?? context, - opaqueBG: true, - message: "Loading ${contract.name}", - rootNavigator: Util.isDesktop, - ); - - if (!success!) { - // TODO: show error dialog here? - Logging.instance.e( - "Failed to load token wallet for $contract", - ); - return; - } + if (wallet.cryptoCurrency is Solana) { + // Handle Solana token. + final token = ref + .read(mainDBProvider) + .getSolContractSync(contractAddress!); - if (desktopNavigatorState != null) { - await desktopNavigatorState!.pushNamed( - DesktopTokenView.routeName, - arguments: walletId, + if (token == null) { + Logging.instance.e( + "Failed to find Solana token with address: $contractAddress", + ); + return; + } + + final success = await showLoading( + whileFuture: _loadSolanaTokenWallet( + desktopNavigatorState?.context ?? context, + ref, + wallet, + token, + ), + context: desktopNavigatorState?.context ?? context, + opaqueBG: true, + message: "Loading ${token.name}", + rootNavigator: Util.isDesktop, ); + + if (!success!) { + Logging.instance.e("Failed to load token wallet for $token"); + return; + } + + if (desktopNavigatorState != null) { + await desktopNavigatorState!.pushNamed( + DesktopSolTokenView.routeName, + arguments: walletId, + ); + } else { + await nav.pushNamed( + SolTokenView.routeName, + arguments: (walletId: walletId, popPrevious: !Util.isDesktop), + ); + } } else { - await nav.pushNamed( - TokenView.routeName, - arguments: (walletId: walletId, popPrevious: !Util.isDesktop), + // Handle Ethereum token (default). + final contract = ref + .read(mainDBProvider) + .getEthContractSync(contractAddress!); + + if (contract == null) { + Logging.instance.e( + "Failed to find Ethereum contract with address: $contractAddress", + ); + return; + } + + final success = await showLoading( + whileFuture: _loadEthTokenWallet( + desktopNavigatorState?.context ?? context, + ref, + wallet, + contract, + ), + context: desktopNavigatorState?.context ?? context, + opaqueBG: true, + message: "Loading ${contract.name}", + rootNavigator: Util.isDesktop, ); + + if (!success!) { + Logging.instance.e("Failed to load token wallet for $contract"); + return; + } + + if (desktopNavigatorState != null) { + await desktopNavigatorState!.pushNamed( + DesktopTokenView.routeName, + arguments: walletId, + ); + } else { + await nav.pushNamed( + TokenView.routeName, + arguments: (walletId: walletId, popPrevious: !Util.isDesktop), + ); + } } } } @@ -203,8 +301,9 @@ class SimpleWalletCard extends ConsumerWidget { child: WalletInfoRow( walletId: walletId, contractAddress: contractAddress, - onPressedDesktop: - Util.isDesktop ? () => _openWallet(context, ref) : null, + onPressedDesktop: Util.isDesktop + ? () => _openWallet(context, ref) + : null, ), ); } diff --git a/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart b/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart index e88737d4ee..a1ec77214b 100644 --- a/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart +++ b/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart @@ -10,14 +10,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../../db/isar/main_db.dart'; -import '../../../models/isar/models/ethereum/eth_contract.dart'; +import '../../../models/isar/models/contract.dart'; +import '../../../providers/wallet/public_private_balance_state_provider.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/amount/amount_formatter.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; +import '../../../wallets/crypto_currency/coins/solana.dart'; import '../../../wallets/isar/providers/eth/token_balance_provider.dart'; +import '../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; class WalletInfoRowBalance extends ConsumerWidget { @@ -25,39 +29,69 @@ class WalletInfoRowBalance extends ConsumerWidget { super.key, required this.walletId, this.contractAddress, + this.balanceType, }); final String walletId; final String? contractAddress; + final BalanceType? balanceType; @override Widget build(BuildContext context, WidgetRef ref) { final info = ref.watch(pWalletInfo(walletId)); final Amount totalBalance; - EthContract? contract; + Contract? contract; + if (contractAddress == null) { - totalBalance = info.cachedBalance.total + - info.cachedBalanceSecondary.total + - info.cachedBalanceTertiary.total; + totalBalance = balanceType == BalanceType.private + ? info.cachedBalanceSecondary.total + info.cachedBalanceTertiary.total + : balanceType == BalanceType.public + ? info.cachedBalance.total + : info.cachedBalance.total + + info.cachedBalanceSecondary.total + + info.cachedBalanceTertiary.total; contract = null; } else { - contract = MainDB.instance.getEthContractSync(contractAddress!)!; - totalBalance = ref - .watch( - pTokenBalance( - (walletId: walletId, contractAddress: contractAddress!), - ), - ) - .total; + // Check if it's a Solana wallet. + if (info.coin is Solana) { + contract = MainDB.instance.getSolContractSync(contractAddress!); + if (contract != null) { + final solanaTokenInfo = ref.watch( + pSolanaTokenWalletInfo(( + walletId: walletId, + tokenMint: contractAddress!, + )), + ); + totalBalance = solanaTokenInfo.getCachedBalance().total; + } else { + // Token not yet in database, show zero balance. + totalBalance = Amount(rawValue: BigInt.zero, fractionDigits: 0); + } + } else { + // Ethereum token. + contract = MainDB.instance.getEthContractSync(contractAddress!); + if (contract != null) { + totalBalance = ref + .watch( + pTokenBalance(( + walletId: walletId, + contractAddress: contractAddress!, + )), + ) + .total; + } else { + // Contract not yet in database, show zero balance. + totalBalance = Amount(rawValue: BigInt.zero, fractionDigits: 0); + } + } } return Text( - ref.watch(pAmountFormatter(info.coin)).format( - totalBalance, - ethContract: contract, - ), + ref + .watch(pAmountFormatter(info.coin)) + .format(totalBalance, tokenContract: contract), style: Util.isDesktop ? STextStyles.desktopTextExtraSmall(context).copyWith( color: Theme.of(context).extension()!.textSubtitle1, diff --git a/lib/widgets/wallet_info_row/wallet_info_row.dart b/lib/widgets/wallet_info_row/wallet_info_row.dart index 381d0e0d56..835a853742 100644 --- a/lib/widgets/wallet_info_row/wallet_info_row.dart +++ b/lib/widgets/wallet_info_row/wallet_info_row.dart @@ -11,11 +11,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../models/isar/models/contract.dart'; import '../../providers/providers.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/coins/solana.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../coin_ticker_tag.dart'; import '../custom_buttons/blue_text_button.dart'; @@ -39,14 +40,25 @@ class WalletInfoRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final wallet = ref.watch(pWallets).getWallet(walletId); - - EthContract? contract; + Contract? contract; if (contractAddress != null) { - contract = ref.watch( - mainDBProvider.select( - (value) => value.getEthContractSync(contractAddress!), - ), - ); + if (wallet.info.coin is Solana) { + // Solana token. + final solContract = ref.watch( + mainDBProvider.select( + (value) => value.getSolContractSync(contractAddress!), + ), + ); + contract = solContract; + } else { + // Ethereum token. + final ethContract = ref.watch( + mainDBProvider.select( + (value) => value.getEthContractSync(contractAddress!), + ), + ); + contract = ethContract; + } } if (Util.isDesktop) { @@ -67,37 +79,38 @@ class WalletInfoRow extends ConsumerWidget { const SizedBox(width: 12), contract != null ? Row( - children: [ - Text( - contract.name, - style: STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: - Theme.of( + children: [ + Text( + contract.name, + style: + STextStyles.desktopTextExtraSmall( context, - ).extension()!.textDark, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), ), - ), - const SizedBox(width: 4), - CoinTickerTag( - ticker: ref.watch( - pWalletCoin(walletId).select((s) => s.ticker), + const SizedBox(width: 4), + CoinTickerTag( + ticker: ref.watch( + pWalletCoin(walletId).select((s) => s.ticker), + ), ), + ], + ) + : Expanded( + child: Text( + wallet.info.name, + overflow: TextOverflow.ellipsis, + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), ), - ], - ) - : Text( - wallet.info.name, - style: STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark, ), - ), ], ), ), diff --git a/lib/wl_gen/interfaces/cs_monero_interface.dart b/lib/wl_gen/interfaces/cs_monero_interface.dart index a676bd169e..f9f30d5c83 100644 --- a/lib/wl_gen/interfaces/cs_monero_interface.dart +++ b/lib/wl_gen/interfaces/cs_monero_interface.dart @@ -13,7 +13,7 @@ abstract class CsMoneroInterface { int getTxPriorityMedium(); int getTxPriorityNormal(); - bool walletExists(String path, {required CsCoin csCoin}); + bool walletExists(String path); Future estimateFee( int rate, @@ -23,19 +23,17 @@ abstract class CsMoneroInterface { Future loadWallet( String walletId, { - required CsCoin csCoin, required String path, required String password, }); - String getAddress( + Future getAddress( WrappedWallet wallet, { int accountIndex = 0, int addressIndex = 0, }); Future getCreatedWallet({ - required CsCoin csCoin, required String path, required String password, required int wordCount, @@ -44,7 +42,6 @@ abstract class CsMoneroInterface { Future getRestoredWallet({ required String walletId, - required CsCoin csCoin, required String path, required String password, required String mnemonic, @@ -54,7 +51,6 @@ abstract class CsMoneroInterface { Future getRestoredFromViewKeyWallet({ required String walletId, - required CsCoin csCoin, required String path, required String password, required String address, @@ -62,32 +58,32 @@ abstract class CsMoneroInterface { int height = 0, }); - String getTxKey(WrappedWallet wallet, String txid); + Future getTxKey(WrappedWallet wallet, String txid); Future save(WrappedWallet wallet); - String getPublicViewKey(WrappedWallet wallet); - String getPrivateViewKey(WrappedWallet wallet); - String getPublicSpendKey(WrappedWallet wallet); - String getPrivateSpendKey(WrappedWallet wallet); + Future getPublicViewKey(WrappedWallet wallet); + Future getPrivateViewKey(WrappedWallet wallet); + Future getPublicSpendKey(WrappedWallet wallet); + Future getPrivateSpendKey(WrappedWallet wallet); Future isSynced(WrappedWallet wallet); - void startSyncing(WrappedWallet wallet); - void stopSyncing(WrappedWallet wallet); + Future startSyncing(WrappedWallet wallet); + Future stopSyncing(WrappedWallet wallet); void startAutoSaving(WrappedWallet wallet); void stopAutoSaving(WrappedWallet wallet); bool hasListeners(WrappedWallet wallet); void addListener(WrappedWallet wallet, CsWalletListener listener); - void startListeners(WrappedWallet wallet); - void stopListeners(WrappedWallet wallet); + Future startListeners(WrappedWallet wallet); + Future stopListeners(WrappedWallet wallet); - Future rescanBlockchain(WrappedWallet wallet); + Future rescanBlockchain(WrappedWallet wallet); Future isConnectedToDaemon(WrappedWallet wallet); - int getRefreshFromBlockHeight(WrappedWallet wallet); - void setRefreshFromBlockHeight(WrappedWallet wallet, int height); + Future getRefreshFromBlockHeight(WrappedWallet wallet); + Future setRefreshFromBlockHeight(WrappedWallet wallet, int height); Future connect( WrappedWallet wallet, { @@ -105,8 +101,11 @@ abstract class CsMoneroInterface { bool refresh = false, }); - BigInt? getBalance(WrappedWallet wallet, {int accountIndex = 0}); - BigInt? getUnlockedBalance(WrappedWallet wallet, {int accountIndex = 0}); + Future getBalance(WrappedWallet wallet, {int accountIndex = 0}); + Future getUnlockedBalance( + WrappedWallet wallet, { + int accountIndex = 0, + }); Future> getAllTxs( WrappedWallet wallet, { @@ -153,16 +152,15 @@ abstract class CsMoneroInterface { Future thawOutput(WrappedWallet wallet, String keyImage); List getMoneroWordList(String language); - List getWowneroWordList(String language, int seedLength); - int getHeightByDate(DateTime date, {required CsCoin csCoin}); + int getHeightByDate(DateTime date); - bool validateAddress(String address, int network, {required CsCoin csCoin}); + bool validateAddress(String address, int network); - String getSeed(WrappedWallet wallet); -} + Future getSeed(WrappedWallet wallet); -enum CsCoin { monero, wownero } + Future close(WrappedWallet wallet, {bool save = false}); +} // forwarding class final class CsWalletListener { diff --git a/lib/wl_gen/interfaces/cs_salvium_interface.dart b/lib/wl_gen/interfaces/cs_salvium_interface.dart index 53ad91b7bb..58be07e53f 100644 --- a/lib/wl_gen/interfaces/cs_salvium_interface.dart +++ b/lib/wl_gen/interfaces/cs_salvium_interface.dart @@ -169,6 +169,8 @@ abstract class CsSalviumInterface { bool validateAddress(String address, int network); String getSeed(WrappedWallet wallet); + + Future close(WrappedWallet wallet, {bool save = false}); } // lol... diff --git a/lib/wl_gen/interfaces/cs_wownero_interface.dart b/lib/wl_gen/interfaces/cs_wownero_interface.dart new file mode 100644 index 0000000000..50a1522227 --- /dev/null +++ b/lib/wl_gen/interfaces/cs_wownero_interface.dart @@ -0,0 +1,161 @@ +import '../../models/input.dart'; +import 'cs_monero_interface.dart'; +import 'cs_salvium_interface.dart' show WrappedWallet; + +export '../generated/cs_wownero_interface_impl.dart'; + +abstract class CsWowneroInterface { + const CsWowneroInterface(); + + void setUseCsWowneroLoggerInternal(bool enable); + + // tx prio forwarding + int getTxPriorityHigh(); + int getTxPriorityMedium(); + int getTxPriorityNormal(); + + bool walletExists(String path); + + Future estimateFee( + int rate, + BigInt amount, { + required WrappedWallet wallet, + }); + + Future loadWallet( + String walletId, { + required String path, + required String password, + }); + + String getAddress( + WrappedWallet wallet, { + int accountIndex = 0, + int addressIndex = 0, + }); + + Future getCreatedWallet({ + required String path, + required String password, + required int wordCount, + required String seedOffset, + }); + + Future getRestoredWallet({ + required String walletId, + required String path, + required String password, + required String mnemonic, + required String seedOffset, + int height = 0, + }); + + Future getRestoredFromViewKeyWallet({ + required String walletId, + required String path, + required String password, + required String address, + required String privateViewKey, + int height = 0, + }); + + String getTxKey(WrappedWallet wallet, String txid); + + Future save(WrappedWallet wallet); + + String getPublicViewKey(WrappedWallet wallet); + String getPrivateViewKey(WrappedWallet wallet); + String getPublicSpendKey(WrappedWallet wallet); + String getPrivateSpendKey(WrappedWallet wallet); + + Future isSynced(WrappedWallet wallet); + void startSyncing(WrappedWallet wallet); + void stopSyncing(WrappedWallet wallet); + + void startAutoSaving(WrappedWallet wallet); + void stopAutoSaving(WrappedWallet wallet); + + bool hasListeners(WrappedWallet wallet); + void addListener(WrappedWallet wallet, CsWalletListener listener); + void startListeners(WrappedWallet wallet); + void stopListeners(WrappedWallet wallet); + + Future rescanBlockchain(WrappedWallet wallet); + Future isConnectedToDaemon(WrappedWallet wallet); + + int getRefreshFromBlockHeight(WrappedWallet wallet); + void setRefreshFromBlockHeight(WrappedWallet wallet, int height); + + Future connect( + WrappedWallet wallet, { + required String daemonAddress, + required bool trusted, + String? daemonUsername, + String? daemonPassword, + bool useSSL = false, + bool isLightWallet = false, + String? socksProxyAddress, + }); + + Future> getAllTxids( + WrappedWallet wallet, { + bool refresh = false, + }); + + BigInt? getBalance(WrappedWallet wallet, {int accountIndex = 0}); + BigInt? getUnlockedBalance(WrappedWallet wallet, {int accountIndex = 0}); + + Future> getAllTxs( + WrappedWallet wallet, { + bool refresh = false, + }); + + Future> getTxs( + WrappedWallet wallet, { + required Set txids, + bool refresh = false, + }); + + Future createTx( + WrappedWallet wallet, { + required CsRecipient output, + required int priority, + required bool sweep, + List? preferredInputs, + required int accountIndex, + required int minConfirms, + required int currentHeight, + }); + + Future createTxMultiDest( + WrappedWallet wallet, { + required List outputs, + required int priority, + required bool sweep, + List? preferredInputs, + required int accountIndex, + required int minConfirms, + required int currentHeight, + }); + + Future commitTx(WrappedWallet wallet, CsPendingTransaction tx); + + Future> getOutputs( + WrappedWallet wallet, { + bool refresh = false, + bool includeSpent = false, + }); + + Future freezeOutput(WrappedWallet wallet, String keyImage); + Future thawOutput(WrappedWallet wallet, String keyImage); + + List getWowneroWordList(String language, int seedLength); + + int getHeightByDate(DateTime date); + + bool validateAddress(String address, int network); + + String getSeed(WrappedWallet wallet); + + Future close(WrappedWallet wallet, {bool save = false}); +} diff --git a/lib/wl_gen/interfaces/lib_spark_interface.dart b/lib/wl_gen/interfaces/lib_spark_interface.dart index 5f24277857..06ddf07c49 100644 --- a/lib/wl_gen/interfaces/lib_spark_interface.dart +++ b/lib/wl_gen/interfaces/lib_spark_interface.dart @@ -4,6 +4,34 @@ import 'package:logger/logger.dart'; export '../generated/lib_spark_interface_impl.dart'; +enum LibSparkSpendVersion { + chaumV1(transactionType: 9), + chaumV2(transactionType: 11); + + const LibSparkSpendVersion({required this.transactionType}); + + static const int baseTransactionVersion = 3; + final int transactionType; + + int get transactionVersion => + baseTransactionVersion | (transactionType << 16); + + bool get allowsMultipleInputs => this == chaumV2; +} + +final class LibSparkNameProofInput { + const LibSparkNameProofInput.chaumV1({required String scalarHex}) + : spendVersion = .chaumV1, + inputHex = scalarHex; + + const LibSparkNameProofInput.chaumV2({required String ownershipDigest}) + : spendVersion = .chaumV2, + inputHex = ownershipDigest; + + final LibSparkSpendVersion spendVersion; + final String inputHex; +} + abstract class LibSparkInterface { const LibSparkInterface(); @@ -14,8 +42,8 @@ abstract class LibSparkInterface { int get maxNameLength; int get maxAdditionalInfoLengthBytes; String get nameRegexString; - String get stage3DevelopmentFundAddressMainNet; - String get stage3DevelopmentFundAddressTestNet; + String get stage3CommunityFundAddressMainNet; + String get stage3CommunityFundAddressTestNet; List get standardSparkNamesFee; void initSparkLogging(Level level); @@ -31,11 +59,16 @@ abstract class LibSparkInterface { bool isTestNet = false, }); + LibSparkSpendVersion getSpendVersionForBlockHeight({ + required int nextBlockHeight, + required int chaumV2ActivationHeight, + }); + ({Uint8List script, int size}) createSparkNameScript({ required int sparkNameValidityBlocks, required String name, required String additionalInfo, - required String scalarHex, + required LibSparkNameProofInput proofInput, required String privateKeyHex, required int spendKeyIndex, required int diversifier, @@ -44,6 +77,10 @@ abstract class LibSparkInterface { required bool ignoreProof, }); + Uint8List getSparkNameCommitment({ + required Uint8List serializedSparkNameData, + }); + List<({Uint8List scriptPubKey, int amount, bool subtractFeeFromAmount})> createSparkMintRecipients({ required List<({String sparkAddress, int value, String memo})> outputs, @@ -61,6 +98,25 @@ abstract class LibSparkInterface { final bool isTestNet = false, }); + WrappedLibSparkCoin? identifyAndRecoverCoinByFullViewKey( + final String serializedCoin, { + required final String fullViewKeyHex, + required final Uint8List context, + final bool isTestNet = false, + }); + + Future getAddressFromFullViewKey({ + required String fullViewKeyHex, + required int index, + required int diversifier, + bool isTestNet = false, + }); + + String getFullViewKeyHexFromPrivateKeyData({ + required String privateKeyHex, + required int index, + }); + ({ Uint8List serializedSpendPayload, List outputScripts, @@ -109,6 +165,8 @@ abstract class LibSparkInterface { required List<({int setId, Uint8List blockHash})> idAndBlockHashes, required Uint8List txHash, required int additionalTxSize, + required LibSparkSpendVersion spendVersion, + Uint8List? extensionCommitment, }); int estimateSparkFee({ @@ -128,6 +186,7 @@ abstract class LibSparkInterface { required int privateRecipientsCount, required int utxoNum, required int additionalTxSize, + required LibSparkSpendVersion spendVersion, }); } diff --git a/lib/wl_gen/interfaces/lib_xelis_interface.dart b/lib/wl_gen/interfaces/lib_xelis_interface.dart index ecf76a435d..4674347e3b 100644 --- a/lib/wl_gen/interfaces/lib_xelis_interface.dart +++ b/lib/wl_gen/interfaces/lib_xelis_interface.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import '../../providers/progress_report/xelis_table_progress_provider.dart'; +import '../../utilities/dynamic_object.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; export '../generated/lib_xelis_interface_impl.dart'; @@ -18,7 +19,10 @@ abstract class LibXelisInterface { Stream createProgressReportStream(); - bool isAddressValid({required String address}); + bool isAddressValid({ + required String address, + required CryptoCurrencyNetwork network, + }); bool validateSeedWord(String word); @@ -32,7 +36,7 @@ abstract class LibXelisInterface { Future updateTables({ required String precomputedTablesPath, - required bool l1Low, + required bool stack_l1Low, }); Future getSeed(OpaqueXelisWallet wallet); @@ -46,7 +50,7 @@ abstract class LibXelisInterface { String? seed, String? privateKey, String? precomputedTablesPath, - bool? l1Low, + bool? stack_l1Low, }); Future openXelisWallet( @@ -56,7 +60,7 @@ abstract class LibXelisInterface { required String password, required CryptoCurrencyNetwork network, String? precomputedTablesPath, - bool? l1Low, + bool? stack_l1Low, }); String getAddress(OpaqueXelisWallet wallet); @@ -225,6 +229,8 @@ enum XelisTableSize { low, full; + // TODO: add more granular table size management interface + // for now, just patching the old system into the new FFI API bool get isLow => this == XelisTableSize.low; static XelisTableSize get platformDefault { @@ -294,7 +300,10 @@ final class NewAsset extends Event { // final xelis_sdk.AssetData asset; final String name; final int decimals; - final int? maxSupply; + + // if used in later, this will probably need to be deconstructed in order + // to keep conditional import of xelis working + final DynamicObject? maxSupply; NewAsset(this.name, this.decimals, this.maxSupply); } diff --git a/lib/wl_gen/interfaces/libepiccash_interface.dart b/lib/wl_gen/interfaces/libepiccash_interface.dart index af5e65375b..cdbbb19cee 100644 --- a/lib/wl_gen/interfaces/libepiccash_interface.dart +++ b/lib/wl_gen/interfaces/libepiccash_interface.dart @@ -1,3 +1,7 @@ +import 'dart:math'; + +import '../../utilities/dynamic_object.dart'; + export '../generated/libepiccash_interface_impl.dart'; abstract class LibEpicCashInterface { @@ -7,24 +11,30 @@ abstract class LibEpicCashInterface { bool txTypeIsReceiveCancelled(Enum value); bool txTypeIsSentCancelled(Enum value); - Future initializeNewWallet({ + Future initializeNewWallet({ required String config, required String mnemonic, required String password, required String name, + required String epicBoxConfig, }); - Future openWallet({required String config, required String password}); + Future openWallet({ + required String config, + required String password, + required String epicboxConfig, + }); - Future recoverWallet({ + Future recoverWallet({ required String config, required String password, required String mnemonic, required String name, + required String epicBoxConfig, }); Future<({String commitId, String slateId})> txHttpSend({ - required String wallet, + required DynamicObject wallet, required int selectionStrategyIsAll, required int minimumConfirmations, required String message, @@ -32,40 +42,51 @@ abstract class LibEpicCashInterface { required String address, }); - Future<({String commitId, String slateId})> createTransaction({ - required String wallet, + Future<({String commitId, String slateId, String slateJson})> + createTransaction({ + required DynamicObject wallet, required int amount, required String address, required int secretKeyIndex, - required String epicboxConfig, required int minimumConfirmations, required String note, + bool returnSlate = false, + }); + + Future<({String slateId, String commitId, String slateJson})> txReceive({ + required DynamicObject wallet, + required String slateJson, + }); + + Future<({String slateId, String commitId, String slateJson})> txFinalize({ + required DynamicObject wallet, + required String slateJson, }); Future cancelTransaction({ - required String wallet, + required DynamicObject wallet, required String transactionId, }); Future> getTransactions({ - required String wallet, + required DynamicObject wallet, required int refreshFromNode, }); - void startEpicboxListener({ - required String wallet, - required String epicboxConfig, - }); + Future startEpicboxListener({required DynamicObject wallet}); + + Future stopEpicboxListener({required DynamicObject wallet}); - void stopEpicboxListener(); + Future isEpicboxListenerRunning({required DynamicObject wallet}); - bool validateSendAddress({required String address}); + Future validateSendAddress({required String address}); + + bool validateSendAddressSync({required String address}); Future<({int fee, bool strategyUseAll, int total})> getTransactionFees({ - required String wallet, + required DynamicObject wallet, required int amount, required int minimumConfirmations, - required int available, }); Future< @@ -77,26 +98,35 @@ abstract class LibEpicCashInterface { }) > getWalletBalances({ - required String wallet, + required DynamicObject wallet, required int refreshFromNode, required int minimumConfirmations, }); Future getAddressInfo({ - required String wallet, + required DynamicObject wallet, required int index, required String epicboxConfig, }); Future scanOutputs({ - required String wallet, + required DynamicObject wallet, required int startHeight, required int numberOfBlocks, }); Future getChainHeight({required String config}); - Future deleteWallet({required String wallet, required String config}); + Future close({required DynamicObject wallet}); + + Future deleteWallet({required String config}); + + void updateEpicboxConfig({ + required DynamicObject wallet, + required String epicBoxConfig, + }); + + void updateConfig({required DynamicObject wallet, required String config}); String getPluginVersion(); } @@ -141,6 +171,24 @@ class EpicTransaction { this.kernelLookupMinHeight, this.paymentProof, }); + + @override + String toString() { + return 'EpicTransaction(' + 'id: $id, ' + 'txSlateId: $txSlateId, ' + 'type: $txType, ' + 'confirmed: $confirmed, ' + 'inputs: $numInputs, ' + 'outputs: $numOutputs, ' + 'credited: $amountCredited, ' + 'debited: $amountDebited, ' + 'fee: $fee, ' + 'created: $creationTs, ' + 'confirmed: $confirmationTs, ' + 'messages: ${messages?.length ?? 0}' + ')'; + } } class EpicMessage { @@ -155,6 +203,15 @@ class EpicMessage { this.message, this.messageSig, }); + + @override + String toString() { + return 'EpicMessage(' + 'id: $id, ' + 'publicKey: ${publicKey.substring(0, 8)}..., ' + 'message: ${message != null ? '"${message!.substring(0, min(20, message!.length))}..."' : 'null'}' + ')'; + } } class BadHttpAddressException implements Exception {} diff --git a/lib/wl_gen/interfaces/libmwc_interface.dart b/lib/wl_gen/interfaces/libmwc_interface.dart index acd5b2d795..a44b3b4d20 100644 --- a/lib/wl_gen/interfaces/libmwc_interface.dart +++ b/lib/wl_gen/interfaces/libmwc_interface.dart @@ -140,6 +140,8 @@ abstract class LibMwcInterface { Future deleteWallet({required String wallet, required String config}); + Future initLogs({required String config}); + String getPluginVersion(); } diff --git a/lib/wl_gen/interfaces/mwebd_server_interface.dart b/lib/wl_gen/interfaces/mwebd_server_interface.dart index 8b597d0771..451a96d471 100644 --- a/lib/wl_gen/interfaces/mwebd_server_interface.dart +++ b/lib/wl_gen/interfaces/mwebd_server_interface.dart @@ -1,3 +1,4 @@ +import '../../utilities/dynamic_object.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; export '../generated/mwebd_server_interface_impl.dart'; @@ -5,7 +6,7 @@ export '../generated/mwebd_server_interface_impl.dart'; abstract class MwebdServerInterface { const MwebdServerInterface(); - Future<({OpaqueMwebdServer server, int port})> createAndStartServer( + Future<({DynamicObject server, int port})> createAndStartServer( CryptoCurrencyNetwork net, { required String chain, required String dataDir, @@ -15,58 +16,6 @@ abstract class MwebdServerInterface { }); Future<({String chain, String dataDir, String peer})> stopServer( - OpaqueMwebdServer server, + DynamicObject server, ); - - Future getServerStatus(OpaqueMwebdServer? server); -} - -// local copy -class Status { - final int blockHeaderHeight; - final int mwebHeaderHeight; - final int mwebUtxosHeight; - final int blockTime; - - Status({ - required this.blockHeaderHeight, - required this.mwebHeaderHeight, - required this.mwebUtxosHeight, - required this.blockTime, - }); - - @override - String toString() { - return 'Status(' - 'blockHeaderHeight: $blockHeaderHeight, ' - 'mwebHeaderHeight: $mwebHeaderHeight, ' - 'mwebUtxosHeight: $mwebUtxosHeight, ' - 'blockTime: $blockTime' - ')'; - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is Status && - blockHeaderHeight == other.blockHeaderHeight && - mwebHeaderHeight == other.mwebHeaderHeight && - mwebUtxosHeight == other.mwebUtxosHeight && - blockTime == other.blockTime; - - @override - int get hashCode => Object.hash( - blockHeaderHeight, - mwebHeaderHeight, - mwebUtxosHeight, - blockTime, - ); -} - -final class OpaqueMwebdServer { - final Object _value; - - const OpaqueMwebdServer(this._value); - - T get() => _value as T; } diff --git a/pubspec.lock b/pubspec.lock index fd2dde3709..6164f3dbbf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: dd3d2ad434b9510001d089e8de7556d50c834481b9abc2891a0184a8493a19dc + sha256: "0eb33edbbe99a02e73b8bbeb6f2b65972023d902117ee8d1bf0ea1a79f83aa7b" url: "https://pub.dev" source: hosted - version: "89.0.0" + version: "90.0.0" analyzer: dependency: "direct dev" description: name: analyzer - sha256: c22b6e7726d1f9e5db58c7251606076a71ca0dbcf76116675edfadbec0c9e875 + sha256: "711e3a890bb529bf55f07d73b8706f4b7504ad77e90d2f205626b116c048583f" url: "https://pub.dev" source: hosted - version: "8.2.0" + version: "8.3.0" another_flushbar: dependency: "direct main" description: @@ -37,10 +37,10 @@ packages: dependency: "direct main" description: name: archive - sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "3.6.1" + version: "4.0.9" args: dependency: transitive description: @@ -53,79 +53,72 @@ packages: dependency: "direct main" description: name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0" basic_utils: dependency: "direct main" description: name: basic_utils - sha256: "2064b21d3c41ed7654bc82cc476fd65542e04d60059b74d5eed490a4da08fc6c" + sha256: "548047bef0b3b697be19fa62f46de54d99c9019a69fb7db92c69e19d87f633c7" url: "https://pub.dev" source: hosted - version: "5.7.0" + version: "5.8.2" bech32: dependency: "direct main" description: path: "." - ref: b6d2a5b4cd17311d917787c0f9505f04932659b1 - resolved-ref: b6d2a5b4cd17311d917787c0f9505f04932659b1 + ref: "6a3388ff8f62c1fa5e624bb7f36c8e71fe53428d" + resolved-ref: "6a3388ff8f62c1fa5e624bb7f36c8e71fe53428d" url: "https://github.com/cypherstack/bech32.git" source: git version: "0.2.1" bip32: dependency: "direct main" description: - name: bip32 - sha256: "54787cd7a111e9d37394aabbf53d1fc5e2e0e0af2cd01c459147a97c0e3f8a97" - url: "https://pub.dev" - source: hosted + path: "." + ref: "9a7e9b9bad9872c69dd1383d6b2e6090f85148fc" + resolved-ref: "9a7e9b9bad9872c69dd1383d6b2e6090f85148fc" + url: "https://github.com/cypherstack/bip32-dart" + source: git version: "2.0.0" - bip340: - dependency: "direct main" - description: - name: bip340 - sha256: "2a92f6ed68959f75d67c9a304c17928b9c9449587d4f75ee68f34152f7f69e87" - url: "https://pub.dev" - source: hosted - version: "0.2.0" bip39: dependency: "direct main" description: path: "." - ref: "0cd6d54e2860bea68fc50c801cb9db2a760192fb" - resolved-ref: "0cd6d54e2860bea68fc50c801cb9db2a760192fb" + ref: "20bc8ca0bf0a30c6965977a26c41475a9e862020" + resolved-ref: "20bc8ca0bf0a30c6965977a26c41475a9e862020" url: "https://github.com/cypherstack/stack-bip39.git" source: git - version: "1.0.6" + version: "1.0.7" bip47: dependency: "direct main" description: path: "." - ref: a6e7941b98a43a613708b1a12564bc17e712cfc7 - resolved-ref: a6e7941b98a43a613708b1a12564bc17e712cfc7 + ref: bdc0c0788d1d6dfb04863a793955f848ba1624a8 + resolved-ref: bdc0c0788d1d6dfb04863a793955f848ba1624a8 url: "https://github.com/cypherstack/bip47.git" source: git - version: "2.0.0" + version: "2.1.0" bitbox: dependency: "direct main" description: path: "." - ref: "50bf29957514a5712466ba37590a851212a244bf" - resolved-ref: "50bf29957514a5712466ba37590a851212a244bf" - url: "https://github.com/PiRK/bitbox-flutter.git" + ref: "4c3c1aadae089dd1ace705aedf012e1c89fe53ad" + resolved-ref: "4c3c1aadae089dd1ace705aedf012e1c89fe53ad" + url: "https://github.com/cypherstack/bitbox-flutter.git" source: git - version: "1.0.1" + version: "1.0.2" bitcoindart: dependency: "direct main" description: path: "." - ref: af6d6c27edfe2e7cc35772ed2684eb4cc826f0e4 - resolved-ref: af6d6c27edfe2e7cc35772ed2684eb4cc826f0e4 + ref: ea33b1f5d6a701791359a2e180f73866dc667732 + resolved-ref: ea33b1f5d6a701791359a2e180f73866dc667732 url: "https://github.com/cypherstack/bitcoindart.git" source: git - version: "3.0.1" + version: "3.0.2" blockchain_signer: dependency: transitive description: @@ -170,10 +163,10 @@ packages: dependency: transitive description: name: build - sha256: "7d95cbbb1526ab5ae977df9b4cc660963b9b27f6d1075c0b34653868911385e4" + sha256: ce76b1d48875e3233fde17717c23d1f60a91cc631597e49a400c89b475395b1d url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.1.0" build_cli_annotations: dependency: transitive description: @@ -186,42 +179,42 @@ packages: dependency: transitive description: name: build_config - sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.2.0" build_daemon: dependency: transitive description: name: build_daemon - sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 url: "https://pub.dev" source: hosted - version: "4.0.4" + version: "4.1.1" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "38c9c339333a09b090a638849a4c56e70a404c6bdd3b511493addfbc113b60c2" + sha256: d1d57f7807debd7349b4726a19fd32ec8bc177c71ad0febf91a20f84cd2d4b46 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.3" build_runner: dependency: "direct dev" description: name: build_runner - sha256: b971d4a1c789eba7be3e6fe6ce5e5b50fd3719e3cb485b3fad6d04358304351d + sha256: b24597fceb695969d47025c958f3837f9f0122e237c6a22cb082a5ac66c3ca30 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.1" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: c04e612ca801cd0928ccdb891c263a2b1391cb27940a5ea5afcf9ba894de5d62 + sha256: "066dda7f73d8eb48ba630a55acb50c4a84a2e6b453b1cb4567f581729e794f7b" url: "https://pub.dev" source: hosted - version: "9.2.0" + version: "9.3.1" built_collection: dependency: transitive description: @@ -234,10 +227,10 @@ packages: dependency: transitive description: name: built_value - sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d + sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" url: "https://pub.dev" source: hosted - version: "8.12.0" + version: "8.12.4" calendar_date_picker2: dependency: "direct main" description: @@ -267,10 +260,10 @@ packages: dependency: "direct main" description: name: camera_platform_interface - sha256: ea1ef6ba79cdbed93df2d3eeef11542a90dec24dbcd9cde574926b86d7a09a10 + sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63" url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.12.0" camera_windows: dependency: "direct main" description: @@ -284,18 +277,26 @@ packages: dependency: "direct main" description: name: cbor - sha256: f5239dd6b6ad24df67d1449e87d7180727d6f43b87b3c9402e6398c7a2d9609b + sha256: "2c5c37650f0a2d25149f03e748ab7b2857787bde338f95fe947738b80d713da2" url: "https://pub.dev" source: hosted - version: "6.3.7" + version: "6.5.1" + change_case: + dependency: transitive + description: + name: change_case + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 + url: "https://pub.dev" + source: hosted + version: "2.2.0" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -308,10 +309,10 @@ packages: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" cli_config: dependency: transitive description: @@ -336,30 +337,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" code_builder: dependency: transitive description: name: code_builder - sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" url: "https://pub.dev" source: hosted - version: "4.11.0" + version: "4.11.1" coinlib: dependency: "direct overridden" description: path: coinlib - ref: d212a8f974bf30be82ce486bf60d7135d80eb6a2 - resolved-ref: d212a8f974bf30be82ce486bf60d7135d80eb6a2 - url: "https://www.github.com/julian-CStack/coinlib" + ref: f0e12dacb6d39e1cb340f0deae178d0fad1d6fd6 + resolved-ref: f0e12dacb6d39e1cb340f0deae178d0fad1d6fd6 + url: "https://github.com/cypherstack/coinlib" source: git version: "4.1.0" coinlib_flutter: dependency: "direct main" description: path: coinlib_flutter - ref: d212a8f974bf30be82ce486bf60d7135d80eb6a2 - resolved-ref: d212a8f974bf30be82ce486bf60d7135d80eb6a2 - url: "https://www.github.com/julian-CStack/coinlib" + ref: f0e12dacb6d39e1cb340f0deae178d0fad1d6fd6 + resolved-ref: f0e12dacb6d39e1cb340f0deae178d0fad1d6fd6 + url: "https://github.com/cypherstack/coinlib" source: git version: "4.0.0" collection: @@ -415,98 +424,98 @@ packages: dependency: transitive description: name: cross_file - sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" url: "https://pub.dev" source: hosted - version: "0.3.4+2" + version: "0.3.5+2" crypto: dependency: "direct main" description: name: crypto - sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67 + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.7" cryptography: dependency: transitive description: name: cryptography - sha256: d146b76d33d94548cf035233fbc2f4338c1242fa119013bead807d033fc4ae05 + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "2.9.0" cs_monero: dependency: "direct main" description: name: cs_monero - sha256: "7cfbcd25135a0710ad096678160d7668abed8979838165f06975adbe6bbec215" + sha256: b174f40e1887eb589e1e9aa99de8e9d0bc97b543f2330d5e5e7b01a6d313a9c2 url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "3.2.0" cs_monero_flutter_libs: dependency: "direct main" description: name: cs_monero_flutter_libs - sha256: "47d716adc7b668653e359df785702d1213245f2fab6efa930a70b87e4cba23ae" + sha256: "459542acbfc01ee6f30446c656cba670c7f1b90e52b7921a4aa0dcbc275b9eca" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "2.0.1" cs_monero_flutter_libs_android: dependency: transitive description: name: cs_monero_flutter_libs_android - sha256: "4b9d1117e63352d27bd0cb7115fc20d6212bd02a7e6ec3cd8ab2b37fddfb21eb" + sha256: f0785f34bcf9872347823303f09409b1238b2ed7e535b9722633b0022d6188f5 url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.2" cs_monero_flutter_libs_android_arm64_v8a: dependency: transitive description: name: cs_monero_flutter_libs_android_arm64_v8a - sha256: cbb8704dcc1d02581a820b99188c97acaa140eaefedee9ce7d17910e24e5530f + sha256: "0b836dff1ead29229535a3228c7c57517127bea8b19c4c2d9bdae2770526f8ca" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.3.0" cs_monero_flutter_libs_android_armeabi_v7a: dependency: transitive description: name: cs_monero_flutter_libs_android_armeabi_v7a - sha256: dc276544b169553a8a63855beaa6c2cf8180af68fb335ab1b629f2fa9370e123 + sha256: "7955bbf91e1c3ec66e352a33e36edbab509808db6db6debfbea06f1ad2396205" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.3.0" cs_monero_flutter_libs_android_x86_64: dependency: transitive description: name: cs_monero_flutter_libs_android_x86_64 - sha256: fb02563c07d3fb4804925ec66446e26389ca2d92659493b72a6cf106765fa321 + sha256: f51f95aa4a09be497befe020621b0d62d749d900f4dfd585fe60b7c9692010a8 url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.3.0" cs_monero_flutter_libs_ios: dependency: transitive description: name: cs_monero_flutter_libs_ios - sha256: "6fbe1590b0633f42c906dfada1db8e3ce4f8899eae8728a4bb9b696dc7fb5155" + sha256: dbc149c0787a7702a3842b4974b9bc30bad654daaa57886f874823c29c390ba7 url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.3.0" cs_monero_flutter_libs_linux: dependency: transitive description: name: cs_monero_flutter_libs_linux - sha256: "394a58f4efefd3857f1f3da03f21e33f1c2ca5141936db7a843e77286ffaa89e" + sha256: "5b8bbc68a7d2bb39efdea4834097ada1aa99fd7e0b1641943c4e06c89f96616e" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.3.0" cs_monero_flutter_libs_macos: dependency: transitive description: name: cs_monero_flutter_libs_macos - sha256: e00616ab86a0ea18b3360dbae8d862b83fa450b1a83355647e34e8c64696a6c7 + sha256: ee02b78184b4168bc2bdb49c7ef71cc5019ffbed54c0feabcebdbc4cae5819ee url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.3.0" cs_monero_flutter_libs_platform_interface: dependency: transitive description: @@ -519,10 +528,10 @@ packages: dependency: transitive description: name: cs_monero_flutter_libs_windows - sha256: de265ed544a4edb9e778e88b56ccee098a1ad38cd4c4536a985f05d3dde95a23 + sha256: "9db54230f83ec07e2dce39b6b90711616ba4ab1144c7f68e4b1c13b161a18cd3" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.3.0" cs_salvium: dependency: "direct main" description: @@ -535,66 +544,66 @@ packages: dependency: "direct main" description: name: cs_salvium_flutter_libs - sha256: "2aea1bbb6e6b69ac0a8e4dace2efc50507a10651ad9bec862f6a5ccd06a76578" + sha256: ac02985a3b9791979d82126f9c7a3a0f239f0cbfed5346be5a2c30b36e53c737 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.1" cs_salvium_flutter_libs_android: dependency: transitive description: name: cs_salvium_flutter_libs_android - sha256: ad9537942f7c1416fbb3432cb154d641262bd18c56471c4f62dd1d2e7e23f125 + sha256: "879706067b32450fe299fb558ad08d6b33cc2ea25a5ffe05ec38346b21e7d60a" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.1" cs_salvium_flutter_libs_android_arm64_v8a: dependency: transitive description: name: cs_salvium_flutter_libs_android_arm64_v8a - sha256: "4c307cd3276c7aa2a461ebcfc726adf9b4d9427dbdbad120dbe50f54d3690b4e" + sha256: "2b0d8047fd777a4a40b60f23310be20dafccbda0f5577465300f3128d90ad5d3" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" cs_salvium_flutter_libs_android_armeabi_v7a: dependency: transitive description: name: cs_salvium_flutter_libs_android_armeabi_v7a - sha256: "9491e0cdd4452c9c907e137acd2d08f76d33efc7a9d4b86fbfab69224bc9f473" + sha256: fb48829fdc52c4cbc71390dcb45a09fdd4e5dddb377bbd3a6b723225be6ea596 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" cs_salvium_flutter_libs_android_x86_64: dependency: transitive description: name: cs_salvium_flutter_libs_android_x86_64 - sha256: "0b87ccd86bd9b0eeb659dade948d076cddf908d535fe803b769030da8ff406dc" + sha256: "3956342b7fc1e2edf9759d2eaf084909dc0a22e5545bc6b962bbdf59c14e23cf" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" cs_salvium_flutter_libs_ios: dependency: transitive description: name: cs_salvium_flutter_libs_ios - sha256: "4dc2447255f1c8997b6d26e72577e30ceab7f4622620549dc9de9eb8dccac35c" + sha256: "5917178148b04f642e604ad8acba041a96f752689a75d7074690a46b6207d3d8" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" cs_salvium_flutter_libs_linux: dependency: transitive description: name: cs_salvium_flutter_libs_linux - sha256: "8adc16e9d0fb8dc439475ddb2eaa4fcde8433fa2cb6e14ce814b1a40965eda5c" + sha256: "5722e9024cb269cb59b6cc4b1df605ddddb432afff04cf3c9bd513c5fbe91be7" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" cs_salvium_flutter_libs_macos: dependency: transitive description: name: cs_salvium_flutter_libs_macos - sha256: "428e4eead3d507112cb6f0b70f69bc43430b3db60f0b4d731e0d6a6fab0b69bb" + sha256: "4413f1f6dfec97574326fc004ea4849c855163d95763a1109cfe9edfc59e2951" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" cs_salvium_flutter_libs_platform_interface: dependency: transitive description: @@ -607,10 +616,98 @@ packages: dependency: transitive description: name: cs_salvium_flutter_libs_windows - sha256: "934a1eeb95619df9e23eff13a6a6a356322297abfa6ab871283cdf665cc32c7f" + sha256: "87f354e0103919022d2376b4305c424eb48289ffe90995553d708bbcce819a79" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + cs_wownero: + dependency: "direct main" + description: + name: cs_wownero + sha256: "9ff7a6be0f4524c6b9e5ca1d223df98e9455c7fe3b06f0b519280a175795e925" url: "https://pub.dev" source: hosted version: "2.0.0" + cs_wownero_flutter_libs: + dependency: "direct main" + description: + name: cs_wownero_flutter_libs + sha256: ba1156d015a9f75c841f927ff2ce6565cd7cd37f15aaedd9aaf36703453a9884 + url: "https://pub.dev" + source: hosted + version: "2.0.3" + cs_wownero_flutter_libs_android: + dependency: transitive + description: + name: cs_wownero_flutter_libs_android + sha256: "14fe0666999d078bcd91ca499a9e9395dd270211eedb2250c533cbd036cb328b" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + cs_wownero_flutter_libs_android_arm64_v8a: + dependency: transitive + description: + name: cs_wownero_flutter_libs_android_arm64_v8a + sha256: "19f7e17ce7adf4615685f92b106c7f588dee80bb4768931c2505d2761a9fa06c" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + cs_wownero_flutter_libs_android_armeabi_v7a: + dependency: transitive + description: + name: cs_wownero_flutter_libs_android_armeabi_v7a + sha256: "1b7dc845674c938259dcbce6b9d6e6c305c98c2ff9b83803b00ea0f1268dfb28" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + cs_wownero_flutter_libs_android_x86_64: + dependency: transitive + description: + name: cs_wownero_flutter_libs_android_x86_64 + sha256: c318ce80ef418d53aeef3698c89c0497394269311f8c5b75f160e0f81610f9d9 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + cs_wownero_flutter_libs_ios: + dependency: transitive + description: + name: cs_wownero_flutter_libs_ios + sha256: "9ffd158469a0a45668d89ce56b90e846dd823ffd44ee6997b50b76129e6f613c" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + cs_wownero_flutter_libs_linux: + dependency: transitive + description: + name: cs_wownero_flutter_libs_linux + sha256: "441c9a7b28e28434942709915e6a54ea2392b3261f90c116e03b27b02fce7492" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + cs_wownero_flutter_libs_macos: + dependency: transitive + description: + name: cs_wownero_flutter_libs_macos + sha256: e703975e6a6f698b01e07b238953547391faa4e930f09733d01f1346ee788fc7 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + cs_wownero_flutter_libs_platform_interface: + dependency: transitive + description: + name: cs_wownero_flutter_libs_platform_interface + sha256: "6a3bda9bcf5a904b36cbd0817e7ae8b7a64693e6f532f1783513e93c64436e6f" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + cs_wownero_flutter_libs_windows: + dependency: transitive + description: + name: cs_wownero_flutter_libs_windows + sha256: fe7485863a6e83e31581cef36c62d3507a9ea36c73d56842b4fee059f349cb49 + url: "https://pub.dev" + source: hosted + version: "1.2.0" csslib: dependency: transitive description: @@ -638,10 +735,11 @@ packages: dart_bs58check: dependency: "direct main" description: - name: dart_bs58check - sha256: "4284e606795a18c1df5a955928bdc4e1b6f908da7ab0e87f49db51b3774e9e6c" - url: "https://pub.dev" - source: hosted + path: "." + ref: bed60e43e4e509ea45bb097e6caee9f8293ddf98 + resolved-ref: bed60e43e4e509ea45bb097e6caee9f8293ddf98 + url: "https://github.com/cypherstack/dart-bs58check" + source: git version: "3.0.2" dart_numerics: dependency: "direct main" @@ -655,10 +753,10 @@ packages: dependency: transitive description: name: dart_style - sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697 + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" dartx: dependency: transitive description: @@ -671,26 +769,26 @@ packages: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" decimal: dependency: "direct main" description: name: decimal - sha256: "24a261d5d5c87e86c7651c417a5dbdf8bcd7080dd592533910e8d0505a279f21" + sha256: fc706a5618b81e5b367b01dd62621def37abc096f2b46a9bd9068b64c1fa36d0 url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "3.2.4" dependency_validator: dependency: "direct dev" description: name: dependency_validator - sha256: "3a243f5b9def5f902887a66fbea7e72e612eee956af6c8c34d382fe6d5484145" + sha256: d6084f8df7677843c8fd0e08b66c11d9c2ce9bae1bb1f18cc574bcb28ebe71b0 url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "5.0.5" desktop_drop: dependency: "direct main" description: @@ -718,12 +816,11 @@ packages: devicelocale: dependency: "direct main" description: - path: "." - ref: ba7d7d87a3772e972adb1358a5ec9a111b514fce - resolved-ref: ba7d7d87a3772e972adb1358a5ec9a111b514fce - url: "https://github.com/cypherstack/flutter-devicelocale" - source: git - version: "0.8.1" + name: devicelocale + sha256: f38dd07265ddd5ede22253d99c7beabb09e0b5b3d36f1f785086b8ca28c27673 + url: "https://pub.dev" + source: hosted + version: "0.9.1" digest_auth: dependency: "direct main" description: @@ -736,42 +833,42 @@ packages: dependency: transitive description: name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c url: "https://pub.dev" source: hosted - version: "5.9.0" + version: "5.9.2" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" drift: dependency: "direct main" description: name: drift - sha256: "540cf382a3bfa99b76e51514db5b0ebcd81ce3679b7c1c9cb9478ff3735e47a1" + sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e" url: "https://pub.dev" source: hosted - version: "2.28.2" + version: "2.31.0" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: "4db0eeedc7e8bed117a9f22d867ab7a3a294300fed5c269aac90d0b3545967ca" + sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587" url: "https://pub.dev" source: hosted - version: "2.28.3" + version: "2.31.0" drift_flutter: dependency: "direct main" description: name: drift_flutter - sha256: b7534bf320aac5213259aac120670ba67b63a1fd010505babc436ff86083818f + sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7 url: "https://pub.dev" source: hosted - version: "0.2.7" + version: "0.2.8" dropdown_button2: dependency: "direct main" description: @@ -800,16 +897,16 @@ packages: dependency: transitive description: name: eip55 - sha256: "213a9b86add87a5216328e8494b0ab836e401210c4d55eb5e521bd39e39169e1" + sha256: a81d6afe386ec965e584541fe8f19719bed8a7ae23a5f5061112e96c50e6521b url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.0.3" electrum_adapter: dependency: "direct main" description: path: "." - ref: "794ab2d7b88b34d64a89518f9b9f41dcc235aca1" - resolved-ref: "794ab2d7b88b34d64a89518f9b9f41dcc235aca1" + ref: b6fa44d015d3bfa06934b73219928c29ca48a290 + resolved-ref: b6fa44d015d3bfa06934b73219928c29ca48a290 url: "https://github.com/cypherstack/electrum_adapter.git" source: git version: "3.0.2" @@ -825,18 +922,19 @@ packages: dependency: "direct main" description: name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.8" ethereum_addresses: dependency: "direct main" description: - name: ethereum_addresses - sha256: e6ba01d44ecb9c5634367b017d6e94598fc937be8b28fc406d0e51ed6e9513dd - url: "https://pub.dev" - source: hosted - version: "1.0.2" + path: "." + ref: "6a5d3d69e54c175ae44b44040fb2743c9b6405a6" + resolved-ref: "6a5d3d69e54c175ae44b44040fb2743c9b6405a6" + url: "https://github.com/cypherstack/dart-ethereum_address" + source: git + version: "1.0.3" event_bus: dependency: "direct main" description: @@ -857,10 +955,10 @@ packages: dependency: "direct main" description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -872,12 +970,11 @@ packages: file_picker: dependency: "direct main" description: - path: "." - ref: b2849e63e1d418ad8d943c886cd3f4ed20d0ff23 - resolved-ref: b2849e63e1d418ad8d943c886cd3f4ed20d0ff23 - url: "https://github.com/cypherstack/flutter_file_picker.git" - source: git - version: "8.3.1" + name: file_picker + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" + url: "https://pub.dev" + source: hosted + version: "10.3.10" fixnum: dependency: "direct main" description: @@ -904,14 +1001,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_feather_icons: - dependency: "direct main" - description: - name: flutter_feather_icons - sha256: b33b9c276fc8108254632da6644cf01f71af6c17fbfb26e136a86945f5ff9b67 - url: "https://pub.dev" - source: hosted - version: "2.0.0+1" flutter_hooks: dependency: "direct main" description: @@ -946,8 +1035,8 @@ packages: dependency: "direct main" description: path: "." - ref: "84a139a25ab1691762002fafcae351e3d444a5c7" - resolved-ref: "84a139a25ab1691762002fafcae351e3d444a5c7" + ref: e017e62766908e9714c5309761183e8be8b37799 + resolved-ref: e017e62766908e9714c5309761183e8be8b37799 url: "https://github.com/cypherstack/flutter_libsparkmobile.git" source: git version: "0.1.0" @@ -987,26 +1076,26 @@ packages: dependency: "direct main" description: name: flutter_mwebd - sha256: c5d1f628a037a12cd558c3c37fec46438c4d8d07e108a7f7cc8969806de13993 + sha256: "14f2a331b2621b78ddf62081ca8a466f6a2b4352a66950fffd68615c14e63edf" url: "https://pub.dev" source: hosted - version: "0.0.1-pre.8" + version: "0.0.1-pre.11" flutter_native_splash: dependency: "direct main" description: name: flutter_native_splash - sha256: "17d9671396fb8ec45ad10f4a975eb8a0f70bedf0fdaf0720b31ea9de6da8c4da" + sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002" url: "https://pub.dev" source: hosted - version: "2.3.7" + version: "2.4.7" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: b0694b7fb1689b0e6cc193b3f1fcac6423c4f93c74fb20b806c6b6f196db0c31 + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 url: "https://pub.dev" source: hosted - version: "2.0.30" + version: "2.0.33" flutter_riverpod: dependency: "direct main" description: @@ -1019,10 +1108,10 @@ packages: dependency: transitive description: name: flutter_rust_bridge - sha256: "5a5c7a5deeef2cc2ffe6076a33b0429f4a20ceac22a397297aed2b1eb067e611" + sha256: e87d6b9ee934dcd24a128ccb2bd91905d2d5fe5c06245d6a8f5477d4907a437a url: "https://pub.dev" source: hosted - version: "2.9.0" + version: "2.12.0" flutter_secure_storage: dependency: "direct main" description: @@ -1075,10 +1164,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678 + sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.4" flutter_test: dependency: "direct dev" description: flutter @@ -1089,14 +1178,22 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed: + dependency: "direct overridden" + description: + name: freezed + sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131 + url: "https://pub.dev" + source: hosted + version: "3.2.5" freezed_annotation: - dependency: transitive + dependency: "direct overridden" description: name: freezed_annotation - sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" url: "https://pub.dev" source: hosted - version: "2.4.4" + version: "3.1.0" frontend_server_client: dependency: transitive description: @@ -1111,7 +1208,7 @@ packages: path: "crypto_plugins/frostdart" relative: true source: path - version: "0.0.1" + version: "0.2.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -1121,8 +1218,8 @@ packages: dependency: "direct main" description: path: "." - ref: "540d0bc7dc27a97d45d63f412f26818a7f3b8b51" - resolved-ref: "540d0bc7dc27a97d45d63f412f26818a7f3b8b51" + ref: "14427bcbbe1e754bce4a1b93cdb0a31ce56d792b" + resolved-ref: "14427bcbbe1e754bce4a1b93cdb0a31ce56d792b" url: "https://github.com/cypherstack/fusiondart.git" source: git version: "1.0.0" @@ -1138,10 +1235,10 @@ packages: dependency: "direct main" description: name: google_fonts - sha256: "2776c66b3e97c6cdd58d1bd3281548b074b64f1fd5c8f82391f7456e38849567" + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 url: "https://pub.dev" source: hosted - version: "4.0.5" + version: "6.3.3" google_identity_services_web: dependency: transitive description: @@ -1170,10 +1267,10 @@ packages: dependency: transitive description: name: grpc - sha256: "2dde469ddd8bbd7a33a0765da417abe1ad2142813efce3a86c512041294e2b26" + sha256: "15227eeed339bd0ef5afe515cb791b2e4bec0711ab56f37cc44257bcfaedc4bf" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.2.0" hex: dependency: "direct main" description: @@ -1194,18 +1291,18 @@ packages: dependency: "direct main" description: name: hive_ce - sha256: "89746b555109029a30780e0a601978460b8065643592667f6e43a238faccb8a4" + sha256: "8e9980e68643afb1e765d3af32b47996552a64e190d03faf622cea07c1294418" url: "https://pub.dev" source: hosted - version: "2.13.2" + version: "2.19.3" hive_ce_flutter: dependency: "direct main" description: name: hive_ce_flutter - sha256: f5bd57fda84402bca7557fedb8c629c96c8ea10fab4a542968d7b60864ca02cc + sha256: "2677e95a333ff15af43ccd06af7eb7abbf1a4f154ea071997f3de4346cae913a" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.4" hive_ce_generator: dependency: "direct dev" description: @@ -1222,8 +1319,16 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" - html: + hooks: dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + html: + dependency: "direct main" description: name: html sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" @@ -1234,10 +1339,10 @@ packages: dependency: "direct main" description: name: http - sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "0.13.6" + version: "1.6.0" http2: dependency: transitive description: @@ -1262,22 +1367,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - ieee754: - dependency: transitive - description: - name: ieee754 - sha256: "7d87451c164a56c156180d34a4e93779372edd191d2c219206100b976203128c" - url: "https://pub.dev" - source: hosted - version: "1.0.3" image: dependency: "direct main" description: name: image - sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.8.0" import_sorter: dependency: "direct dev" description: @@ -1295,10 +1392,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "910f85bce16fb5c6f614e117efa303e85a1731bb0081edf3604a2ae6e9a3cc91" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.17.0" + version: "0.19.0" io: dependency: transitive description: @@ -1335,10 +1432,10 @@ packages: dependency: transitive description: name: isolate_channel - sha256: f3d36f783b301e6b312c3450eeb2656b0e7d1db81331af2a151d9083a3f6b18d + sha256: a9d3d620695bc984244dafae00b95e4319d6974b2d77f4b9e1eb4f2efe099094 url: "https://pub.dev" source: hosted - version: "0.2.2+1" + version: "0.6.1" js: dependency: transitive description: @@ -1351,26 +1448,18 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.12.0" json_rpc_2: - dependency: transitive + dependency: "direct overridden" description: name: json_rpc_2 - sha256: "246b321532f0e8e2ba474b4d757eaa558ae4fdd0688fdbc1e1ca9705f9b8ca0e" + sha256: "82dfd37d3b2e5030ae4729e1d7f5538cbc45eb1c73d618b9272931facac3bec1" url: "https://pub.dev" source: hosted - version: "3.0.3" - json_serializable: - dependency: transitive - description: - name: json_serializable - sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe" - url: "https://pub.dev" - source: hosted - version: "6.11.1" + version: "4.1.0" jsontool: dependency: transitive description: @@ -1431,26 +1520,26 @@ packages: dependency: transitive description: name: local_auth_android - sha256: "48924f4a8b3cc45994ad5993e2e232d3b00788a305c1bf1c7db32cef281ce9a3" + sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467 url: "https://pub.dev" source: hosted - version: "1.0.52" + version: "1.0.56" local_auth_darwin: dependency: transitive description: name: local_auth_darwin - sha256: "0e9706a8543a4a2eee60346294d6a633dd7c3ee60fae6b752570457c4ff32055" + sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49" url: "https://pub.dev" source: hosted - version: "1.6.0" + version: "1.6.1" local_auth_platform_interface: dependency: transitive description: name: local_auth_platform_interface - sha256: "1b842ff177a7068442eae093b64abe3592f816afd2a533c0ebcdbe40f9d2075a" + sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122 url: "https://pub.dev" source: hosted - version: "1.0.10" + version: "1.1.0" local_auth_windows: dependency: transitive description: @@ -1480,26 +1569,26 @@ packages: dependency: "direct main" description: name: lottie - sha256: a93542cc2d60a7057255405f62252533f8e8956e7e06754955669fd32fb4b216 + sha256: "8ae0be46dbd9e19641791dc12ee480d34e1fd3f84c749adc05f3ad9342b71b95" url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "3.3.2" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" memoize: dependency: transitive description: @@ -1512,10 +1601,10 @@ packages: dependency: "direct main" description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1524,14 +1613,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.6" - mobile_scanner: + mobile_app_privacy: dependency: "direct main" description: - name: mobile_scanner - sha256: "5e7e09d904dc01de071b79b3f3789b302b0ed3c9c963109cd3f83ad90de62ecf" - url: "https://pub.dev" - source: hosted - version: "7.1.2" + path: "." + ref: "v0.0.3" + resolved-ref: a949b6e79aa2c97af9d339690067800a5c5eb89e + url: "https://github.com/cypherstack/mobile_app_privacy" + source: git + version: "0.0.3" mockingjay: dependency: "direct dev" description: @@ -1544,10 +1634,10 @@ packages: dependency: "direct dev" description: name: mockito - sha256: "4feb43bc4eb6c03e832f5fcd637d1abb44b98f9cfa245c58e27382f58859f8f6" + sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566 url: "https://pub.dev" source: hosted - version: "5.5.1" + version: "5.6.3" mocktail: dependency: transitive description: @@ -1584,19 +1674,28 @@ packages: dependency: "direct main" description: path: "." - ref: "819b21164ef93cc0889049d4a8a1be2d0cc36a1b" - resolved-ref: "819b21164ef93cc0889049d4a8a1be2d0cc36a1b" - url: "https://github.com/Cyrix126/namecoin_dart" + ref: "73a29731ba493595fed331d92c7a4b5604fd6e23" + resolved-ref: "73a29731ba493595fed331d92c7a4b5604fd6e23" + url: "https://github.com/cypherstack/namecoin_dart" source: git - version: "2.0.0" + version: "2.0.1" nanodart: dependency: "direct main" description: - name: nanodart - sha256: "4b2f42d60307b54e8cf384d6193a567d07f8efd773858c0d5948246153c13282" + path: "." + ref: "1d3f30c8abd36d352a8b3147426308b77c77484e" + resolved-ref: "1d3f30c8abd36d352a8b3147426308b77c77484e" + url: "https://github.com/cypherstack/nanodart" + source: git + version: "2.0.1" + native_toolchain_cmake: + dependency: transitive + description: + name: native_toolchain_cmake + sha256: "8ba223410102665483e873b83d2156720233a6a0181c07f9dc41cb2e961336b0" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "0.3.2" nm: dependency: transitive description: @@ -1673,18 +1772,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e url: "https://pub.dev" source: hosted - version: "2.2.18" + version: "2.2.22" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.5.1" path_provider_linux: dependency: transitive description: @@ -1761,12 +1860,12 @@ packages: dependency: transitive description: name: petitparser - sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "7.0.2" pinenacl: - dependency: "direct overridden" + dependency: transitive description: name: pinenacl sha256: "57e907beaacbc3c024a098910b6240758e899674de07d6949a67b52fd984cbdf" @@ -1793,10 +1892,10 @@ packages: dependency: "direct main" description: name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" url: "https://pub.dev" source: hosted - version: "3.9.1" + version: "4.0.0" pool: dependency: transitive description: @@ -1805,6 +1904,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" pretty_dio_logger: dependency: transitive description: @@ -1817,10 +1924,10 @@ packages: dependency: transitive description: name: process - sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "5.0.5" protobuf: dependency: transitive description: @@ -1853,6 +1960,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + qr_code_scanner_plus: + dependency: "direct main" + description: + name: qr_code_scanner_plus + sha256: dae0596b2763c2fd0294f5cfddb1d3a21577ae4dc7fc1449eb5aafc957872f61 + url: "https://pub.dev" + source: hosted + version: "2.1.1" qr_flutter: dependency: "direct main" description: @@ -1885,6 +2000,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" retry: dependency: transitive description: @@ -1901,14 +2024,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.3" + saf_stream: + dependency: "direct main" + description: + name: saf_stream + sha256: c05449997698c481a03e428162a999f93b1ee1bcc0349d651899a59f7b10230a + url: "https://pub.dev" + source: hosted + version: "0.12.3" + saf_util: + dependency: "direct main" + description: + name: saf_util + sha256: "219f983e5f17b28998335158cdc97add9d52af9884e38b5a43f10dcc070510ec" + url: "https://pub.dev" + source: hosted + version: "0.11.0" sec: dependency: transitive description: name: sec - sha256: "8bbd56df884502192a441b5f5d667265498f2f8728a282beccd9db79e215f379" + sha256: "52a93800943642e0b5225408d0973a1837e2452b9aa8a501fdfbc8e76b6ac135" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" share_plus: dependency: "direct main" description: @@ -1999,10 +2138,10 @@ packages: dependency: transitive description: name: source_helper - sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca + sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" url: "https://pub.dev" source: hosted - version: "1.3.7" + version: "1.3.8" source_map_stack_trace: dependency: transitive description: @@ -2023,10 +2162,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" sqlite3: dependency: "direct main" description: @@ -2047,10 +2186,10 @@ packages: dependency: transitive description: name: sqlparser - sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67" + sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19" url: "https://pub.dev" source: hosted - version: "0.41.2" + version: "0.43.1" stack_trace: dependency: transitive description: @@ -2080,10 +2219,10 @@ packages: dependency: "direct main" description: name: stellar_flutter_sdk - sha256: "7d505963fe11d0f90b3f798964c485ed9fa64731c38f14c9b2fb76d5d5bd6cd8" + sha256: d3a7a38e262d7d96f2650a09d15fe831ef1686cb5b2f07feebbe0e3bfceceaf5 url: "https://pub.dev" source: hosted - version: "1.8.1" + version: "2.2.2" stream_channel: dependency: "direct main" description: @@ -2136,32 +2275,32 @@ packages: dependency: transitive description: name: test - sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.26.2" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.11" + version: "0.6.17" tezart: dependency: "direct main" description: path: "." - ref: d000cc245e51d3ff50e6467960fb3d9159d5b2a9 - resolved-ref: d000cc245e51d3ff50e6467960fb3d9159d5b2a9 + ref: "84c563104f1a19c26e49bafccb7da404b210b666" + resolved-ref: "84c563104f1a19c26e49bafccb7da404b210b666" url: "https://github.com/cypherstack/tezart.git" source: git version: "2.0.5" @@ -2169,10 +2308,10 @@ packages: dependency: transitive description: name: time - sha256: "370572cf5d1e58adcb3e354c47515da3f7469dac3a95b447117e728e7be6f461" + sha256: "46187cf30bffdab28c56be9a63861b36e4ab7347bf403297595d6a97e10c789f" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" timezone: dependency: transitive description: @@ -2201,10 +2340,10 @@ packages: dependency: transitive description: name: toml - sha256: "69756bc12eccf279b72217a87310d217efc4b3752f722e890f672801f19ac485" + sha256: "35cd2a1351c14bd213f130f8efcbd3e0c18181bff0c8ca7a08f6822a2bede786" url: "https://pub.dev" source: hosted - version: "0.13.1" + version: "0.17.0" tor_ffi_plugin: dependency: "direct main" description: @@ -2234,18 +2373,18 @@ packages: dependency: transitive description: name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.3.1" unorm_dart: - dependency: transitive + dependency: "direct main" description: name: unorm_dart - sha256: "5b35bff83fce4d76467641438f9e867dc9bcfdb8c1694854f230579d68cd8f4b" + sha256: "0c69186b03ca6addab0774bcc0f4f17b88d4ce78d9d4d8f0619e30a99ead58e7" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.3.2" url_launcher: dependency: "direct main" description: @@ -2258,34 +2397,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e" + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" url: "https://pub.dev" source: hosted - version: "6.3.20" + version: "6.3.28" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.3.4" + version: "6.4.1" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -2298,34 +2437,34 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" uuid: dependency: "direct main" description: name: uuid - sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "4.5.3" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + sha256: "7076216a10d5c390315fbe536a30f1254c341e7543e6c4c8a815e591307772b1" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.1.20" vector_graphics_codec: dependency: transitive description: @@ -2338,10 +2477,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.0" vector_math: dependency: transitive description: @@ -2354,18 +2493,18 @@ packages: dependency: transitive description: name: very_good_analysis - sha256: "62d2b86d183fb81b2edc22913d9f155d26eb5cf3855173adb1f59fac85035c63" + sha256: "481af67ab5877af20325251dc215a4ebac7666a1c8cf09198ffd457bc612b33d" url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "10.3.0" vm_service: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "14.3.1" + version: "15.0.2" wakelock_platform_interface: dependency: transitive description: @@ -2386,10 +2525,10 @@ packages: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" + sha256: "24b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" wakelock_windows: dependency: "direct overridden" description: @@ -2400,13 +2539,13 @@ packages: source: git version: "0.2.2" wallet: - dependency: transitive + dependency: "direct main" description: name: wallet - sha256: "687fd89a16557649b26189e597792962f405797fc64113e8758eabc2c2605c32" + sha256: "20b6d8440039726841bd23b2bac64f888ec1ce1509edcc3ed2ad1753f613521e" url: "https://pub.dev" source: hosted - version: "0.0.13" + version: "0.0.18" wasm_interop: dependency: transitive description: @@ -2419,10 +2558,10 @@ packages: dependency: transitive description: name: watcher - sha256: "5bf046f41320ac97a469d506261797f35254fa61c641741ef32dacda98b7d39c" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.2.1" web: dependency: "direct overridden" description: @@ -2435,10 +2574,10 @@ packages: dependency: "direct main" description: name: web3dart - sha256: "0b96223a6b284e3146e65dc842ded139eca68a85c4ab79c5ba1a73284927d3cd" + sha256: bde2c92aac6f086988b6a1935c9d884f42a6acb772c93e1e2810f64af0db5600 url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "3.0.1" web_socket_channel: dependency: "direct main" description: @@ -2451,18 +2590,18 @@ packages: dependency: transitive description: name: web_socket_client - sha256: "0ec5230852349191188c013112e4d2be03e3fc83dbe80139ead9bf3a136e53b5" + sha256: "394789177aa3bc1b7b071622a1dbf52a4631d7ce23c555c39bb2523e92316b07" url: "https://pub.dev" source: hosted - version: "0.1.5" + version: "0.2.1" webdriver: dependency: transitive description: name: webdriver - sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" url: "https://pub.dev" source: hosted - version: "3.0.4" + version: "3.1.0" webkit_inspection_protocol: dependency: transitive description: @@ -2475,10 +2614,10 @@ packages: dependency: "direct overridden" description: name: win32 - sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "5.14.0" + version: "5.15.0" win32_registry: dependency: transitive description: @@ -2505,30 +2644,30 @@ packages: source: hosted version: "1.1.0" xelis_dart_sdk: - dependency: transitive + dependency: "direct main" description: name: xelis_dart_sdk - sha256: "2a7f8ab4c30fad2fd824ba6ea4e83ac20c726b47c7aa4f1e713ef3971a3ec1f7" + sha256: f185d7f81f194979e36c6ec5a2b33b342b4b76d3348446081c9597c0d40d89ec url: "https://pub.dev" source: hosted - version: "0.24.0" + version: "0.35.1" xelis_flutter: dependency: "direct main" description: path: "." - ref: "5dd5c50713160fa15fb06ff44886ae035eed62fd" - resolved-ref: "5dd5c50713160fa15fb06ff44886ae035eed62fd" - url: "https://github.com/cypherstack/xelis-flutter-ffi.git" + ref: "3e5ac06c22956a9113a88a2d38ba82f8787be1c6" + resolved-ref: "3e5ac06c22956a9113a88a2d38ba82f8787be1c6" + url: "https://github.com/xelis-project/xelis-flutter-ffi.git" source: git - version: "0.1.1" + version: "0.2.0" xml: dependency: transitive description: name: xml - sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.6.1" xxh3: dependency: transitive description: @@ -2570,5 +2709,5 @@ packages: source: hosted version: "0.2.4" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.29.0 <4.0.0" + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.9 <4.0.0" diff --git a/scripts/android/build_all.sh b/scripts/android/build_all.sh index dc904e95dd..1ee7c10cf7 100755 --- a/scripts/android/build_all.sh +++ b/scripts/android/build_all.sh @@ -2,20 +2,27 @@ set -x -e +APP="${1:-stack_wallet}" + mkdir -p build . ./config.sh PLUGINS_DIR=../../crypto_plugins -# libepiccash requires old rust source ../rust_version.sh -set_rust_version_for_libepiccash -(cd "${PLUGINS_DIR}"/flutter_libepiccash/scripts/android && ./build_all.sh ) -(cd "${PLUGINS_DIR}"/flutter_libmwc/scripts/android && ./build_all.sh ) -# set rust (back) to a more recent stable release after building epiccash + +if [[ "$APP" = "stack_wallet" ]]; then + set_rust_version_for_libepiccash + (cd "${PLUGINS_DIR}"/flutter_libepiccash/scripts/android && ./build_all.sh ) + set_rust_version_for_libmwc + (cd "${PLUGINS_DIR}"/flutter_libmwc/scripts/android && ./build_all.sh ) +fi + set_rust_to_everything_else -(cd "${PLUGINS_DIR}"/frostdart/scripts/android && ./build_all.sh ) +if [[ "$APP" = "stack_wallet" || "$APP" = "stack_duo" ]]; then + (cd "${PLUGINS_DIR}"/frostdart/scripts/android && ./build_all.sh ) +fi wait echo "Done building" diff --git a/scripts/android/build_all_campfire.sh b/scripts/android/build_all_campfire.sh deleted file mode 100755 index fd10e418fa..0000000000 --- a/scripts/android/build_all_campfire.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash - -set -x -e - -mkdir -p build -. ./config.sh - -PLUGINS_DIR=../../crypto_plugins - -# libepiccash requires old rust -source ../rust_version.sh -set_rust_to_everything_else - -wait -echo "Done building" diff --git a/scripts/android/build_all_duo.sh b/scripts/android/build_all_duo.sh deleted file mode 100755 index dcfc24427a..0000000000 --- a/scripts/android/build_all_duo.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash - -set -x -e - -# todo: revisit following at some point - -mkdir -p build -. ./config.sh - -PLUGINS_DIR=../../crypto_plugins - -source ../rust_version.sh -set_rust_to_everything_else - -(cd "${PLUGINS_DIR}"/frostdart/scripts/android && ./build_all.sh ) - -wait -echo "Done building" diff --git a/scripts/android/download_all.sh b/scripts/android/download_all.sh new file mode 100755 index 0000000000..34c708c0b8 --- /dev/null +++ b/scripts/android/download_all.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -x -e + +APP="${1:-stack_wallet}" + +mkdir -p build +. ./config.sh + +PLUGINS_DIR=../../crypto_plugins + +if [[ "$APP" = "stack_wallet" ]]; then + (cd "${PLUGINS_DIR}"/flutter_libepiccash/scripts/android && ./download.sh) + (cd "${PLUGINS_DIR}"/flutter_libmwc/scripts/android && ./download.sh) +fi + +if [[ "$APP" = "stack_wallet" || "$APP" = "stack_duo" ]]; then + (cd "${PLUGINS_DIR}"/frostdart/scripts/android && ./download.sh) +fi + +wait +echo "Done" diff --git a/scripts/app_config/configure_campfire.sh b/scripts/app_config/configure_campfire.sh index 04054b9ec4..e12697b35e 100755 --- a/scripts/app_config/configure_campfire.sh +++ b/scripts/app_config/configure_campfire.sh @@ -63,6 +63,8 @@ const _appDataDirName = "campfire"; const _shortDescriptionText = "Your privacy. Your wallet. Your Firo."; const _commitHash = "$BUILT_COMMIT_HASH"; +const _mwebdExeHash = ""; + const Set _features = { AppFeature.tor, AppFeature.swap diff --git a/scripts/app_config/configure_stack_duo.sh b/scripts/app_config/configure_stack_duo.sh index 148775aa3e..7d6bae012d 100755 --- a/scripts/app_config/configure_stack_duo.sh +++ b/scripts/app_config/configure_stack_duo.sh @@ -14,14 +14,11 @@ NEW_PUBSPEC_NAME="stackduo" PUBSPEC_FILE="${APP_PROJECT_ROOT_DIR}/pubspec.yaml" # String replacements. -if [[ "$(uname)" == 'Darwin' ]]; then - # macos specific sed - sed -i '' "s/name: PLACEHOLDER/name: ${NEW_PUBSPEC_NAME}/g" "${PUBSPEC_FILE}" - sed -i '' "s/description: PLACEHOLDER/description: ${NEW_NAME}/g" "${PUBSPEC_FILE}" -else - sed -i "s/name: PLACEHOLDER/name: ${NEW_PUBSPEC_NAME}/g" "${PUBSPEC_FILE}" - sed -i "s/description: PLACEHOLDER/description: ${NEW_NAME}/g" "${PUBSPEC_FILE}" -fi +sed -i.bak \ + -e "s/name: PLACEHOLDER/name: ${NEW_PUBSPEC_NAME}/g" \ + -e "s/description: PLACEHOLDER/description: ${NEW_NAME}/g" \ + "${PUBSPEC_FILE}" +rm -f "${PUBSPEC_FILE}.bak" dart "${APP_PROJECT_ROOT_DIR}/tool/process_pubspec_deps.dart" \ "${PUBSPEC_FILE}" \ @@ -59,10 +56,14 @@ const _appDataDirName = "stackduo"; const _shortDescriptionText = "An open-source, multicoin wallet for everyone"; const _commitHash = "$BUILT_COMMIT_HASH"; +const _mwebdExeHash = ""; + const Set _features = { AppFeature.themeSelection, AppFeature.buy, AppFeature.tor, + AppFeature.shopinBit, + AppFeature.cakePay, AppFeature.swap }; diff --git a/scripts/app_config/configure_stack_wallet.sh b/scripts/app_config/configure_stack_wallet.sh index cbcead4841..bf3d6c6621 100755 --- a/scripts/app_config/configure_stack_wallet.sh +++ b/scripts/app_config/configure_stack_wallet.sh @@ -14,20 +14,18 @@ NEW_PUBSPEC_NAME="stackwallet" PUBSPEC_FILE="${APP_PROJECT_ROOT_DIR}/pubspec.yaml" # String replacements. -if [[ "$(uname)" == 'Darwin' ]]; then - # macos specific sed - sed -i '' "s/name: PLACEHOLDER/name: ${NEW_PUBSPEC_NAME}/g" "${PUBSPEC_FILE}" - sed -i '' "s/description: PLACEHOLDER/description: ${NEW_NAME}/g" "${PUBSPEC_FILE}" -else - sed -i "s/name: PLACEHOLDER/name: ${NEW_PUBSPEC_NAME}/g" "${PUBSPEC_FILE}" - sed -i "s/description: PLACEHOLDER/description: ${NEW_NAME}/g" "${PUBSPEC_FILE}" -fi +sed -i.bak \ + -e "s/name: PLACEHOLDER/name: ${NEW_PUBSPEC_NAME}/g" \ + -e "s/description: PLACEHOLDER/description: ${NEW_NAME}/g" \ + "${PUBSPEC_FILE}" +rm -f "${PUBSPEC_FILE}.bak" dart "${APP_PROJECT_ROOT_DIR}/tool/process_pubspec_deps.dart" \ "${PUBSPEC_FILE}" \ MWC \ MWEBD \ XMR \ + WOW \ SAL \ TOR \ EPIC \ @@ -41,6 +39,7 @@ dart "${APP_PROJECT_ROOT_DIR}/tool/gen_interfaces.dart" \ MWC \ MWEBD \ XMR \ + WOW \ SAL \ TOR \ EPIC \ @@ -48,6 +47,20 @@ dart "${APP_PROJECT_ROOT_DIR}/tool/gen_interfaces.dart" \ XEL \ FROST + +MWEBD_EXE_SHA256="" +if [[ "$1" == "windows" ]]; then + if [[ "${MWEBD_FETCH:-0}" == "1" ]]; then + dart "${APP_PROJECT_ROOT_DIR}/tool/build_standalone_mwebd_windows.dart" --fetch + else + dart "${APP_PROJECT_ROOT_DIR}/tool/build_standalone_mwebd_windows.dart" + fi + MWEBD_EXE_SHA256="$(sha256sum "${APP_PROJECT_ROOT_DIR}/assets/windows/mwebd.exe" | awk '{print $1}')" + dart "${APP_PROJECT_ROOT_DIR}/tool/process_pubspec_deps.dart" \ + "${PUBSPEC_FILE}" MWEBDEXE +fi + + export INCLUDE_EPIC_SO="ON" export INCLUDE_MWC_SO="ON" @@ -71,10 +84,14 @@ const _appDataDirName = "stackwallet"; const _shortDescriptionText = "An open-source, multicoin wallet for everyone"; const _commitHash = "$BUILT_COMMIT_HASH"; +const _mwebdExeHash = "$MWEBD_EXE_SHA256"; + const Set _features = { AppFeature.themeSelection, AppFeature.buy, AppFeature.tor, + AppFeature.shopinBit, + AppFeature.cakePay, AppFeature.swap }; @@ -91,11 +108,11 @@ final List _supportedCoins = List.unmodifiable([ Dogecoin(CryptoCurrencyNetwork.main), Ecash(CryptoCurrencyNetwork.main), Epiccash(CryptoCurrencyNetwork.main), - if (!Platform.isMacOS) Mimblewimblecoin(CryptoCurrencyNetwork.main), Ethereum(CryptoCurrencyNetwork.main), Fact0rn(CryptoCurrencyNetwork.main), Firo(CryptoCurrencyNetwork.main), Litecoin(CryptoCurrencyNetwork.main), + if (!Platform.isMacOS) Mimblewimblecoin(CryptoCurrencyNetwork.main), Nano(CryptoCurrencyNetwork.main), Namecoin(CryptoCurrencyNetwork.main), Particl(CryptoCurrencyNetwork.main), diff --git a/scripts/app_config/platforms/linux/platform_config.sh b/scripts/app_config/platforms/linux/platform_config.sh index 61dfdfb8bd..72145787e3 100755 --- a/scripts/app_config/platforms/linux/platform_config.sh +++ b/scripts/app_config/platforms/linux/platform_config.sh @@ -14,6 +14,7 @@ done # Configure Linux sed -i "s/${APP_BASIC_NAME_PLACEHOLDER}/${NEW_BASIC_NAME}/g" "${APP_PROJECT_ROOT_DIR}/${LINUX_TF_0}" +sed -i "s/${APP_ID_PLACEHOLDER}/${NEW_APP_ID}/g" "${APP_PROJECT_ROOT_DIR}/${LINUX_TF_0}" sed -i "s/${APP_NAME_PLACEHOLDER}/${NEW_NAME}/g" "${APP_PROJECT_ROOT_DIR}/${LINUX_TF_1}" sed -i "s/INCLUDE_EPIC_SO_FLAG/${INCLUDE_EPIC_SO}/g" "${APP_PROJECT_ROOT_DIR}/${LINUX_TF_0}" sed -i "s/INCLUDE_MWC_SO_FLAG/${INCLUDE_MWC_SO}/g" "${APP_PROJECT_ROOT_DIR}/${LINUX_TF_0}" \ No newline at end of file diff --git a/scripts/app_config/platforms/macos/platform_config.sh b/scripts/app_config/platforms/macos/platform_config.sh index c54ba32a6e..dd71bc1178 100755 --- a/scripts/app_config/platforms/macos/platform_config.sh +++ b/scripts/app_config/platforms/macos/platform_config.sh @@ -13,8 +13,17 @@ for (( i=0; i<=2; i++ )); do done # Configure macOS for Duo. -sed -i '' "s/${APP_ID_PLACEHOLDER_CAMEL}/${NEW_APP_ID_CAMEL}/g" "${APP_PROJECT_ROOT_DIR}/${MAC_TF_0}" -sed -i '' "s/${APP_NAME_PLACEHOLDER}/${NEW_NAME}/g" "${APP_PROJECT_ROOT_DIR}/${MAC_TF_0}" -sed -i '' "s/${APP_NAME_PLACEHOLDER}/${NEW_NAME}/g" "${APP_PROJECT_ROOT_DIR}/${MAC_TF_1}" -sed -i '' "s/${APP_NAME_PLACEHOLDER}/${NEW_NAME}/g" "${APP_PROJECT_ROOT_DIR}/${MAC_TF_2}" -sed -i '' "s/${APP_ID_PLACEHOLDER_SNAKE}/${NEW_APP_ID_SNAKE}/g" "${APP_PROJECT_ROOT_DIR}/${MAC_TF_2}" +sed -i.bak \ + -e "s/${APP_ID_PLACEHOLDER_CAMEL}/${NEW_APP_ID_CAMEL}/g" \ + -e "s/${APP_NAME_PLACEHOLDER}/${NEW_NAME}/g" \ + "${APP_PROJECT_ROOT_DIR}/${MAC_TF_0}" +rm -f "${APP_PROJECT_ROOT_DIR}/${MAC_TF_0}.bak" + +sed -i.bak "s/${APP_NAME_PLACEHOLDER}/${NEW_NAME}/g" "${APP_PROJECT_ROOT_DIR}/${MAC_TF_1}" +rm -f "${APP_PROJECT_ROOT_DIR}/${MAC_TF_1}.bak" + +sed -i.bak \ + -e "s/${APP_NAME_PLACEHOLDER}/${NEW_NAME}/g" \ + -e "s/${APP_ID_PLACEHOLDER_SNAKE}/${NEW_APP_ID_SNAKE}/g" \ + "${APP_PROJECT_ROOT_DIR}/${MAC_TF_2}" +rm -f "${APP_PROJECT_ROOT_DIR}/${MAC_TF_2}.bak" diff --git a/scripts/app_config/shared/asset_generators.sh b/scripts/app_config/shared/asset_generators.sh index 50d035a657..a2d9487585 100755 --- a/scripts/app_config/shared/asset_generators.sh +++ b/scripts/app_config/shared/asset_generators.sh @@ -14,14 +14,20 @@ pushd "${APP_PROJECT_ROOT_DIR}" YAML_FILE="${APP_PROJECT_ROOT_DIR}/scripts/app_config/platforms/${APP_BUILD_PLATFORM}/flutter_launcher_icons.yaml" if [[ "${APP_BUILD_PLATFORM}" = 'windows' ]]; then cmd.exe /c flutter pub get - WIN_PATH_VERSION=$(wslpath -w ${YAML_FILE}) + if command -v cygpath >/dev/null 2>&1; then + WIN_PATH_VERSION=$(cygpath -w "${YAML_FILE}") + else + WIN_PATH_VERSION=$(wslpath -w "${YAML_FILE}") + fi cmd.exe /c dart run flutter_launcher_icons -f "${WIN_PATH_VERSION}" - #native splash screen not used - #cmd.exe /c dart run flutter_native_splash:create + # not needed in windows +# cmd.exe /c dart run flutter_native_splash:create else flutter pub get dart run flutter_launcher_icons -f "${YAML_FILE}" - #native splash screen not used - #dart run flutter_native_splash:create + + if [[ "${APP_BUILD_PLATFORM}" = 'ios' || "${APP_BUILD_PLATFORM}" = 'android' ]]; then + dart run flutter_native_splash:create + fi fi popd \ No newline at end of file diff --git a/scripts/app_config/shared/link_assets.sh b/scripts/app_config/shared/link_assets.sh index 25c016f9c5..9c7147e3ed 100755 --- a/scripts/app_config/shared/link_assets.sh +++ b/scripts/app_config/shared/link_assets.sh @@ -23,8 +23,13 @@ for dirname in "default_themes" "icon" "lottie" "in_app_logo_icons" "svg"; do rm -f "${ASSETS_DIR}/${dirname}" if [[ "${APP_BUILD_PLATFORM}" = 'windows' ]]; then - LINK_SOURCE_DIR_WIN_PATH_VERSION=$(wslpath -w "${LINK_SOURCE_DIR}") - LINK_NAME_WIN_PATH_VERSION=$(wslpath -w "${ASSETS_DIR}") + if command -v cygpath >/dev/null 2>&1; then + LINK_SOURCE_DIR_WIN_PATH_VERSION=$(cygpath -w "${LINK_SOURCE_DIR}") + LINK_NAME_WIN_PATH_VERSION=$(cygpath -w "${ASSETS_DIR}") + else + LINK_SOURCE_DIR_WIN_PATH_VERSION=$(wslpath -w "${LINK_SOURCE_DIR}") + LINK_NAME_WIN_PATH_VERSION=$(wslpath -w "${ASSETS_DIR}") + fi cmd.exe /c mklink /D "${LINK_NAME_WIN_PATH_VERSION}\\${dirname}" "${LINK_SOURCE_DIR_WIN_PATH_VERSION}" else ln -s "${LINK_SOURCE_DIR}" "${ASSETS_DIR}/${dirname}" diff --git a/scripts/app_config/shared/update_version.sh b/scripts/app_config/shared/update_version.sh index d056b9b738..11c1cbd8c9 100755 --- a/scripts/app_config/shared/update_version.sh +++ b/scripts/app_config/shared/update_version.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -x -e @@ -34,13 +34,10 @@ if [ ! -f "$PUBSPEC_FILE" ]; then exit 1 fi -if [[ "$(uname)" == 'Darwin' ]]; then - # macos specific sed - sed -i '' "s/PLACEHOLDER_V/$VERSION/g" "${PUBSPEC_FILE}" - sed -i '' "s/PLACEHOLDER_B/$BUILD_NUMBER/g" "${PUBSPEC_FILE}" -else - sed -i "s/PLACEHOLDER_V/$VERSION/g" "${PUBSPEC_FILE}" - sed -i "s/PLACEHOLDER_B/$BUILD_NUMBER/g" "${PUBSPEC_FILE}" -fi +sed -i.bak \ + -e "s/PLACEHOLDER_V/$VERSION/g" \ + -e "s/PLACEHOLDER_B/$BUILD_NUMBER/g" \ + "${PUBSPEC_FILE}" +rm -f "${PUBSPEC_FILE}.bak" echo "Updated $PUBSPEC_FILE with version: $VERSION and build number: $BUILD_NUMBER" diff --git a/scripts/app_config/templates/android/app/build.gradle b/scripts/app_config/templates/android/app/build.gradle index dc1cb233a9..8fee783991 100644 --- a/scripts/app_config/templates/android/app/build.gradle +++ b/scripts/app_config/templates/android/app/build.gradle @@ -15,7 +15,7 @@ android { namespace "com.place.holder" compileSdk flutter.compileSdkVersion // ndkVersion flutter.ndkVersion - ndkVersion = "28.0.13004108" + ndkVersion = "28.2.13676358" packagingOptions { pickFirst 'lib/x86/libc++_shared.so' @@ -45,9 +45,9 @@ android { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") } - ndk { - abiFilters "x86_64","armeabi-v7a", "arm64-v8a" - } + // No ndk.abiFilters here: AGP rejects it alongside the abi splits set + // up by `flutter build apk --split-per-abi`. Flutter defaults to + // android-arm,android-arm64,android-x64 which is the same set we want. // externalNativeBuild { // cmake { diff --git a/scripts/app_config/templates/configure_template_files.sh b/scripts/app_config/templates/configure_template_files.sh index b4731683da..24a4195cf8 100755 --- a/scripts/app_config/templates/configure_template_files.sh +++ b/scripts/app_config/templates/configure_template_files.sh @@ -65,4 +65,9 @@ for TF in "${TEMPLATE_FILES[@]}"; do rm "${FILE}" fi cp -rp "${TEMPLATES_DIR}/${TF}" "${FILE}" -done \ No newline at end of file +done + +if [ "$BUILD_ISAR_FROM_SOURCE" -eq 1 ]; then + source "${APP_PROJECT_ROOT_DIR}/scripts/app_config/templates/isar_build.sh" + build_isar_source +fi diff --git a/scripts/app_config/templates/ios/Runner.xcodeproj/project.pbxproj b/scripts/app_config/templates/ios/Runner.xcodeproj/project.pbxproj index febbbb8af7..d03d3a71c0 100644 --- a/scripts/app_config/templates/ios/Runner.xcodeproj/project.pbxproj +++ b/scripts/app_config/templates/ios/Runner.xcodeproj/project.pbxproj @@ -456,6 +456,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 4DQKUWSG6C; ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "x86_64"; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -632,6 +633,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 4DQKUWSG6C; ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "x86_64"; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -700,6 +702,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 4DQKUWSG6C; ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "x86_64"; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", diff --git a/scripts/app_config/templates/ios/Runner/Info.plist b/scripts/app_config/templates/ios/Runner/Info.plist index ea8af9f9fb..9201a4c02c 100644 --- a/scripts/app_config/templates/ios/Runner/Info.plist +++ b/scripts/app_config/templates/ios/Runner/Info.plist @@ -35,7 +35,7 @@ NSFaceIDUsageDescription This app requires Face ID permissions so that the user can securely lock their wallet if their device uses Face ID. It will be useful feature for all users, and especially those prone to forget their login pin on iPhones where Touch ID is not available. NSPhotoLibraryUsageDescription - Photo Library Access Warning + This app only reads images you select, such as a picture containing a QR code to scan. It will never access your photo library on its own. UIFileSharingEnabled UILaunchStoryboardName diff --git a/scripts/app_config/templates/isar_build.sh b/scripts/app_config/templates/isar_build.sh new file mode 100644 index 0000000000..d1d53d7f93 --- /dev/null +++ b/scripts/app_config/templates/isar_build.sh @@ -0,0 +1,100 @@ +#!/bin/bash + +find_isar_core_lib() { + local isar_core_path + isar_core_path=$(find "${HOME}/.pub-cache/git" -type d -path "*/isar_core_ffi" -print -quit 2>/dev/null) + [[ -z "${isar_core_path}" ]] && return 1 + echo "${isar_core_path}" +} + +detect_isar_version() { + local version="unknown" + local lock_file="${APP_PROJECT_ROOT_DIR}/pubspec.lock" + [[ -f "${lock_file}" ]] && version=$(grep -A1 "isar_community:" "${lock_file}" 2>/dev/null | grep version | awk -F'"' '{print $2}' | head -n1) + echo "${version:-3.3.0-dev.2}" +} + +copy_isar_lib() { + local lib_src="$1" + local lib_dest="$2" + + if [[ ! -f "${lib_src}" ]]; then + echo "Warning: libisar.so not found at ${lib_src}" + return 1 + fi + + mkdir -p "${lib_dest}" + cp -f "${lib_src}" "${lib_dest}/" + echo "Copied libisar.so to ${lib_dest}" +} + +build_isar_core() { + local isar_core_path="$1" + local workspace_root="$2" + + echo "Building Isar core from: ${isar_core_path}" + + if [[ ! -f "${isar_core_path}/Cargo.toml" ]]; then + echo "Error: Cargo.toml not found" >&2 + return 1 + fi + + if [[ -f "${workspace_root}/target/release/libisar.so" ]] || \ + [[ -f "${workspace_root}/target/release/deps/libisar.so" ]]; then + echo "Note: libisar.so already built, skipping build step" + return 0 + fi + + (cd "${isar_core_path}" && cargo build --release) || { + echo "Error: cargo build failed for isar_core_ffi" >&2 + return 1 + } +} + +find_isar_library() { + local workspace_root="$1" + + if [[ -f "${workspace_root}/target/release/libisar.so" ]]; then + echo "${workspace_root}/target/release/libisar.so" + return 0 + fi + + if [[ -f "${workspace_root}/target/release/deps/libisar.so" ]]; then + echo "${workspace_root}/target/release/deps/libisar.so" + return 0 + fi + + echo "Error: could not produce libisar.so" >&2 + return 1 +} + +build_isar_source() { + echo "------------------------------------------------------------" + echo "Building Isar database library from source (BUILD_ISAR_FROM_SOURCE=1)" + echo "------------------------------------------------------------" + + local isar_core_path + isar_core_path=$(find_isar_core_lib) || { + echo "Error: could not locate isar_core_ffi inside ~/.pub-cache/git." + return 1 + } + + echo "Found isar_core_ffi at: ${isar_core_path}" + + local workspace_root=$(dirname $(dirname "${isar_core_path}")) + + build_isar_core "${isar_core_path}" "${workspace_root}" || return 1 + + local lib_src + lib_src=$(find_isar_library "${workspace_root}") || return 1 + + local plugin_path="${APP_PROJECT_ROOT_DIR}/linux/flutter/ephemeral/.plugin_symlinks/isar_community_flutter_libs/linux" + if [[ -d "$(dirname "${plugin_path}")" ]]; then + copy_isar_lib "${lib_src}" "${plugin_path}" || return 1 + fi + + local bundle_path="${APP_PROJECT_ROOT_DIR}/build/linux/x64/release/bundle/lib" + if [[ -d "$(dirname "${bundle_path}")" ]]; then + copy_isar_lib "${lib_src}" "${bundle_path}" || return 1 + fi +} diff --git a/scripts/app_config/templates/linux/CMakeLists.txt b/scripts/app_config/templates/linux/CMakeLists.txt index 4b0e69d628..d36efad397 100644 --- a/scripts/app_config/templates/linux/CMakeLists.txt +++ b/scripts/app_config/templates/linux/CMakeLists.txt @@ -10,7 +10,7 @@ set(BINARY_NAME "place_holder") set(APPLICATION_ID "com.place.holder") set(INCLUDE_EPIC_SO INCLUDE_EPIC_SO_FLAG) -set(INCLUDE_MWC_SO INCLUDE_EPIC_SO_FLAG) +set(INCLUDE_MWC_SO INCLUDE_MWC_SO_FLAG) # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. @@ -53,24 +53,37 @@ endfunction() set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") add_subdirectory(${FLUTTER_MANAGED_DIR}) -# build libjsoncpp and libsecret for flutter_secure_storage -set(ENV{PKG_CONFIG_PATH} "$ENV{PKG_CONFIG_PATH}:${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/jsoncpp/build/pkg-config") -set(ENV{PKG_CONFIG_PATH} "$ENV{PKG_CONFIG_PATH}:${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/pc") - -include_directories(${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/jsoncpp/include) - -include_directories(${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/libsecret) -include_directories(${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/libsecret/_build) - -add_library(jsoncpp SHARED IMPORTED) -set_target_properties(jsoncpp PROPERTIES IMPORTED_LOCATION "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/jsoncpp/build/src/lib_json/libjsoncpp.so") -add_library(secret-1 SHARED IMPORTED) -set_target_properties(secret-1 PROPERTIES IMPORTED_LOCATION "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/libsecret/_build/libsecret/libsecret-1.so") - # System-level dependencies. find_package(PkgConfig REQUIRED) pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +# jsoncpp and libsecret for flutter_secure_storage. When +# USE_SYSTEM_SECURE_STORAGE_DEPS is set, link and bundle the system-installed +# copies; otherwise use the artifacts built by scripts/linux/build_secure_storage_deps.sh. +option(USE_SYSTEM_SECURE_STORAGE_DEPS "Link against system-installed jsoncpp and libsecret" OFF) +if(DEFINED ENV{USE_SYSTEM_SECURE_STORAGE_DEPS} AND "$ENV{USE_SYSTEM_SECURE_STORAGE_DEPS}" STREQUAL "1") + set(USE_SYSTEM_SECURE_STORAGE_DEPS ON) +endif() + +if(USE_SYSTEM_SECURE_STORAGE_DEPS) + pkg_check_modules(JSONCPP REQUIRED IMPORTED_TARGET jsoncpp) + pkg_check_modules(LIBSECRET REQUIRED IMPORTED_TARGET libsecret-1) + pkg_get_variable(JSONCPP_LIBDIR jsoncpp libdir) + pkg_get_variable(LIBSECRET_LIBDIR libsecret-1 libdir) +else() + set(ENV{PKG_CONFIG_PATH} "$ENV{PKG_CONFIG_PATH}:${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/jsoncpp/build/pkg-config") + set(ENV{PKG_CONFIG_PATH} "$ENV{PKG_CONFIG_PATH}:${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/pc") + + include_directories(${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/jsoncpp/include) + include_directories(${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/libsecret) + include_directories(${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/libsecret/_build) + + add_library(jsoncpp SHARED IMPORTED) + set_target_properties(jsoncpp PROPERTIES IMPORTED_LOCATION "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/jsoncpp/build/src/lib_json/libjsoncpp.so") + add_library(secret-1 SHARED IMPORTED) + set_target_properties(secret-1 PROPERTIES IMPORTED_LOCATION "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/libsecret/_build/libsecret/libsecret-1.so") +endif() + add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") # Define the application target. To change its name, change BINARY_NAME above, @@ -91,8 +104,12 @@ apply_standard_settings(${BINARY_NAME}) target_link_libraries(${BINARY_NAME} PRIVATE -static-libgcc -static-libstdc++) target_link_libraries(${BINARY_NAME} PRIVATE flutter) target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) -target_link_libraries(${BINARY_NAME} PRIVATE jsoncpp) -target_link_libraries(${BINARY_NAME} PRIVATE secret-1) +if(USE_SYSTEM_SECURE_STORAGE_DEPS) + target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::JSONCPP PkgConfig::LIBSECRET) +else() + target_link_libraries(${BINARY_NAME} PRIVATE jsoncpp) + target_link_libraries(${BINARY_NAME} PRIVATE secret-1) +endif() # Run the Flutter tool portions of the build. This must not be removed. @@ -138,28 +155,37 @@ install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) if(INCLUDE_EPIC_SO) - install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../crypto_plugins/flutter_libepiccash/scripts/linux/build/rust/target/x86_64-unknown-linux-gnu/release/libepic_cash_wallet.so" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../crypto_plugins/flutter_libepiccash/linux/bin/x86_64-unknown-linux-gnu/release/libepic_cash_wallet.so" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) endif() if(INCLUDE_MWC_SO) - install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../crypto_plugins/flutter_libmwc/scripts/linux/build/rust/target/x86_64-unknown-linux-gnu/release/libmwc_wallet.so" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../crypto_plugins/flutter_libmwc/linux/bin/x86_64-unknown-linux-gnu/release/libmwc_wallet.so" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) endif() -install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/jsoncpp/build/src/lib_json/libjsoncpp.so.1.7.4" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/jsoncpp/build/src/lib_json/libjsoncpp.so.1" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/jsoncpp/build/src/lib_json/libjsoncpp.so" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) +if(USE_SYSTEM_SECURE_STORAGE_DEPS) + file(GLOB JSONCPP_SO_FILES "${JSONCPP_LIBDIR}/libjsoncpp.so*") + install(FILES ${JSONCPP_SO_FILES} DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + file(GLOB LIBSECRET_SO_FILES "${LIBSECRET_LIBDIR}/libsecret-1.so*") + install(FILES ${LIBSECRET_SO_FILES} DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +else() + install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/jsoncpp/build/src/lib_json/libjsoncpp.so.1.7.4" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/jsoncpp/build/src/lib_json/libjsoncpp.so.1" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/jsoncpp/build/src/lib_json/libjsoncpp.so" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) -install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/libsecret/_build/libsecret/libsecret-1.so.0.0.0" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/libsecret/_build/libsecret/libsecret-1.so.0" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/libsecret/_build/libsecret/libsecret-1.so" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) + install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/libsecret/_build/libsecret/libsecret-1.so.0.0.0" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/libsecret/_build/libsecret/libsecret-1.so.0" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/linux/build/libsecret/_build/libsecret/libsecret-1.so" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) install(FILES "${bundled_library}" @@ -167,6 +193,12 @@ foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) COMPONENT Runtime) endforeach(bundled_library) +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. set(FLUTTER_ASSET_DIR_NAME "flutter_assets") diff --git a/scripts/app_config/templates/linux/my_application.cc b/scripts/app_config/templates/linux/my_application.cc index a6eec39569..0954bbc891 100644 --- a/scripts/app_config/templates/linux/my_application.cc +++ b/scripts/app_config/templates/linux/my_application.cc @@ -1,9 +1,6 @@ #include "my_application.h" #include -#ifdef GDK_WINDOWING_X11 -#include -#endif #include "flutter/generated_plugin_registrant.h" @@ -14,30 +11,23 @@ struct _MyApplication { G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView *view) +{ + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { + // Use a traditional title bar by default for best compatibility across + // desktop environments (KDE, XFCE, tiling WMs, etc.). + // Set GTK_CSD=1 to use a GNOME-style header bar instead. + const char* gtk_csd_env_var = getenv("GTK_CSD"); + if (gtk_csd_env_var && strcmp(gtk_csd_env_var, "1") == 0) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); gtk_header_bar_set_title(header_bar, "PlaceHolderName"); @@ -54,9 +44,18 @@ static void my_application_activate(GApplication* application) { fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); gtk_widget_show(GTK_WIDGET(view)); gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); + gtk_widget_realize(GTK_WIDGET(view)); + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); gtk_widget_grab_focus(GTK_WIDGET(view)); @@ -81,6 +80,24 @@ static gboolean my_application_local_command_line(GApplication* application, gch return TRUE; } +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + // Implements GObject::dispose. static void my_application_dispose(GObject* object) { MyApplication* self = MY_APPLICATION(object); @@ -91,12 +108,20 @@ static void my_application_dispose(GObject* object) { static void my_application_class_init(MyApplicationClass* klass) { G_APPLICATION_CLASS(klass)->activate = my_application_activate; G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; G_OBJECT_CLASS(klass)->dispose = my_application_dispose; } static void my_application_init(MyApplication* self) {} MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + return MY_APPLICATION(g_object_new(my_application_get_type(), "application-id", APPLICATION_ID, "flags", G_APPLICATION_NON_UNIQUE, diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 5649487960..a9161185f8 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -14,8 +14,8 @@ description: PLACEHOLDER version: PLACEHOLDER_V+PLACEHOLDER_B environment: - sdk: ">=3.9.0 <4.0.0" - flutter: ^3.29.0 + sdk: ">=3.12.0 <4.0.0" + flutter: ^3.44.9 dependencies: flutter: @@ -30,17 +30,21 @@ dependencies: # %%END_ENABLE_FROST%% # %%ENABLE_XEL%% +# xelis_dart_sdk: 0.35.1 +## git: +## url: https://github.com/xelis-project/xelis-dart-sdk.git +## ref: f1da98f8bad8b9ad3645661a23f9efb83e44b0c9 # xelis_flutter: # git: -# url: https://github.com/cypherstack/xelis-flutter-ffi.git -# ref: 5dd5c50713160fa15fb06ff44886ae035eed62fd +# url: https://github.com/xelis-project/xelis-flutter-ffi.git +# ref: 3e5ac06c22956a9113a88a2d38ba82f8787be1c6 # %%END_ENABLE_XEL%% # %%ENABLE_FIRO%% # flutter_libsparkmobile: # git: # url: https://github.com/cypherstack/flutter_libsparkmobile.git -# ref: 84a139a25ab1691762002fafcae351e3d444a5c7 +# ref: e017e62766908e9714c5309761183e8be8b37799 # %%END_ENABLE_FIRO%% # %%ENABLE_EPIC%% @@ -61,17 +65,22 @@ dependencies: # %%END_ENABLE_TOR%% # %%ENABLE_XMR%% -# cs_monero: 1.1.1 -# cs_monero_flutter_libs: 1.1.1 +# cs_monero: 3.2.0 +# cs_monero_flutter_libs: 2.0.1 # %%END_ENABLE_XMR%% +# %%ENABLE_WOW%% +# cs_wownero: 2.0.0 +# cs_wownero_flutter_libs: 2.0.3 +# %%END_ENABLE_WOW%% + # %%ENABLE_SAL%% # cs_salvium: ^2.0.0 -# cs_salvium_flutter_libs: ^2.0.0 +# cs_salvium_flutter_libs: ^3.0.1 # %%END_ENABLE_SAL%% # %%ENABLE_MWEBD%% -# flutter_mwebd: ^0.0.1-pre.8 +# flutter_mwebd: 0.0.1-pre.11 # %%END_ENABLE_MWEBD%% monero_rpc: ^2.0.0 @@ -86,7 +95,7 @@ dependencies: bitcoindart: git: url: https://github.com/cypherstack/bitcoindart.git - ref: af6d6c27edfe2e7cc35772ed2684eb4cc826f0e4 + ref: ea33b1f5d6a701791359a2e180f73866dc667732 stack_wallet_backup: git: @@ -96,15 +105,15 @@ dependencies: bip47: git: url: https://github.com/cypherstack/bip47.git - ref: a6e7941b98a43a613708b1a12564bc17e712cfc7 + ref: bdc0c0788d1d6dfb04863a793955f848ba1624a8 fusiondart: git: url: https://github.com/cypherstack/fusiondart.git - ref: 540d0bc7dc27a97d45d63f412f26818a7f3b8b51 + ref: 14427bcbbe1e754bce4a1b93cdb0a31ce56d792b # Utility plugins - http: ^0.13.0 + http: ^1.6.0 local_auth: ^2.3.0 permission_handler: ^12.0.0+1 flutter_local_notifications: ^17.2.2 @@ -120,21 +129,27 @@ dependencies: bip39: git: url: https://github.com/cypherstack/stack-bip39.git - ref: 0cd6d54e2860bea68fc50c801cb9db2a760192fb + ref: 20bc8ca0bf0a30c6965977a26c41475a9e862020 bitbox: git: - url: https://github.com/PiRK/bitbox-flutter.git - ref: 50bf29957514a5712466ba37590a851212a244bf - bip32: ^2.0.0 + url: https://github.com/cypherstack/bitbox-flutter.git + ref: 4c3c1aadae089dd1ace705aedf012e1c89fe53ad + bip32: + git: + url: https://github.com/cypherstack/bip32-dart + ref: 9a7e9b9bad9872c69dd1383d6b2e6090f85148fc bech32: git: url: https://github.com/cypherstack/bech32.git - ref: b6d2a5b4cd17311d917787c0f9505f04932659b1 + ref: 6a3388ff8f62c1fa5e624bb7f36c8e71fe53428d bs58check: ^1.0.2 # Eth Plugins - web3dart: 2.6.1 - ethereum_addresses: 1.0.2 + web3dart: 3.0.1 + ethereum_addresses: + git: + url: https://github.com/cypherstack/dart-ethereum_address + ref: 6a5d3d69e54c175ae44b44040fb2743c9b6405a6 # Storage plugins flutter_secure_storage: ^8.0.0 @@ -144,22 +159,18 @@ dependencies: # UI/Component plugins flutter_native_splash: ^2.2.4 - google_fonts: ^4.0.4 + google_fonts: ^6.3.2 url_launcher: ^6.0.5 flutter_svg: ^2.0.7 - flutter_feather_icons: ^2.0.0+1 - decimal: ^2.1.0 + decimal: ^3.2.4 event_bus: ^2.0.0 - uuid: ^3.0.5 + uuid: ^4.5.2 crypto: ^3.0.2 - mobile_scanner: ^7.0.1 image: ^4.3.0 wakelock_plus: ^1.2.8 - intl: ^0.17.0 - devicelocale: - git: - url: https://github.com/cypherstack/flutter-devicelocale - ref: ba7d7d87a3772e972adb1358a5ec9a111b514fce + intl: ^0.19.0 + html: ^0.15.6 + devicelocale: 0.9.1 device_info_plus: ^10.1.2 keyboard_dismisser: ^3.0.0 another_flushbar: ^1.10.28 @@ -168,13 +179,10 @@ dependencies: qr_flutter: ^4.0.0 share_plus: ^7.0.2 emojis: ^0.9.9 - pointycastle: ^3.6.0 + pointycastle: ^4.0.0 package_info_plus: ^8.0.2 - lottie: ^2.3.2 - file_picker: - git: - url: https://github.com/cypherstack/flutter_file_picker.git - ref: b2849e63e1d418ad8d943c886cd3f4ed20d0ff23 + lottie: ^3.3.2 + file_picker: ^10.3.3 connectivity_plus: ^4.0.1 isar_community: 3.3.0-dev.2 isar_community_flutter_libs: 3.3.0-dev.2 @@ -183,32 +191,38 @@ dependencies: equatable: ^2.0.5 async: ^2.10.0 dart_bs58: ^1.0.1 - dart_bs58check: ^3.0.2 + dart_bs58check: + git: + url: https://github.com/cypherstack/dart-bs58check + ref: bed60e43e4e509ea45bb097e6caee9f8293ddf98 hex: ^0.2.0 - archive: ^3.6.1 + archive: ^4.0.2 desktop_drop: ^0.4.4 - nanodart: ^2.0.0 + nanodart: + git: + url: https://github.com/cypherstack/nanodart + ref: 1d3f30c8abd36d352a8b3147426308b77c77484e basic_utils: ^5.5.4 - stellar_flutter_sdk: ^1.7.8 - bip340: ^0.2.0 + stellar_flutter_sdk: ^2.1.7 +# bip340: ^0.2.0 # tezart: ^2.0.5 tezart: git: url: https://github.com/cypherstack/tezart.git - ref: d000cc245e51d3ff50e6467960fb3d9159d5b2a9 + ref: 84c563104f1a19c26e49bafccb7da404b210b666 socks5_proxy: 1.0.3+dev.3 convert: ^3.1.1 flutter_hooks: ^0.20.3 meta: ^1.9.1 coinlib_flutter: git: - url: https://www.github.com/julian-CStack/coinlib + url: https://github.com/cypherstack/coinlib path: coinlib_flutter - ref: d212a8f974bf30be82ce486bf60d7135d80eb6a2 + ref: f0e12dacb6d39e1cb340f0deae178d0fad1d6fd6 electrum_adapter: git: url: https://github.com/cypherstack/electrum_adapter.git - ref: 794ab2d7b88b34d64a89518f9b9f41dcc235aca1 + ref: b6fa44d015d3bfa06934b73219928c29ca48a290 stream_channel: ^2.1.0 solana: git: # TODO [prio=low]: Revert to official package once Tor support is merged upstream. @@ -239,13 +253,24 @@ dependencies: ref: 3c0cba27868ebb5c7d65ebc30a8e6e5342186692 namecoin: git: - url: https://github.com/Cyrix126/namecoin_dart - ref: 819b21164ef93cc0889049d4a8a1be2d0cc36a1b + url: https://github.com/cypherstack/namecoin_dart + ref: 73a29731ba493595fed331d92c7a4b5604fd6e23 drift: ^2.28.2 drift_flutter: ^0.2.7 path: ^1.9.1 mweb_client: ^0.2.0 fixnum: ^1.1.1 + saf_util: ^0.11.0 + saf_stream: ^0.12.3 + unorm_dart: ^0.3.2 + qr_code_scanner_plus: ^2.0.14 + mobile_app_privacy: + git: + url: https://github.com/cypherstack/mobile_app_privacy + ref: v0.0.3 + + # required for web3dart to use EthereumAddress class... + wallet: 0.0.18 dev_dependencies: flutter_test: @@ -268,7 +293,11 @@ dev_dependencies: flutter_native_splash: image: assets/icon/splash.png color: "F7F7F7" - android_disable_fullscreen: true + color_dark_ios: "2A2D34" + color_dark_android: "2A2D34" + android_12: + color: "F7F7F7" + color_dark: "2A2D34" dependency_overrides: logger: @@ -282,23 +311,23 @@ dependency_overrides: # needed for dart 3.5+ (at least for now) win32: ^5.5.4 - # namecoin names lib needs to be updated + # coinlib_flutter requires this coinlib: git: - url: https://www.github.com/julian-CStack/coinlib + url: https://github.com/cypherstack/coinlib path: coinlib - ref: d212a8f974bf30be82ce486bf60d7135d80eb6a2 - - coinlib_flutter: - git: - url: https://www.github.com/julian-CStack/coinlib - path: coinlib_flutter - ref: d212a8f974bf30be82ce486bf60d7135d80eb6a2 + ref: f0e12dacb6d39e1cb340f0deae178d0fad1d6fd6 bip47: git: url: https://github.com/cypherstack/bip47.git - ref: a6e7941b98a43a613708b1a12564bc17e712cfc7 + ref: bdc0c0788d1d6dfb04863a793955f848ba1624a8 + + # bip47 pins a different bitcoindart commit; override to ours + bitcoindart: + git: + url: https://github.com/cypherstack/bitcoindart.git + ref: ea33b1f5d6a701791359a2e180f73866dc667732 # required for dart 3, at least until a fix is merged upstream wakelock_windows: @@ -307,16 +336,40 @@ dependency_overrides: ref: 2a9bca63a540771f241d688562351482b2cf234c path: wakelock_windows - # required override for nanodart + # required override for solana, etc bip39: git: url: https://github.com/cypherstack/stack-bip39.git - ref: 0cd6d54e2860bea68fc50c801cb9db2a760192fb + ref: 20bc8ca0bf0a30c6965977a26c41475a9e862020 + + # required to override solana's lower version + decimal: ^3.2.4 - crypto: 3.0.2 - analyzer: ^8.2.0 - pinenacl: ^0.6.0 - http: ^0.13.0 + # pin analyzer below 8.4.0 to avoid source_gen 3.1.0 incompatibility (getInvocation() removed in 8.4.0+) + analyzer: ">=8.2.0 <8.4.0" + + # xelis override + json_rpc_2: ^4.0.0 + freezed: ^3.1.0 + freezed_annotation: ^3.1.0 + +# %%ENABLE_ISAR%% +# isar_community: +# git: +# url: https://github.com/isar-community/isar-community.git +# path: packages/isar_community +# ref: 39ea19ff035518aef4d0e776206d96a428f57789 +# isar_community_flutter_libs: +# git: +# url: https://github.com/isar-community/isar-community.git +# path: packages/isar_community_flutter_libs +# ref: 39ea19ff035518aef4d0e776206d96a428f57789 +# isar_community_generator: +# git: +# url: https://github.com/isar-community/isar-community.git +# path: packages/isar_community_generator +# ref: 39ea19ff035518aef4d0e776206d96a428f57789 +# %%END_ENABLE_ISAR%% # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -365,6 +418,10 @@ flutter: # default themes_testing - assets/default_themes/ +# %%ENABLE_MWEBDEXE%% +# - assets/windows/mwebd.exe +# %%END_ENABLE_MWEBDEXE%% + import_sorter: comments: false # Optional, defaults to true ignored_files: # Optional, defaults to [] diff --git a/scripts/app_config/templates/windows/CMakeLists.txt b/scripts/app_config/templates/windows/CMakeLists.txt index edc7b5a5b7..d152222b2c 100644 --- a/scripts/app_config/templates/windows/CMakeLists.txt +++ b/scripts/app_config/templates/windows/CMakeLists.txt @@ -7,7 +7,7 @@ project(place_holder LANGUAGES CXX) set(BINARY_NAME "place_holder") set(INCLUDE_EPIC_SO INCLUDE_EPIC_SO_FLAG) -set(INCLUDE_MWC_SO INCLUDE_EPIC_SO_FLAG) +set(INCLUDE_MWC_SO INCLUDE_MWC_SO_FLAG) # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. @@ -99,6 +99,12 @@ if(PLUGIN_BUNDLED_LIBRARIES) COMPONENT Runtime) endif() +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. set(FLUTTER_ASSET_DIR_NAME "flutter_assets") diff --git a/scripts/build_app.sh b/scripts/build_app.sh index fc56a2bc1f..051236d09e 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -9,7 +9,7 @@ APP_NAMED_IDS=("stack_wallet" "stack_duo" "campfire") # Function to display usage. usage() { - echo "Usage: $0 -v -b -p -a " + echo "Usage: $0 -v -b -p -a [-d] [-i] [-f] [-s]" exit 1 } @@ -33,15 +33,21 @@ unset -v APP_NAMED_ID # optional args (with defaults) BUILD_CRYPTO_PLUGINS=0 +DOWNLOAD_CRYPTO_PLUGINS=0 +BUILD_ISAR_FROM_SOURCE=0 +USE_SYSTEM_SECURE_STORAGE_DEPS=0 # Parse command-line arguments. -while getopts "v:b:p:a:i" opt; do +while getopts "v:b:p:a:idfs" opt; do case "${opt}" in v) APP_VERSION_STRING="$OPTARG" ;; b) APP_BUILD_NUMBER="$OPTARG" ;; p) APP_BUILD_PLATFORM="$OPTARG" ;; a) APP_NAMED_ID="$OPTARG" ;; i) BUILD_CRYPTO_PLUGINS=1 ;; + d) DOWNLOAD_CRYPTO_PLUGINS=1 ;; + f) BUILD_ISAR_FROM_SOURCE=1 ;; + s) USE_SYSTEM_SECURE_STORAGE_DEPS=1 ;; *) usage ;; esac done @@ -71,6 +77,9 @@ set -x source "${APP_PROJECT_ROOT_DIR}/scripts/app_config/templates/configure_template_files.sh" +export BUILD_ISAR_FROM_SOURCE +export USE_SYSTEM_SECURE_STORAGE_DEPS + # checks for the correct platform dir and pushes it for later if printf '%s\0' "${APP_PLATFORMS[@]}" | grep -Fxqz -- "${APP_BUILD_PLATFORM}"; then pushd "${APP_PROJECT_ROOT_DIR}/scripts/${APP_BUILD_PLATFORM}" @@ -107,15 +116,10 @@ else fi if [ "$BUILD_CRYPTO_PLUGINS" -eq 0 ]; then - if [[ "$APP_NAMED_ID" = "stack_wallet" ]]; then - ./build_all.sh - elif [[ "$APP_NAMED_ID" = "stack_duo" ]]; then - ./build_all_duo.sh - elif [[ "$APP_NAMED_ID" = "campfire" ]]; then - ./build_all_campfire.sh + if [ "$DOWNLOAD_CRYPTO_PLUGINS" -eq 1 ]; then + ./download_all.sh "$APP_NAMED_ID" else - echo "Invalid app id: ${APP_NAMED_ID}" - exit 1 + ./build_all.sh "$APP_NAMED_ID" fi fi diff --git a/scripts/ensure_test_app_config.sh b/scripts/ensure_test_app_config.sh new file mode 100755 index 0000000000..c9dccc3399 --- /dev/null +++ b/scripts/ensure_test_app_config.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/env.sh" + +APP_CONFIG_DART_FILE="${APP_PROJECT_ROOT_DIR}/lib/app_config.g.dart" + +if test -f "$APP_CONFIG_DART_FILE"; then + echo 'ensure_test_app_config.sh: verified lib/app_config.g.dart' + exit 0 +fi + +BUILT_COMMIT_HASH="$(git -C "${APP_PROJECT_ROOT_DIR}" log -1 --pretty=format:%H 2>/dev/null || true)" + +cat > "$APP_CONFIG_DART_FILE" < _features = { + AppFeature.themeSelection, + AppFeature.buy, + AppFeature.tor, + AppFeature.swap +}; + +const ({String light, String dark})? _appIconAsset = null; + +final List _supportedCoins = List.unmodifiable([ + Bitcoin(CryptoCurrencyNetwork.main), + Monero(CryptoCurrencyNetwork.main), + Banano(CryptoCurrencyNetwork.main), + Bitcoincash(CryptoCurrencyNetwork.main), + BitcoinFrost(CryptoCurrencyNetwork.main), + Cardano(CryptoCurrencyNetwork.main), + Dash(CryptoCurrencyNetwork.main), + Dogecoin(CryptoCurrencyNetwork.main), + Ecash(CryptoCurrencyNetwork.main), + Epiccash(CryptoCurrencyNetwork.main), + Ethereum(CryptoCurrencyNetwork.main), + Fact0rn(CryptoCurrencyNetwork.main), + Firo(CryptoCurrencyNetwork.main), + Litecoin(CryptoCurrencyNetwork.main), + if (!Platform.isMacOS) Mimblewimblecoin(CryptoCurrencyNetwork.main), + Nano(CryptoCurrencyNetwork.main), + Namecoin(CryptoCurrencyNetwork.main), + Particl(CryptoCurrencyNetwork.main), + Peercoin(CryptoCurrencyNetwork.main), + Salvium(CryptoCurrencyNetwork.main), + Solana(CryptoCurrencyNetwork.main), + Stellar(CryptoCurrencyNetwork.main), + Tezos(CryptoCurrencyNetwork.main), + Wownero(CryptoCurrencyNetwork.main), + Xelis(CryptoCurrencyNetwork.main), + Bitcoin(CryptoCurrencyNetwork.test), + Bitcoin(CryptoCurrencyNetwork.test4), + Bitcoincash(CryptoCurrencyNetwork.test), + BitcoinFrost(CryptoCurrencyNetwork.test), + BitcoinFrost(CryptoCurrencyNetwork.test4), + Dogecoin(CryptoCurrencyNetwork.test), + Firo(CryptoCurrencyNetwork.test), + Litecoin(CryptoCurrencyNetwork.test), + Peercoin(CryptoCurrencyNetwork.test), + Salvium(CryptoCurrencyNetwork.test), + Stellar(CryptoCurrencyNetwork.test), + Xelis(CryptoCurrencyNetwork.test), +]); + +final ({String from, String fromFuzzyNet, String to, String toFuzzyNet}) +_swapDefaults = ( + from: "BTC", + fromFuzzyNet: "btc", + to: "XMR", + toFuzzyNet: "xmr", +); +EOF + +echo 'ensure_test_app_config.sh: created lib/app_config.g.dart' diff --git a/scripts/ios/build_all.sh b/scripts/ios/build_all.sh index 83177db5c2..f025c6c250 100755 --- a/scripts/ios/build_all.sh +++ b/scripts/ios/build_all.sh @@ -2,27 +2,26 @@ set -x -e -# ensure ios rust triples are there -rustup target add aarch64-apple-ios -rustup target add x86_64-apple-ios +APP="${1:-stack_wallet}" # ensure ios rust triples are there rustup target add aarch64-apple-ios rustup target add x86_64-apple-ios -# libepiccash requires old rust source ../rust_version.sh -set_rust_version_for_libepiccash -(cd ../../crypto_plugins/flutter_libepiccash/scripts/ios && ./build_all.sh ) -(cd ../../crypto_plugins/flutter_libmwc/scripts/ios/ && ./build_all.sh ) -# set rust (back) to a more recent stable release after building epiccash + +if [[ "$APP" = "stack_wallet" ]]; then + set_rust_version_for_libepiccash + (cd ../../crypto_plugins/flutter_libepiccash/scripts/ios && ./build_all.sh ) + set_rust_version_for_libmwc + (cd ../../crypto_plugins/flutter_libmwc/scripts/ios/ && ./build_all.sh ) +fi + set_rust_to_everything_else -(cd ../../crypto_plugins/frostdart/scripts/ios && ./build_all.sh ) +if [[ "$APP" = "stack_wallet" || "$APP" = "stack_duo" ]]; then + (cd ../../crypto_plugins/frostdart/scripts/ios && ./build_all.sh ) +fi wait echo "Done building" - -# ensure ios rust triples are there -rustup target add aarch64-apple-ios -rustup target add x86_64-apple-ios diff --git a/scripts/ios/build_all_campfire.sh b/scripts/ios/build_all_campfire.sh deleted file mode 100755 index 994b682446..0000000000 --- a/scripts/ios/build_all_campfire.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash - -set -x -e - -# ensure ios rust triples are there -rustup target add aarch64-apple-ios -rustup target add x86_64-apple-ios - -# ensure ios rust triples are there -rustup target add aarch64-apple-ios -rustup target add x86_64-apple-ios - -# libepiccash requires old rust -source ../rust_version.sh -set_rust_to_everything_else - -wait -echo "Done building" - -# ensure ios rust triples are there -rustup target add aarch64-apple-ios -rustup target add x86_64-apple-ios diff --git a/scripts/ios/build_all_duo.sh b/scripts/ios/build_all_duo.sh deleted file mode 100755 index c09b3528fa..0000000000 --- a/scripts/ios/build_all_duo.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -set -x -e - -# todo: revisit following at some point - -# ensure ios rust triples are there -rustup target add aarch64-apple-ios -rustup target add x86_64-apple-ios - -# ensure ios rust triples are there -rustup target add aarch64-apple-ios -rustup target add x86_64-apple-ios - -# libepiccash requires old rust -source ../rust_version.sh -set_rust_to_everything_else - -(cd ../../crypto_plugins/frostdart/scripts/ios && ./build_all.sh ) - -wait -echo "Done building" - -# ensure ios rust triples are there -rustup target add aarch64-apple-ios -rustup target add x86_64-apple-ios diff --git a/scripts/ios/download_all.sh b/scripts/ios/download_all.sh new file mode 100755 index 0000000000..1e5866c033 --- /dev/null +++ b/scripts/ios/download_all.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -x -e + +APP="${1:-stack_wallet}" + +mkdir -p build + +PLUGINS_DIR=../../crypto_plugins + +if [[ "$APP" = "stack_wallet" ]]; then + (cd "${PLUGINS_DIR}"/flutter_libepiccash/scripts/ios && ./download.sh) + (cd "${PLUGINS_DIR}"/flutter_libmwc/scripts/ios && ./download.sh) +fi + +if [[ "$APP" = "stack_wallet" || "$APP" = "stack_duo" ]]; then + (cd "${PLUGINS_DIR}"/frostdart/scripts/ios && ./download.sh) +fi + +wait +echo "Done" diff --git a/scripts/linux/build_all.sh b/scripts/linux/build_all.sh index 50490b1979..d2c703d218 100755 --- a/scripts/linux/build_all.sh +++ b/scripts/linux/build_all.sh @@ -2,6 +2,8 @@ set -x -e +APP="${1:-stack_wallet}" + # for arm # flutter-elinux clean # flutter-elinux pub get @@ -9,15 +11,20 @@ set -x -e mkdir -p build ./build_secure_storage_deps.sh -# libepiccash requires old rust source ../rust_version.sh -set_rust_version_for_libepiccash -(cd ../../crypto_plugins/flutter_libepiccash/scripts/linux && ./build_all.sh ) -(cd ../../crypto_plugins/flutter_libmwc/scripts/linux && ./build_all.sh ) -# set rust (back) to a more recent stable release after building epiccash + +if [[ "$APP" = "stack_wallet" ]]; then + set_rust_version_for_libepiccash + (cd ../../crypto_plugins/flutter_libepiccash/scripts/linux && ./build_all.sh ) + set_rust_version_for_libmwc + (cd ../../crypto_plugins/flutter_libmwc/scripts/linux && ./build_all.sh ) +fi + set_rust_to_everything_else -(cd ../../crypto_plugins/frostdart/scripts/linux && ./build_all.sh ) +if [[ "$APP" = "stack_wallet" || "$APP" = "stack_duo" ]]; then + (cd ../../crypto_plugins/frostdart/scripts/linux && ./build_all.sh ) +fi ./build_secp256k1.sh diff --git a/scripts/linux/build_all_campfire.sh b/scripts/linux/build_all_campfire.sh deleted file mode 100755 index d1e1de71a1..0000000000 --- a/scripts/linux/build_all_campfire.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -set -x -e - -# for arm -# flutter-elinux clean -# flutter-elinux pub get -# flutter-elinux build linux --dart-define="IS_ARM=true" -mkdir -p build -./build_secure_storage_deps.sh - -# libepiccash requires old rust -source ../rust_version.sh -set_rust_to_everything_else - -./build_secp256k1.sh - -wait -echo "Done building" diff --git a/scripts/linux/build_all_duo.sh b/scripts/linux/build_all_duo.sh deleted file mode 100755 index 3e2ee5b5b1..0000000000 --- a/scripts/linux/build_all_duo.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash - -set -x -e - -# todo: revisit following at some point - - -# for arm -# flutter-elinux clean -# flutter-elinux pub get -# flutter-elinux build linux --dart-define="IS_ARM=true" -mkdir -p build -./build_secure_storage_deps.sh - -# libepiccash requires old rust -source ../rust_version.sh -set_rust_to_everything_else - -(cd ../../crypto_plugins/frostdart/scripts/linux && ./build_all.sh ) - -./build_secp256k1.sh - -wait -echo "Done building" diff --git a/scripts/linux/build_secp256k1.sh b/scripts/linux/build_secp256k1.sh index e139cc9377..b6037a3060 100755 --- a/scripts/linux/build_secp256k1.sh +++ b/scripts/linux/build_secp256k1.sh @@ -6,8 +6,9 @@ fi cd secp256k1 git checkout 68b55209f1ba3e6c0417789598f5f75649e9c14c git reset --hard +rm -rf build mkdir -p build && cd build -cmake .. +cmake .. -DSECP256K1_ENABLE_MODULE_RECOVERY=ON cmake --build . mkdir -p ../../../../../build cp lib/libsecp256k1.so.2.*.* "../../../../../build/libsecp256k1.so" diff --git a/scripts/linux/build_secure_storage_deps.sh b/scripts/linux/build_secure_storage_deps.sh index 737508ab0d..e84572bcaa 100755 --- a/scripts/linux/build_secure_storage_deps.sh +++ b/scripts/linux/build_secure_storage_deps.sh @@ -1,4 +1,10 @@ #!/bin/bash + +if [ "${USE_SYSTEM_SECURE_STORAGE_DEPS:-0}" = "1" ]; then + echo "USE_SYSTEM_SECURE_STORAGE_DEPS is set; skipping build of jsoncpp and libsecret (using system packages)" + exit 0 +fi + LINUX_DIRECTORY=$(pwd) JSONCPP_TAG=1.7.4 LIBSECRET_TAG=0.21.4 diff --git a/scripts/linux/download_all.sh b/scripts/linux/download_all.sh new file mode 100755 index 0000000000..1da22e4ce0 --- /dev/null +++ b/scripts/linux/download_all.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -x -e + +APP="${1:-stack_wallet}" + +mkdir -p build +./build_secure_storage_deps.sh + +if [[ "$APP" = "stack_wallet" ]]; then + (cd ../../crypto_plugins/flutter_libepiccash/scripts/linux && ./download.sh) + (cd ../../crypto_plugins/flutter_libmwc/scripts/linux && ./download.sh) +fi + +if [[ "$APP" = "stack_wallet" || "$APP" = "stack_duo" ]]; then + (cd ../../crypto_plugins/frostdart/scripts/linux && ./download.sh) +fi + +./build_secp256k1.sh + +wait +echo "Done" diff --git a/scripts/macos/build_all.sh b/scripts/macos/build_all.sh index de9b79efaa..4dbefd5e53 100755 --- a/scripts/macos/build_all.sh +++ b/scripts/macos/build_all.sh @@ -2,17 +2,22 @@ set -x -e +APP="${1:-stack_wallet}" -# libepiccash requires old rust source ../rust_version.sh -set_rust_version_for_libepiccash -(cd ../../crypto_plugins/flutter_libepiccash/scripts/macos && ./build_all.sh ) -(cd ../../crypto_plugins/flutter_libmwc/scripts/macos && ./build_all.sh ) -# set rust (back) to a more recent stable release after building epiccash + +if [[ "$APP" = "stack_wallet" ]]; then + set_rust_version_for_libepiccash + (cd ../../crypto_plugins/flutter_libepiccash/scripts/macos && ./build_all.sh ) + set_rust_version_for_libmwc + (cd ../../crypto_plugins/flutter_libmwc/scripts/macos && ./build_all.sh ) +fi + set_rust_to_everything_else -(cd ../../crypto_plugins/frostdart/scripts/macos && ./build_all.sh ) +if [[ "$APP" = "stack_wallet" || "$APP" = "stack_duo" ]]; then + (cd ../../crypto_plugins/frostdart/scripts/macos && ./build_all.sh ) +fi wait echo "Done building" - diff --git a/scripts/macos/build_all_campfire.sh b/scripts/macos/build_all_campfire.sh deleted file mode 100755 index e1b4216bc0..0000000000 --- a/scripts/macos/build_all_campfire.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -set -x -e - - -# libepiccash requires old rust -source ../rust_version.sh -set_rust_to_everything_else - -wait -echo "Done building" diff --git a/scripts/macos/build_all_duo.sh b/scripts/macos/build_all_duo.sh deleted file mode 100755 index a618eeebb7..0000000000 --- a/scripts/macos/build_all_duo.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -set -x -e - -# todo: revisit following at some point - - -# libepiccash requires old rust -source ../rust_version.sh -set_rust_to_everything_else - -(cd ../../crypto_plugins/frostdart/scripts/macos && ./build_all.sh ) - -wait -echo "Done building" - diff --git a/scripts/macos/download_all.sh b/scripts/macos/download_all.sh new file mode 100755 index 0000000000..36dce29efb --- /dev/null +++ b/scripts/macos/download_all.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -x -e + +APP="${1:-stack_wallet}" + +mkdir -p build + +PLUGINS_DIR=../../crypto_plugins + +if [[ "$APP" = "stack_wallet" ]]; then + (cd "${PLUGINS_DIR}"/flutter_libepiccash/scripts/macos && ./download.sh) + (cd "${PLUGINS_DIR}"/flutter_libmwc/scripts/macos && ./download.sh) +fi + +if [[ "$APP" = "stack_wallet" || "$APP" = "stack_duo" ]]; then + (cd "${PLUGINS_DIR}"/frostdart/scripts/macos && ./download.sh) +fi + +wait +echo "Done" diff --git a/scripts/prebuild.ps1 b/scripts/prebuild.ps1 index 04b68bc351..b749602ab7 100644 --- a/scripts/prebuild.ps1 +++ b/scripts/prebuild.ps1 @@ -2,7 +2,7 @@ $KEYS = "..\lib\external_api_keys.dart" if (-not (Test-Path $KEYS)) { Write-Host "prebuild.ps1: creating template lib/external_api_keys.dart file" - "const kChangeNowApiKey = '';" + "`nconst kSimpleSwapApiKey = '';" + "`nconst kNanswapApiKey = '';" + "`nconst kNanoSwapRpcApiKey = '';" | Out-File $KEYS -Encoding UTF8 + "const kChangeNowApiKey = '';" + "`nconst kSimpleSwapApiKey = '';" + "`nconst kNanswapApiKey = '';" + "`nconst kNanoSwapRpcApiKey = '';" + "`nconst kWizSwapApiKey = '';" + "`nconst kShopInBitAccessKey = '';" + "`nconst kShopInBitPartnerSecret = '';" + "`nconst kCakePayApiToken = '';" + "`nconst kExolixApiKey = '';" + "`nconst kLetsExchangeId = '';" + "`nconst kLetsExchangeToken = '';" + "`nconst kCypherGoatApiKey = '';" + "`nconst kCypherGoatAffiliate = '';" | Out-File $KEYS -Encoding UTF8 } # Create template wallet test parameter files if they don't already exist diff --git a/scripts/prebuild.sh b/scripts/prebuild.sh index 6c50fbefd9..404569eab9 100755 --- a/scripts/prebuild.sh +++ b/scripts/prebuild.sh @@ -4,7 +4,7 @@ KEYS=../lib/external_api_keys.dart if ! test -f "$KEYS"; then echo 'prebuild.sh: creating template lib/external_api_keys.dart file' - printf 'const kChangeNowApiKey = "";\nconst kSimpleSwapApiKey = "";\nconst kNanswapApiKey = "";\nconst kNanoSwapRpcApiKey = "";\n' > $KEYS + printf 'const kChangeNowApiKey = "";\nconst kSimpleSwapApiKey = "";\nconst kNanswapApiKey = "";\nconst kNanoSwapRpcApiKey = "";\nconst kWizSwapApiKey = "";\nconst kShopInBitAccessKey = "";\nconst kShopInBitPartnerSecret = "";\nconst kCakePayApiToken = "";\nconst kExolixApiKey = "";\nconst kLetsExchangeId = "";\nconst kLetsExchangeToken = "";\nconst kCypherGoatApiKey = "";\nconst kCypherGoatAffiliate = "";\n' > $KEYS fi # Create template wallet test parameter files if they don't already exist diff --git a/scripts/rust_version.sh b/scripts/rust_version.sh index 65bf911f49..68c52d6b97 100755 --- a/scripts/rust_version.sh +++ b/scripts/rust_version.sh @@ -3,18 +3,27 @@ set_rust_to_everything_else() { if rustup toolchain list | grep -q "1.85.1"; then - rustup default 1.85.1 + rustup default 1.89.0 else - echo "Rust version 1.85.1 is not installed. Please install it using 'rustup install 1.85.1'." >&2 + echo "Rust version 1.89.0 is not installed. Please install it using 'rustup install 1.89.0'." >&2 exit 1 fi } set_rust_version_for_libepiccash() { - if rustup toolchain list | grep -q "1.81.0"; then - rustup default 1.81 + if rustup toolchain list | grep -q "1.89.0"; then + rustup default 1.89.0 else - echo "Rust version 1.81.0 is not installed. Please install it using 'rustup install 1.81.0'." >&2 + echo "Rust version 1.89.0 is not installed. Please install it using 'rustup install 1.89.0'." >&2 exit 1 fi } + +set_rust_version_for_libmwc() { + if rustup toolchain list | grep -q "1.85.1"; then + rustup default 1.85.1 + else + echo "Rust version 1.85.1 is not installed. Please install it using 'rustup install 1.85.1'." >&2 + exit 1 + fi +} \ No newline at end of file diff --git a/scripts/windows/build_all.sh b/scripts/windows/build_all.sh index 6d7395bbf3..6d3f3f55b8 100755 --- a/scripts/windows/build_all.sh +++ b/scripts/windows/build_all.sh @@ -2,17 +2,24 @@ set -x -e +APP="${1:-stack_wallet}" + mkdir -p build -# libepiccash requires old rust source ../rust_version.sh -set_rust_version_for_libepiccash -(cd ../../crypto_plugins/flutter_libepiccash/scripts/windows && ./build_all.sh ) -(cd ../../crypto_plugins/flutter_libmwc/scripts/windows && ./build_all.sh ) -# set rust (back) to a more recent stable release after building epiccash + +if [[ "$APP" = "stack_wallet" ]]; then + set_rust_version_for_libepiccash + (cd ../../crypto_plugins/flutter_libepiccash/scripts/windows && ./build_all.sh ) + set_rust_version_for_libmwc + (cd ../../crypto_plugins/flutter_libmwc/scripts/windows && ./build_all.sh ) +fi + set_rust_to_everything_else -(cd ../../crypto_plugins/frostdart/scripts/windows && ./build_all.sh ) +if [[ "$APP" = "stack_wallet" || "$APP" = "stack_duo" ]]; then + (cd ../../crypto_plugins/frostdart/scripts/windows && ./build_all.sh ) +fi ./build_secp256k1_wsl.sh diff --git a/scripts/windows/build_all_campfire.sh b/scripts/windows/build_all_campfire.sh deleted file mode 100755 index e74572b457..0000000000 --- a/scripts/windows/build_all_campfire.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -set -x -e - -mkdir -p build - -# libepiccash requires old rust -source ../rust_version.sh -set_rust_to_everything_else - -./build_secp256k1_wsl.sh - -wait -echo "Done building" diff --git a/scripts/windows/build_all_duo.sh b/scripts/windows/build_all_duo.sh deleted file mode 100755 index 42ff340c37..0000000000 --- a/scripts/windows/build_all_duo.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash - -set -x -e - -# todo: revisit following at some point - -mkdir -p build - -# libepiccash requires old rust -source ../rust_version.sh -set_rust_to_everything_else - -(cd ../../crypto_plugins/frostdart/scripts/windows && ./build_all.sh ) - -./build_secp256k1_wsl.sh - -wait -echo "Done building" diff --git a/scripts/windows/build_secp256k1.bat b/scripts/windows/build_secp256k1.bat index bae7c97888..b619e6e78e 100644 --- a/scripts/windows/build_secp256k1.bat +++ b/scripts/windows/build_secp256k1.bat @@ -4,7 +4,8 @@ git clone https://github.com/bitcoin-core/secp256k1 cd secp256k1 git checkout 68b55209f1ba3e6c0417789598f5f75649e9c14c git reset --hard -cmake -G "Visual Studio 17 2022" -A x64 -S . -B build +if exist "build" rmdir /s /q "build" +cmake -G "Visual Studio 17 2022" -A x64 -S . -B build -DSECP256K1_ENABLE_MODULE_RECOVERY=ON cd build cmake --build . if not exist "..\..\..\..\..\build\" mkdir "..\..\..\..\..\build\" diff --git a/scripts/windows/build_secp256k1_wsl.sh b/scripts/windows/build_secp256k1_wsl.sh index a39cd3bee3..cedb2bc2c1 100644 --- a/scripts/windows/build_secp256k1_wsl.sh +++ b/scripts/windows/build_secp256k1_wsl.sh @@ -6,8 +6,9 @@ fi cd secp256k1 git checkout 68b55209f1ba3e6c0417789598f5f75649e9c14c git reset --hard +rm -rf build mkdir -p build && cd build -cmake .. -DCMAKE_TOOLCHAIN_FILE=../cmake/x86_64-w64-mingw32.toolchain.cmake +cmake .. -DCMAKE_TOOLCHAIN_FILE=../cmake/x86_64-w64-mingw32.toolchain.cmake -DSECP256K1_ENABLE_MODULE_RECOVERY=ON cmake --build . mkdir -p ../../../../../build cp bin/libsecp256k1-2.dll "../../../../../build/secp256k1.dll" diff --git a/scripts/windows/download_all.sh b/scripts/windows/download_all.sh new file mode 100755 index 0000000000..0585885080 --- /dev/null +++ b/scripts/windows/download_all.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -x -e + +APP="${1:-stack_wallet}" + +mkdir -p build + +PLUGINS_DIR=../../crypto_plugins + +if [[ "$APP" = "stack_wallet" ]]; then + (cd "${PLUGINS_DIR}"/flutter_libepiccash/scripts/windows && ./download.sh) + (cd "${PLUGINS_DIR}"/flutter_libmwc/scripts/windows && ./download.sh) +fi + +if [[ "$APP" = "stack_wallet" || "$APP" = "stack_duo" ]]; then + (cd "${PLUGINS_DIR}"/frostdart/scripts/windows && ./download.sh) +fi + +wait +echo "Done" diff --git a/test/cached_electrumx_test.dart b/test/cached_electrumx_test.dart index 370e029727..0e5bf0b551 100644 --- a/test/cached_electrumx_test.dart +++ b/test/cached_electrumx_test.dart @@ -1,5 +1,4 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_test/hive_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:stackwallet/electrumx_rpc/cached_electrumx_client.dart'; @@ -8,13 +7,14 @@ import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; import 'cached_electrumx_test.mocks.dart'; +import 'hive/hive_ce_test_utils.dart'; // import 'sample_data/get_anonymity_set_sample_data.dart'; @GenerateMocks([ElectrumXClient, Prefs]) void main() { group("tests using mock hive", () { setUp(() async { - await setUpTestHive(); + await setUpHiveCeTest(); // await Hive.openBox( // DB.instance.boxNameUsedSerialsCache(coin: Coin.firo)); // await Hive.openBox(DB.instance.boxNameSetCache(coin: Coin.firo)); @@ -117,24 +117,17 @@ void main() { test("getTransaction throws", () async { final client = MockElectrumXClient(); - when( - client.getTransaction( - txHash: "some hash", - ), - ).thenThrow(Exception()); + when(client.getTransaction(txHash: "some hash")).thenThrow(Exception()); - final cachedClient = CachedElectrumXClient( - electrumXClient: client, - ); + final cachedClient = CachedElectrumXClient(electrumXClient: client); expect( - () async => await cachedClient.getTransaction( - txHash: "some hash", - cryptoCurrency: Firo( - CryptoCurrencyNetwork.main, - ), - ), - throwsA(isA())); + () async => await cachedClient.getTransaction( + txHash: "some hash", + cryptoCurrency: Firo(CryptoCurrencyNetwork.main), + ), + throwsA(isA()), + ); }); test("clearSharedTransactionCache", () async { @@ -145,9 +138,7 @@ void main() { bool didThrow = false; try { await cachedClient.clearSharedTransactionCache( - cryptoCurrency: Firo( - CryptoCurrencyNetwork.main, - ), + cryptoCurrency: Firo(CryptoCurrencyNetwork.main), ); } catch (_) { didThrow = true; @@ -157,7 +148,7 @@ void main() { }); tearDown(() async { - await tearDownTestHive(); + await tearDownHiveCeTest(); }); }); @@ -172,8 +163,9 @@ void main() { clearnetEnabled: true, ); - final client = - CachedElectrumXClient.from(electrumXClient: MockElectrumXClient()); + final client = CachedElectrumXClient.from( + electrumXClient: MockElectrumXClient(), + ); expect(client, isA()); }); diff --git a/test/cached_electrumx_test.mocks.dart b/test/cached_electrumx_test.mocks.dart index 1da0fa70b8..41d6b0203c 100644 --- a/test/cached_electrumx_test.mocks.dart +++ b/test/cached_electrumx_test.mocks.dart @@ -332,6 +332,22 @@ class MockElectrumXClient extends _i1.Mock implements _i6.ElectrumXClient { ) as _i9.Future>); + @override + _i9.Future>> getBatchTransactions({ + required List? txHashes, + String? requestID, + }) => + (super.noSuchMethod( + Invocation.method(#getBatchTransactions, [], { + #txHashes: txHashes, + #requestID: requestID, + }), + returnValue: _i9.Future>>.value( + >[], + ), + ) + as _i9.Future>>); + @override _i9.Future> getLelantusAnonymitySet({ String? groupId = '1', @@ -864,6 +880,19 @@ class MockPrefs extends _i1.Mock implements _i10.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -1093,6 +1122,18 @@ class MockPrefs extends _i1.Mock implements _i10.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) @@ -1108,13 +1149,12 @@ class MockPrefs extends _i1.Mock implements _i10.Prefs { as _i9.Future); @override - _i9.Future incrementCurrentNotificationIndex() => + _i9.Future incrementCurrentNotificationIndex() => (super.noSuchMethod( Invocation.method(#incrementCurrentNotificationIndex, []), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), + returnValue: _i9.Future.value(0), ) - as _i9.Future); + as _i9.Future); @override _i9.Future isExternalCallsSet() => diff --git a/test/electrumx_test.dart b/test/electrumx_test.dart index 06ed6111d6..b8c82c8c18 100644 --- a/test/electrumx_test.dart +++ b/test/electrumx_test.dart @@ -1,1778 +1,613 @@ -// import 'dart:io'; -// -// import 'package:flutter_test/flutter_test.dart'; -// import 'package:mockito/annotations.dart'; -// import 'package:mockito/mockito.dart'; -// import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; -// import 'package:stackwallet/electrumx_rpc/rpc.dart'; -// import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; -// import 'package:stackwallet/services/tor_service.dart'; -// import 'package:stackwallet/utilities/prefs.dart'; -// -// import 'electrumx_test.mocks.dart'; -// import 'sample_data/get_anonymity_set_sample_data.dart'; -// import 'sample_data/get_used_serials_sample_data.dart'; -// import 'sample_data/transaction_data_samples.dart'; -// -// @GenerateMocks([JsonRPC, Prefs, TorService]) -// void main() { -// group("factory constructors and getters", () { -// test("electrumxnode .from factory", () { -// final nodeA = ElectrumXNode( -// address: "some address", -// port: 1, -// name: "some name", -// id: "some ID", -// useSSL: true, -// ); -// -// final nodeB = ElectrumXNode.from(nodeA); -// -// expect(nodeB.toString(), nodeA.toString()); -// expect(nodeA == nodeB, false); -// }); -// -// test("electrumx .from factory", () { -// final node = ElectrumXNode( -// address: "some address", -// port: 1, -// name: "some name", -// id: "some ID", -// useSSL: true, -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// -// final client = ElectrumXClient.from( -// node: node, -// failovers: [], -// prefs: mockPrefs, -// torService: torService, -// ); -// -// expect(client.useSSL, node.useSSL); -// expect(client.host, node.address); -// expect(client.port, node.port); -// expect(client.rpcClient, null); -// -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// test("Server error", () { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.transaction.get"; -// const jsonArgs = '["",true]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "error": { -// "code": 1, -// "message": "None should be a transaction hash", -// }, -// "id": "some requestId", -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// failovers: [], -// prefs: mockPrefs, -// torService: torService, -// ); -// -// expect(() => client.getTransaction(requestID: "some requestId", txHash: ''), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// group("getBlockHeadTip", () { -// test("getBlockHeadTip success", () async { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.headers.subscribe"; -// const jsonArgs = '[]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": {"height": 520481, "hex": "some block hex string"}, -// "id": "some requestId" -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = -// await (client.getBlockHeadTip(requestID: "some requestId")); -// -// expect(result["height"], 520481); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("getBlockHeadTip throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.headers.subscribe"; -// const jsonArgs = '[]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect(() => client.getBlockHeadTip(requestID: "some requestId"), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// group("ping", () { -// test("ping success", () async { -// final mockClient = MockJsonRPC(); -// const command = "server.ping"; -// const jsonArgs = '[]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 2), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": null, -// "id": "some requestId", -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = await client.ping(requestID: "some requestId"); -// -// expect(result, true); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("ping throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "server.ping"; -// const jsonArgs = '[]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 2), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect(() => client.ping(requestID: "some requestId"), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// group("getServerFeatures", () { -// test("getServerFeatures success", () async { -// final mockClient = MockJsonRPC(); -// const command = "server.features"; -// const jsonArgs = '[]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": { -// "genesis_hash": -// "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943", -// "hosts": { -// "0.0.0.0": {"tcp_port": 51001, "ssl_port": 51002} -// }, -// "protocol_max": "1.0", -// "protocol_min": "1.0", -// "pruning": null, -// "server_version": "ElectrumX 1.0.17", -// "hash_function": "sha256" -// }, -// "id": "some requestId" -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = -// await client.getServerFeatures(requestID: "some requestId"); -// -// expect(result, { -// "genesis_hash": -// "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943", -// "hosts": { -// "0.0.0.0": {"tcp_port": 51001, "ssl_port": 51002} -// }, -// "protocol_max": "1.0", -// "protocol_min": "1.0", -// "pruning": null, -// "server_version": "ElectrumX 1.0.17", -// "hash_function": "sha256", -// }); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("getServerFeatures throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "server.features"; -// const jsonArgs = '[]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect(() => client.getServerFeatures(requestID: "some requestId"), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// group("broadcastTransaction", () { -// test("broadcastTransaction success", () async { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.transaction.broadcast"; -// const jsonArgs = '["some raw transaction string"]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": "the txid of the rawtx", -// "id": "some requestId" -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = await client.broadcastTransaction( -// rawTx: "some raw transaction string", requestID: "some requestId"); -// -// expect(result, "the txid of the rawtx"); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("broadcastTransaction throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.transaction.broadcast"; -// const jsonArgs = '["some raw transaction string"]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect( -// () => client.broadcastTransaction( -// rawTx: "some raw transaction string", -// requestID: "some requestId"), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// group("getBalance", () { -// test("getBalance success", () async { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.scripthash.get_balance"; -// const jsonArgs = '["dummy hash"]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": { -// "confirmed": 103873966, -// "unconfirmed": 23684400, -// }, -// "id": "some requestId" -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = await client.getBalance( -// scripthash: "dummy hash", requestID: "some requestId"); -// -// expect(result, {"confirmed": 103873966, "unconfirmed": 23684400}); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("getBalance throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.scripthash.get_balance"; -// const jsonArgs = '["dummy hash"]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect( -// () => client.getBalance( -// scripthash: "dummy hash", requestID: "some requestId"), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// group("getHistory", () { -// test("getHistory success", () async { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.scripthash.get_history"; -// const jsonArgs = '["dummy hash"]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(minutes: 5), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": [ -// { -// "height": 200004, -// "tx_hash": -// "acc3758bd2a26f869fcc67d48ff30b96464d476bca82c1cd6656e7d506816412" -// }, -// { -// "height": 215008, -// "tx_hash": -// "f3e1bf48975b8d6060a9de8884296abb80be618dc00ae3cb2f6cee3085e09403" -// } -// ], -// "id": "some requestId" -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = await client.getHistory( -// scripthash: "dummy hash", requestID: "some requestId"); -// -// expect(result, [ -// { -// "height": 200004, -// "tx_hash": -// "acc3758bd2a26f869fcc67d48ff30b96464d476bca82c1cd6656e7d506816412" -// }, -// { -// "height": 215008, -// "tx_hash": -// "f3e1bf48975b8d6060a9de8884296abb80be618dc00ae3cb2f6cee3085e09403" -// } -// ]); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("getHistory throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.scripthash.get_history"; -// const jsonArgs = '["dummy hash"]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(minutes: 5), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect( -// () => client.getHistory( -// scripthash: "dummy hash", requestID: "some requestId"), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// group("getUTXOs", () { -// test("getUTXOs success", () async { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.scripthash.listunspent"; -// const jsonArgs = '["dummy hash"]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": [ -// { -// "tx_pos": 0, -// "value": 45318048, -// "tx_hash": -// "9f2c45a12db0144909b5db269415f7319179105982ac70ed80d76ea79d923ebf", -// "height": 437146 -// }, -// { -// "tx_pos": 0, -// "value": 919195, -// "tx_hash": -// "3d2290c93436a3e964cfc2f0950174d8847b1fbe3946432c4784e168da0f019f", -// "height": 441696 -// } -// ], -// "id": "some requestId" -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = await client.getUTXOs( -// scripthash: "dummy hash", requestID: "some requestId"); -// -// expect(result, [ -// { -// "tx_pos": 0, -// "value": 45318048, -// "tx_hash": -// "9f2c45a12db0144909b5db269415f7319179105982ac70ed80d76ea79d923ebf", -// "height": 437146 -// }, -// { -// "tx_pos": 0, -// "value": 919195, -// "tx_hash": -// "3d2290c93436a3e964cfc2f0950174d8847b1fbe3946432c4784e168da0f019f", -// "height": 441696 -// } -// ]); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("getUTXOs throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.scripthash.listunspent"; -// const jsonArgs = '["dummy hash"]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect( -// () => client.getUTXOs( -// scripthash: "dummy hash", requestID: "some requestId"), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// group("getTransaction", () { -// test("getTransaction success", () async { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.transaction.get"; -// const jsonArgs = '["${SampleGetTransactionData.txHash0}",true]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": SampleGetTransactionData.txData0, -// "id": "some requestId" -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = await client.getTransaction( -// txHash: SampleGetTransactionData.txHash0, -// verbose: true, -// requestID: "some requestId"); -// -// expect(result, SampleGetTransactionData.txData0); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("getTransaction throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.transaction.get"; -// const jsonArgs = '["${SampleGetTransactionData.txHash0}",true]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect( -// () => client.getTransaction( -// txHash: SampleGetTransactionData.txHash0, -// requestID: "some requestId"), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// group("getAnonymitySet", () { -// test("getAnonymitySet success", () async { -// final mockClient = MockJsonRPC(); -// const command = "lelantus.getanonymityset"; -// const jsonArgs = '["1",""]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": GetAnonymitySetSampleData.data, -// "id": "some requestId" -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = await client.getLelantusAnonymitySet( -// groupId: "1", blockhash: "", requestID: "some requestId"); -// -// expect(result, GetAnonymitySetSampleData.data); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("getAnonymitySet throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "lelantus.getanonymityset"; -// const jsonArgs = '["1",""]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect( -// () => client.getLelantusAnonymitySet( -// groupId: "1", requestID: "some requestId"), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// group("getMintData", () { -// test("getMintData success", () async { -// final mockClient = MockJsonRPC(); -// const command = "lelantus.getmintmetadata"; -// const jsonArgs = '["some mints"]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": "mint meta data", -// "id": "some requestId" -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = await client.getLelantusMintData( -// mints: "some mints", requestID: "some requestId"); -// -// expect(result, "mint meta data"); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("getMintData throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "lelantus.getmintmetadata"; -// const jsonArgs = '["some mints"]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect( -// () => client.getLelantusMintData( -// mints: "some mints", requestID: "some requestId"), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// group("getUsedCoinSerials", () { -// test("getUsedCoinSerials success", () async { -// final mockClient = MockJsonRPC(); -// const command = "lelantus.getusedcoinserials"; -// const jsonArgs = '["0"]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(minutes: 2), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": GetUsedSerialsSampleData.serials, -// "id": "some requestId" -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = await client.getLelantusUsedCoinSerials( -// requestID: "some requestId", startNumber: 0); -// -// expect(result, GetUsedSerialsSampleData.serials); -// -// verify(mockPrefs.wifiOnly).called(3); -// verify(mockPrefs.useTor).called(3); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("getUsedCoinSerials throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "lelantus.getusedcoinserials"; -// const jsonArgs = '["0"]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(minutes: 2), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect( -// () => client.getLelantusUsedCoinSerials( -// requestID: "some requestId", startNumber: 0), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// group("getLatestCoinId", () { -// test("getLatestCoinId success", () async { -// final mockClient = MockJsonRPC(); -// const command = "lelantus.getlatestcoinid"; -// const jsonArgs = '[]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": 1, -// "id": "some requestId", -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = -// await client.getLelantusLatestCoinId(requestID: "some requestId"); -// -// expect(result, 1); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("getLatestCoinId throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "lelantus.getlatestcoinid"; -// const jsonArgs = '[]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect( -// () => client.getLelantusLatestCoinId( -// requestID: "some requestId", -// ), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// group("getCoinsForRecovery", () { -// test("getCoinsForRecovery success", () async { -// final mockClient = MockJsonRPC(); -// const command = "lelantus.getanonymityset"; -// const jsonArgs = '["1",""]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": GetAnonymitySetSampleData.data, -// "id": "some requestId" -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = await client.getLelantusAnonymitySet( -// groupId: "1", blockhash: "", requestID: "some requestId"); -// -// expect(result, GetAnonymitySetSampleData.data); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("getAnonymitySet throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "lelantus.getanonymityset"; -// const jsonArgs = '["1",""]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect( -// () => client.getLelantusAnonymitySet( -// groupId: "1", -// requestID: "some requestId", -// ), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// group("getMintData", () { -// test("getMintData success", () async { -// final mockClient = MockJsonRPC(); -// const command = "lelantus.getmintmetadata"; -// const jsonArgs = '["some mints"]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": "mint meta data", -// "id": "some requestId" -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = await client.getLelantusMintData( -// mints: "some mints", requestID: "some requestId"); -// -// expect(result, "mint meta data"); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("getMintData throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "lelantus.getmintmetadata"; -// const jsonArgs = '["some mints"]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect( -// () => client.getLelantusMintData( -// mints: "some mints", -// requestID: "some requestId", -// ), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// group("getUsedCoinSerials", () { -// test("getUsedCoinSerials success", () async { -// final mockClient = MockJsonRPC(); -// const command = "lelantus.getusedcoinserials"; -// const jsonArgs = '["0"]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(minutes: 2), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": GetUsedSerialsSampleData.serials, -// "id": "some requestId" -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = await client.getLelantusUsedCoinSerials( -// requestID: "some requestId", startNumber: 0); -// -// expect(result, GetUsedSerialsSampleData.serials); -// -// verify(mockPrefs.wifiOnly).called(3); -// verify(mockPrefs.useTor).called(3); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("getUsedCoinSerials throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "lelantus.getusedcoinserials"; -// const jsonArgs = '["0"]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(minutes: 2), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect( -// () => client.getLelantusUsedCoinSerials( -// requestID: "some requestId", startNumber: 0), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// group("getLatestCoinId", () { -// test("getLatestCoinId success", () async { -// final mockClient = MockJsonRPC(); -// const command = "lelantus.getlatestcoinid"; -// const jsonArgs = '[]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": 1, -// "id": "some requestId", -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = -// await client.getLelantusLatestCoinId(requestID: "some requestId"); -// -// expect(result, 1); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("getLatestCoinId throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "lelantus.getlatestcoinid"; -// const jsonArgs = '[]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect(() => client.getLelantusLatestCoinId(requestID: "some requestId"), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// group("getFeeRate", () { -// test("getFeeRate success", () async { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.getfeerate"; -// const jsonArgs = '[]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": { -// "rate": 1000, -// }, -// "id": "some requestId" -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// final result = await client.getFeeRate(requestID: "some requestId"); -// -// expect(result, {"rate": 1000}); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// test("getFeeRate throws/fails", () { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.getfeerate"; -// const jsonArgs = '[]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenThrow(Exception()); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: torService, -// failovers: []); -// -// expect(() => client.getFeeRate(requestID: "some requestId"), -// throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// }); -// -// test("rpcClient is null throws with bad server info", () { -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((realInvocation) => false); -// final torService = MockTorService(); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final client = ElectrumXClient( -// client: null, -// port: -10, -// host: "_ :sa %", -// useSSL: false, -// prefs: mockPrefs, -// torService: torService, -// failovers: [], -// ); -// -// expect(() => client.getFeeRate(), throwsA(isA())); -// -// verify(mockPrefs.wifiOnly).called(1); -// verifyNoMoreInteractions(mockPrefs); -// }); -// -// group("Tor tests", () { -// // useTor is false, so no TorService calls should be made. -// test("Tor not in use", () async { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.transaction.get"; -// const jsonArgs = '["${SampleGetTransactionData.txHash0}",true]'; -// when(mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId","method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// )).thenAnswer((_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": SampleGetTransactionData.txData0, -// "id": "some requestId", -// })); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((_) => false); -// when(mockPrefs.torKillSwitch) -// .thenAnswer((_) => false); // Or true, shouldn't matter. -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final mockTorService = MockTorService(); -// when(mockTorService.status) -// .thenAnswer((_) => TorConnectionStatus.disconnected); -// -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// failovers: [], -// prefs: mockPrefs, -// torService: mockTorService, -// ); -// -// final result = await client.getTransaction( -// txHash: SampleGetTransactionData.txHash0, -// verbose: true, -// requestID: "some requestId"); -// -// expect(result, SampleGetTransactionData.txData0); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNever(mockPrefs.torKillSwitch); -// verifyNoMoreInteractions(mockPrefs); -// verifyNever(mockTorService.status); -// verifyNoMoreInteractions(mockTorService); -// }); -// -// // useTor is true, but TorService is not enabled and the killswitch is off, so a clearnet call should be made. -// test("Tor in use but Tor unavailable and killswitch off", () async { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.transaction.get"; -// const jsonArgs = '["${SampleGetTransactionData.txHash0}",true]'; -// when(mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId","method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// )).thenAnswer((_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": SampleGetTransactionData.txData0, -// "id": "some requestId", -// })); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((_) => true); -// when(mockPrefs.torKillSwitch).thenAnswer((_) => false); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// -// final mockTorService = MockTorService(); -// when(mockTorService.status) -// .thenAnswer((_) => TorConnectionStatus.disconnected); -// when(mockTorService.getProxyInfo()).thenAnswer((_) => ( -// host: InternetAddress('1.2.3.4'), -// port: -1 -// )); // Port is set to -1 until Tor is enabled. -// -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: mockTorService, -// failovers: []); -// -// final result = await client.getTransaction( -// txHash: SampleGetTransactionData.txHash0, -// verbose: true, -// requestID: "some requestId"); -// -// expect(result, SampleGetTransactionData.txData0); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verify(mockPrefs.torKillSwitch).called(1); -// verifyNoMoreInteractions(mockPrefs); -// verify(mockTorService.status).called(1); -// verifyNever(mockTorService.getProxyInfo()); -// verifyNoMoreInteractions(mockTorService); -// }); -// -// // useTor is true and TorService is enabled, so a TorService call should be made. -// test("Tor in use and available", () async { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.transaction.get"; -// const jsonArgs = '["${SampleGetTransactionData.txHash0}",true]'; -// when(mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId","method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// )).thenAnswer((_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": SampleGetTransactionData.txData0, -// "id": "some requestId", -// })); -// when(mockClient.proxyInfo) -// .thenAnswer((_) => (host: InternetAddress('1.2.3.4'), port: 42)); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((_) => true); -// when(mockPrefs.torKillSwitch).thenAnswer((_) => false); // Or true. -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// -// final mockTorService = MockTorService(); -// when(mockTorService.status) -// .thenAnswer((_) => TorConnectionStatus.connected); -// when(mockTorService.getProxyInfo()) -// .thenAnswer((_) => (host: InternetAddress('1.2.3.4'), port: 42)); -// -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// prefs: mockPrefs, -// torService: mockTorService, -// failovers: []); -// -// final result = await client.getTransaction( -// txHash: SampleGetTransactionData.txHash0, -// verbose: true, -// requestID: "some requestId"); -// -// expect(result, SampleGetTransactionData.txData0); -// -// verify(mockClient.proxyInfo).called(1); -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verifyNever(mockPrefs.torKillSwitch); -// verifyNoMoreInteractions(mockPrefs); -// verify(mockTorService.status).called(1); -// verify(mockTorService.getProxyInfo()).called(1); -// verifyNoMoreInteractions(mockTorService); -// }); -// -// // useTor is true, but TorService is not enabled and the killswitch is on, so no TorService calls should be made. -// test("killswitch enabled", () async { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.transaction.get"; -// const jsonArgs = '["",true]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "error": { -// "code": 1, -// "message": "None should be a transaction hash", -// }, -// "id": "some requestId", -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((_) => true); -// when(mockPrefs.torKillSwitch).thenAnswer((_) => true); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final mockTorService = MockTorService(); -// when(mockTorService.status) -// .thenAnswer((_) => TorConnectionStatus.disconnected); -// -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// failovers: [], -// prefs: mockPrefs, -// torService: mockTorService, -// ); -// -// try { -// var result = await client.getTransaction( -// requestID: "some requestId", txHash: ''); -// } catch (e) { -// expect(e, isA()); -// expect( -// e.toString(), -// equals( -// "Exception: Tor preference and killswitch set but Tor is not enabled, not connecting to ElectrumX")); -// } -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verify(mockPrefs.torKillSwitch).called(1); -// verifyNoMoreInteractions(mockPrefs); -// verify(mockTorService.status).called(1); -// verifyNoMoreInteractions(mockTorService); -// }); -// -// // useTor is true but Tor is not enabled, but because the killswitch is off, a clearnet call should be made. -// test("killswitch disabled", () async { -// final mockClient = MockJsonRPC(); -// const command = "blockchain.transaction.get"; -// const jsonArgs = '["${SampleGetTransactionData.txHash0}",true]'; -// when( -// mockClient.request( -// '{"jsonrpc": "2.0", "id": "some requestId",' -// '"method": "$command","params": $jsonArgs}', -// const Duration(seconds: 60), -// ), -// ).thenAnswer( -// (_) async => JsonRPCResponse(data: { -// "jsonrpc": "2.0", -// "result": SampleGetTransactionData.txData0, -// "id": "some requestId" -// }), -// ); -// -// final mockPrefs = MockPrefs(); -// when(mockPrefs.useTor).thenAnswer((_) => true); -// when(mockPrefs.torKillSwitch).thenAnswer((_) => false); -// when(mockPrefs.wifiOnly).thenAnswer((_) => false); -// final mockTorService = MockTorService(); -// when(mockTorService.status) -// .thenAnswer((_) => TorConnectionStatus.disconnected); -// -// final client = ElectrumXClient( -// host: "some server", -// port: 0, -// useSSL: true, -// client: mockClient, -// failovers: [], -// prefs: mockPrefs, -// torService: mockTorService, -// ); -// -// final result = await client.getTransaction( -// txHash: SampleGetTransactionData.txHash0, -// verbose: true, -// requestID: "some requestId"); -// -// expect(result, SampleGetTransactionData.txData0); -// -// verify(mockPrefs.wifiOnly).called(1); -// verify(mockPrefs.useTor).called(1); -// verify(mockPrefs.torKillSwitch).called(1); -// verifyNoMoreInteractions(mockPrefs); -// verify(mockTorService.status).called(1); -// verifyNoMoreInteractions(mockTorService); -// }); -// }); -// } +import 'dart:io'; + +import 'package:decimal/decimal.dart'; +import 'package:event_bus/event_bus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart' show Level; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; +import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; +import 'package:stackwallet/services/tor_service.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; +import 'package:stackwallet/wallets/crypto_currency/coins/bitcoin.dart'; +import 'package:stackwallet/wallets/crypto_currency/coins/firo.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +import 'sample_data/get_anonymity_set_sample_data.dart'; +import 'sample_data/get_used_serials_sample_data.dart'; +import 'sample_data/gethistory_samples.dart'; +import 'sample_data/transaction_data_samples.dart'; +import 'utilities/mock_electrum_server.dart'; + +class MockPrefs extends Mock implements Prefs { + @override + bool get wifiOnly => + super.noSuchMethod( + Invocation.getter(#wifiOnly), + returnValue: false, + returnValueForMissingStub: false, + ) + as bool; + + @override + bool get useTor => + super.noSuchMethod( + Invocation.getter(#useTor), + returnValue: false, + returnValueForMissingStub: false, + ) + as bool; + + @override + bool get torKillSwitch => + super.noSuchMethod( + Invocation.getter(#torKillSwitch), + returnValue: false, + returnValueForMissingStub: false, + ) + as bool; +} + +class FakeTorService implements TorService { + FakeTorService({ + this.currentStatus = TorConnectionStatus.disconnected, + ({InternetAddress host, int port})? proxyInfo, + }) : _proxyInfo = + proxyInfo ?? (host: InternetAddress.loopbackIPv4, port: 9050); + + TorConnectionStatus currentStatus; + ({InternetAddress host, int port}) _proxyInfo; + int statusReads = 0; + int proxyInfoReads = 0; + + @override + TorConnectionStatus get status { + statusReads++; + return currentStatus; + } + + void setProxyInfo(({InternetAddress host, int port}) proxyInfo) { + _proxyInfo = proxyInfo; + } + + @override + ({InternetAddress host, int port}) getProxyInfo() { + proxyInfoReads++; + return _proxyInfo; + } + + @override + Future disable() async {} + + @override + void init({required String torDataDirPath}) {} + + @override + Future start() async {} +} + +void main() { + late Directory logDir; + late MockPrefs prefs; + late FakeTorService torService; + late EventBus eventBus; + final servers = []; + + setUpAll(() async { + logDir = await Directory.systemTemp.createTemp('electrumx_test_logs'); + await Logging.instance.initialize(logDir.path, level: Level.off); + }); + + Bitcoin bitcoin() => Bitcoin(CryptoCurrencyNetwork.main); + Firo firo() => Firo(CryptoCurrencyNetwork.main); + + MockElectrumServer registerServer({ + Map handlers = const {}, + }) { + final server = MockElectrumServer(handlers: handlers); + servers.add(server); + return server; + } + + ManagedElectrumXClient buildClient({ + required MockElectrumServer clearServer, + MockElectrumServer? torServer, + required CryptoCurrency coin, + TorPlainNetworkOption netType = TorPlainNetworkOption.both, + }) { + return ManagedElectrumXClient( + host: 'mock.stackwallet.dev', + port: 50002, + useSSL: true, + prefs: prefs, + torService: torService, + failovers: [], + cryptoCurrency: coin, + netType: netType, + clearServer: clearServer, + torServer: torServer, + globalEventBusForTesting: eventBus, + ); + } + + Matcher throwsCurrentCastError() => throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('is not a subtype'), + ), + ); + + setUp(() { + prefs = MockPrefs(); + torService = FakeTorService(); + eventBus = EventBus(); + servers.clear(); + + when(prefs.wifiOnly).thenReturn(false); + when(prefs.useTor).thenReturn(false); + when(prefs.torKillSwitch).thenReturn(false); + }); + + tearDown(() async { + await tearDownManagedElectrum(servers: servers); + }); + + group('factory constructors and getters', () { + test('electrumxnode .from factory copies current fields', () { + final nodeA = ElectrumXNode( + address: 'some address', + port: 50002, + name: 'some name', + id: 'some ID', + useSSL: true, + torEnabled: true, + clearnetEnabled: false, + ); + + final nodeB = ElectrumXNode.from(nodeA); + + expect(nodeB.toString(), nodeA.toString()); + expect(nodeA == nodeB, false); + expect(nodeB.torEnabled, isTrue); + expect(nodeB.clearnetEnabled, isFalse); + }); + + test('electrumx .from factory uses current constructor inputs', () { + final node = ElectrumXNode( + address: 'some address', + port: 60001, + name: 'some name', + id: 'some ID', + useSSL: false, + torEnabled: false, + clearnetEnabled: true, + ); + + final client = ElectrumXClient.from( + node: node, + failovers: [], + prefs: prefs, + torService: torService, + globalEventBusForTesting: eventBus, + cryptoCurrency: bitcoin(), + ); + + expect(client.useSSL, isFalse); + expect(client.host, node.address); + expect(client.port, node.port); + expect(client.netType, TorPlainNetworkOption.clear); + expect(client.getElectrumAdapter(), isNull); + verifyNever(prefs.useTor); + expect(torService.statusReads, 0); + }); + }); + + group('generic request wrappers', () { + test('ping success uses the live adapter client', () async { + final server = registerServer(handlers: {'server.ping': (_) => null}); + final client = buildClient(clearServer: server, coin: bitcoin()); + + final result = await client.ping(requestID: 'ping-1'); + + expect(result, isTrue); + expect(server.requestCount('blockchain.headers.subscribe'), 1); + expect(server.requestCount('server.ping'), 1); + }); + + test('server.features success returns a parsed map', () async { + final expected = { + 'genesis_hash': 'genesis', + 'hosts': { + '0.0.0.0': {'tcp_port': 51001, 'ssl_port': 51002}, + }, + 'protocol_max': '1.4', + 'protocol_min': '1.0', + 'server_version': 'ElectrumX 1.0.17', + 'hash_function': 'sha256', + }; + final server = registerServer( + handlers: {'server.features': (_) => expected}, + ); + final client = buildClient(clearServer: server, coin: bitcoin()); + + final result = await client.getServerFeatures(requestID: 'features-1'); + + expect(result, expected); + expect(server.requestCount('server.features'), 1); + }); + + test('getTransaction supports verbose and raw responses', () async { + final server = registerServer( + handlers: { + 'blockchain.transaction.get': (params) { + if (params.last == false) { + return 'raw-transaction-hex'; + } + return SampleGetTransactionData.txData0; + }, + }, + ); + final client = buildClient(clearServer: server, coin: firo()); + + final verbose = await client.getTransaction( + txHash: SampleGetTransactionData.txHash0, + verbose: true, + requestID: 'tx-verbose', + ); + final raw = await client.getTransaction( + txHash: SampleGetTransactionData.txHash0, + verbose: false, + requestID: 'tx-raw', + ); + + expect(verbose, SampleGetTransactionData.txData0); + expect(raw, {'rawtx': 'raw-transaction-hex'}); + }); + + test('request surfaces server errors for malformed inputs', () async { + final server = registerServer( + handlers: { + 'blockchain.transaction.get': (_) => { + 'error': { + 'code': 1, + 'message': 'None should be a transaction hash', + }, + }, + }, + ); + final client = buildClient(clearServer: server, coin: bitcoin()); + + await expectLater( + () => client.request( + command: 'blockchain.transaction.get', + args: const ['', true], + requestID: 'bad-tx', + ), + throwsA(isA()), + ); + }); + + test('getHistory uses the current list payload', () async { + final server = registerServer( + handlers: { + 'blockchain.scripthash.get_history': (_) => + SampleGetHistoryData.data1, + }, + ); + final client = buildClient(clearServer: server, coin: firo()); + + final history = await client.getHistory( + scripthash: SampleGetHistoryData.scripthash1, + requestID: 'history-1', + ); + + expect(history, SampleGetHistoryData.data1); + expect(server.requestCount('blockchain.scripthash.get_history'), 1); + }); + + test('getHistory throws after retrying malformed payloads', () async { + final server = registerServer( + handlers: { + 'blockchain.scripthash.get_history': (_) => {'unexpected': true}, + }, + ); + final client = buildClient(clearServer: server, coin: firo()); + + await expectLater( + () => client.getHistory( + scripthash: SampleGetHistoryData.scripthash1, + requestID: 'history-bad', + ), + throwsCurrentCastError(), + ); + expect(server.requestCount('blockchain.scripthash.get_history'), 3); + }); + + test('fee wrappers use the current adapter command names', () async { + final server = registerServer( + handlers: { + 'blockchain.getfeerate': (_) => {'rate': 1000}, + 'blockchain.estimatefee': (params) { + expect(params, [5]); + return '0.00001000'; + }, + 'blockchain.relayfee': (_) => '0.00002000', + }, + ); + final client = buildClient(clearServer: server, coin: firo()); + + final feeRate = await client.getFeeRate(requestID: 'fee-rate'); + final estimate = await client.estimateFee( + requestID: 'estimate-1', + blocks: 5, + ); + final relay = await client.relayFee(requestID: 'relay-1'); + + expect(feeRate, {'rate': 1000}); + expect(estimate, Decimal.parse('0.00001000')); + expect(relay, Decimal.parse('0.00002000')); + expect(server.requestCount('blockchain.getfeerate'), 1); + expect(server.requestCount('blockchain.estimatefee'), 1); + expect(server.requestCount('blockchain.relayfee'), 1); + }); + + test('bad server exceptions bubble from current public wrappers', () async { + final server = registerServer( + handlers: { + 'server.features': (_) => throw Exception('mock bad server'), + }, + ); + final client = buildClient(clearServer: server, coin: bitcoin()); + + await expectLater( + () => client.getServerFeatures(requestID: 'features-bad'), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('mock bad server'), + ), + ), + ); + }); + }); + + group('Firo-specific wrappers', () { + test( + 'Lelantus wrappers use the current payloads and request shapes', + () async { + const requestedMints = ['mint-a', 'mint-b']; + final mintMetadata = { + 'mint-a': {'groupId': 1, 'height': 455866}, + 'mint-b': {'groupId': 2, 'height': 455876}, + }; + final server = registerServer( + handlers: { + 'lelantus.getanonymityset': (params) { + expect(params, ['1', '']); + return GetAnonymitySetSampleData.data; + }, + 'lelantus.getmintmetadata': (params) { + expect(params, [requestedMints]); + return mintMetadata; + }, + 'lelantus.getusedcoinserials': (params) { + expect(params, ['0']); + return GetUsedSerialsSampleData.serials; + }, + 'lelantus.getlatestcoinid': (_) => 42, + }, + ); + final client = buildClient(clearServer: server, coin: firo()); + + final anonymitySet = await client.getLelantusAnonymitySet( + groupId: '1', + blockhash: '', + requestID: 'set-1', + ); + final mintData = await client.getLelantusMintData( + mints: requestedMints, + requestID: 'mint-1', + ); + final serials = await client.getLelantusUsedCoinSerials( + requestID: 'serials-1', + startNumber: 0, + ); + final latest = await client.getLelantusLatestCoinId(requestID: 'id-1'); + + expect(anonymitySet, GetAnonymitySetSampleData.data); + expect(mintData, mintMetadata); + expect(serials, GetUsedSerialsSampleData.serials); + expect(latest, 42); + expect(server.requestCount('lelantus.getanonymityset'), 1); + expect(server.requestCount('lelantus.getmintmetadata'), 1); + expect(server.requestCount('lelantus.getusedcoinserials'), 3); + expect(server.requestCount('lelantus.getlatestcoinid'), 1); + }, + ); + + test('Lelantus wrappers surface current failure modes', () async { + final server = registerServer( + handlers: { + 'lelantus.getmintmetadata': (_) => + throw Exception('mint metadata unavailable'), + 'lelantus.getusedcoinserials': (_) => ['not-a-map'], + 'lelantus.getlatestcoinid': (_) => 'forty-two', + }, + ); + final client = buildClient(clearServer: server, coin: firo()); + + await expectLater( + () => client.getLelantusMintData( + mints: const ['mint-a'], + requestID: 'mint-bad', + ), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('mint metadata unavailable'), + ), + ), + ); + await expectLater( + () => client.getLelantusUsedCoinSerials( + requestID: 'serials-bad', + startNumber: 0, + ), + throwsCurrentCastError(), + ); + await expectLater( + () => client.getLelantusLatestCoinId(requestID: 'id-bad'), + throwsCurrentCastError(), + ); + expect(server.requestCount('lelantus.getusedcoinserials'), 1); + }); + }); + + group('Tor tests', () { + test('Tor not in use', () async { + when(prefs.useTor).thenReturn(false); + when(prefs.torKillSwitch).thenReturn(false); + + final clearServer = registerServer( + handlers: { + 'blockchain.transaction.get': (_) => SampleGetTransactionData.txData0, + }, + ); + final torServer = registerServer( + handlers: { + 'blockchain.transaction.get': (_) => {'unexpected': true}, + }, + ); + + final client = buildClient( + clearServer: clearServer, + torServer: torServer, + coin: firo(), + ); + + final result = await client.getTransaction( + txHash: SampleGetTransactionData.txHash0, + verbose: true, + requestID: 'tor-off', + ); + + expect(result, SampleGetTransactionData.txData0); + expect(clearServer.requestCount('blockchain.transaction.get'), 1); + expect(torServer.requestCount('blockchain.transaction.get'), 0); + verify(prefs.useTor).called(greaterThanOrEqualTo(1)); + expect(torService.statusReads, 0); + expect(torService.proxyInfoReads, 0); + }); + + test( + 'Tor in use but unavailable and killswitch off uses clearnet', + () async { + when(prefs.useTor).thenReturn(true); + when(prefs.torKillSwitch).thenReturn(false); + torService.currentStatus = TorConnectionStatus.disconnected; + + final clearServer = registerServer( + handlers: { + 'blockchain.transaction.get': (_) => + SampleGetTransactionData.txData0, + }, + ); + final torServer = registerServer( + handlers: { + 'blockchain.transaction.get': (_) => {'unexpected': true}, + }, + ); + + final client = buildClient( + clearServer: clearServer, + torServer: torServer, + coin: firo(), + ); + + final result = await client.getTransaction( + txHash: SampleGetTransactionData.txHash0, + verbose: true, + requestID: 'tor-fallback', + ); + + expect(result, SampleGetTransactionData.txData0); + expect(clearServer.requestCount('blockchain.transaction.get'), 1); + expect(torServer.requestCount('blockchain.transaction.get'), 0); + expect(torService.statusReads, greaterThanOrEqualTo(1)); + expect(torService.proxyInfoReads, 0); + }, + ); + + test('Tor in use and available uses the tor-backed adapter', () async { + when(prefs.useTor).thenReturn(true); + torService.currentStatus = TorConnectionStatus.connected; + torService.setProxyInfo((host: InternetAddress.loopbackIPv4, port: 9050)); + + final clearServer = registerServer( + handlers: { + 'blockchain.transaction.get': (_) => {'unexpected': true}, + }, + ); + final torServer = registerServer( + handlers: { + 'blockchain.transaction.get': (_) => SampleGetTransactionData.txData0, + }, + ); + + final client = buildClient( + clearServer: clearServer, + torServer: torServer, + coin: firo(), + ); + + final result = await client.getTransaction( + txHash: SampleGetTransactionData.txHash0, + verbose: true, + requestID: 'tor-on', + ); + + expect(result, SampleGetTransactionData.txData0); + expect(clearServer.requestCount('blockchain.transaction.get'), 0); + expect(torServer.requestCount('blockchain.transaction.get'), 1); + expect(torService.statusReads, greaterThanOrEqualTo(1)); + expect(torService.proxyInfoReads, greaterThanOrEqualTo(1)); + }); + + test('killswitch enabled throws before any adapter request', () async { + when(prefs.useTor).thenReturn(true); + when(prefs.torKillSwitch).thenReturn(true); + torService.currentStatus = TorConnectionStatus.disconnected; + + final clearServer = registerServer( + handlers: { + 'blockchain.transaction.get': (_) => SampleGetTransactionData.txData0, + }, + ); + + final client = buildClient(clearServer: clearServer, coin: firo()); + + await expectLater( + () => client.getTransaction( + txHash: SampleGetTransactionData.txHash0, + verbose: true, + requestID: 'tor-killswitch', + ), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains( + 'Tor preference and killswitch set but Tor is not enabled', + ), + ), + ), + ); + expect(clearServer.requestCount('blockchain.transaction.get'), 0); + }); + }); +} diff --git a/test/hive/hive_ce_test_utils.dart b/test/hive/hive_ce_test_utils.dart new file mode 100644 index 0000000000..3ac973cca8 --- /dev/null +++ b/test/hive/hive_ce_test_utils.dart @@ -0,0 +1,82 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:hive_ce/hive.dart'; +import 'package:stackwallet/db/hive/db.dart'; + +const _helperPath = 'test/hive/hive_ce_test_utils.dart'; +const _defaultHiveCeTestTimeout = Duration(seconds: 15); + +Directory? _testHiveDirectory; + +Future setUpHiveCeTest({ + Duration timeout = _defaultHiveCeTestTimeout, +}) async { + if (_testHiveDirectory != null) { + throw StateError( + '$_helperPath [init]: previous Hive CE temp directory ' + '"${_testHiveDirectory!.path}" was not cleaned up before reinitialization.', + ); + } + + try { + await (() async { + final tempDirectory = await Directory.systemTemp.createTemp( + 'stack_wallet_hive_ce_test_', + ); + Hive.init(tempDirectory.path); + DB.instance.hive.init(tempDirectory.path); + _testHiveDirectory = tempDirectory; + })().timeout( + timeout, + onTimeout: () => throw TimeoutException( + '$_helperPath [init]: timed out after ${timeout.inSeconds}s.', + ), + ); + } catch (error, stackTrace) { + final tempDirectory = _testHiveDirectory; + _testHiveDirectory = null; + + if (tempDirectory != null && await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + + Error.throwWithStackTrace( + StateError('$_helperPath [init]: $error'), + stackTrace, + ); + } +} + +Future tearDownHiveCeTest({ + Duration timeout = _defaultHiveCeTestTimeout, +}) async { + final tempDirectory = _testHiveDirectory; + if (tempDirectory == null) { + throw StateError( + '$_helperPath [cleanup]: called before setUpHiveCeTest().', + ); + } + + _testHiveDirectory = null; + + try { + await (() async { + await DB.instance.hive.close(); + await Hive.close(); + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + })().timeout( + timeout, + onTimeout: () => throw TimeoutException( + '$_helperPath [cleanup]: timed out after ${timeout.inSeconds}s.', + ), + ); + } catch (error, stackTrace) { + Error.throwWithStackTrace( + StateError('$_helperPath [cleanup]: $error'), + stackTrace, + ); + } +} diff --git a/test/pages/send_view/send_view_test.mocks.dart b/test/pages/send_view/send_view_test.mocks.dart index 91fa2a84e8..8df86739df 100644 --- a/test/pages/send_view/send_view_test.mocks.dart +++ b/test/pages/send_view/send_view_test.mocks.dart @@ -4,23 +4,24 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i10; -import 'dart:typed_data' as _i19; -import 'dart:ui' as _i14; +import 'dart:typed_data' as _i20; +import 'dart:ui' as _i15; -import 'package:logger/logger.dart' as _i22; +import 'package:logger/logger.dart' as _i23; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i16; +import 'package:mockito/src/dummies.dart' as _i17; import 'package:stackwallet/db/isar/main_db.dart' as _i3; -import 'package:stackwallet/models/isar/stack_theme.dart' as _i18; +import 'package:stackwallet/models/epicbox_server_model.dart' as _i14; +import 'package:stackwallet/models/isar/stack_theme.dart' as _i19; import 'package:stackwallet/models/node_model.dart' as _i13; import 'package:stackwallet/networking/http.dart' as _i7; -import 'package:stackwallet/services/locale_service.dart' as _i15; +import 'package:stackwallet/services/locale_service.dart' as _i16; import 'package:stackwallet/services/node_service.dart' as _i2; import 'package:stackwallet/services/wallets.dart' as _i9; -import 'package:stackwallet/themes/theme_service.dart' as _i17; -import 'package:stackwallet/utilities/amount/amount_unit.dart' as _i23; -import 'package:stackwallet/utilities/enums/backup_frequency_type.dart' as _i21; -import 'package:stackwallet/utilities/enums/sync_type_enum.dart' as _i20; +import 'package:stackwallet/themes/theme_service.dart' as _i18; +import 'package:stackwallet/utilities/amount/amount_unit.dart' as _i24; +import 'package:stackwallet/utilities/enums/backup_frequency_type.dart' as _i22; +import 'package:stackwallet/utilities/enums/sync_type_enum.dart' as _i21; import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart' as _i6; import 'package:stackwallet/utilities/prefs.dart' as _i12; @@ -320,6 +321,64 @@ class MockNodeService extends _i1.Mock implements _i2.NodeService { ) as _i10.Future); + @override + _i10.Future updateDefaultEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#updateDefaultEpicBoxes, []), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); + + @override + _i10.Future setPrimaryEpicBox({ + required _i14.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners = false, + }) => + (super.noSuchMethod( + Invocation.method(#setPrimaryEpicBox, [], { + #epicBox: epicBox, + #shouldNotifyListeners: shouldNotifyListeners, + }), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); + + @override + List<_i14.EpicBoxServerModel> getEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#getEpicBoxes, []), + returnValue: <_i14.EpicBoxServerModel>[], + ) + as List<_i14.EpicBoxServerModel>); + + @override + _i14.EpicBoxServerModel? getEpicBoxById({required String? id}) => + (super.noSuchMethod(Invocation.method(#getEpicBoxById, [], {#id: id})) + as _i14.EpicBoxServerModel?); + + @override + _i10.Future addEpicBox( + _i14.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners, + ) => + (super.noSuchMethod( + Invocation.method(#addEpicBox, [epicBox, shouldNotifyListeners]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); + + @override + _i10.Future deleteEpicBox(String? id, bool? shouldNotifyListeners) => + (super.noSuchMethod( + Invocation.method(#deleteEpicBox, [id, shouldNotifyListeners]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); + @override _i10.Future updateCommunityNodes() => (super.noSuchMethod( @@ -330,13 +389,13 @@ class MockNodeService extends _i1.Mock implements _i2.NodeService { as _i10.Future); @override - void addListener(_i14.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i15.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#addListener, [listener]), returnValueForMissingStub: null, ); @override - void removeListener(_i14.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i15.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#removeListener, [listener]), returnValueForMissingStub: null, ); @@ -357,7 +416,7 @@ class MockNodeService extends _i1.Mock implements _i2.NodeService { /// A class which mocks [LocaleService]. /// /// See the documentation for Mockito's code generation for more information. -class MockLocaleService extends _i1.Mock implements _i15.LocaleService { +class MockLocaleService extends _i1.Mock implements _i16.LocaleService { MockLocaleService() { _i1.throwOnMissingStub(this); } @@ -366,7 +425,7 @@ class MockLocaleService extends _i1.Mock implements _i15.LocaleService { String get locale => (super.noSuchMethod( Invocation.getter(#locale), - returnValue: _i16.dummyValue( + returnValue: _i17.dummyValue( this, Invocation.getter(#locale), ), @@ -388,13 +447,13 @@ class MockLocaleService extends _i1.Mock implements _i15.LocaleService { as _i10.Future); @override - void addListener(_i14.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i15.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#addListener, [listener]), returnValueForMissingStub: null, ); @override - void removeListener(_i14.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i15.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#removeListener, [listener]), returnValueForMissingStub: null, ); @@ -415,7 +474,7 @@ class MockLocaleService extends _i1.Mock implements _i15.LocaleService { /// A class which mocks [ThemeService]. /// /// See the documentation for Mockito's code generation for more information. -class MockThemeService extends _i1.Mock implements _i17.ThemeService { +class MockThemeService extends _i1.Mock implements _i18.ThemeService { MockThemeService() { _i1.throwOnMissingStub(this); } @@ -437,12 +496,12 @@ class MockThemeService extends _i1.Mock implements _i17.ThemeService { as _i7.HTTP); @override - List<_i18.StackTheme> get installedThemes => + List<_i19.StackTheme> get installedThemes => (super.noSuchMethod( Invocation.getter(#installedThemes), - returnValue: <_i18.StackTheme>[], + returnValue: <_i19.StackTheme>[], ) - as List<_i18.StackTheme>); + as List<_i19.StackTheme>); @override set client(_i7.HTTP? value) => super.noSuchMethod( @@ -457,7 +516,7 @@ class MockThemeService extends _i1.Mock implements _i17.ThemeService { ); @override - _i10.Future install({required _i19.Uint8List? themeArchiveData}) => + _i10.Future install({required _i20.Uint8List? themeArchiveData}) => (super.noSuchMethod( Invocation.method(#install, [], { #themeArchiveData: themeArchiveData, @@ -494,29 +553,29 @@ class MockThemeService extends _i1.Mock implements _i17.ThemeService { as _i10.Future); @override - _i10.Future> fetchThemes() => + _i10.Future> fetchThemes() => (super.noSuchMethod( Invocation.method(#fetchThemes, []), - returnValue: _i10.Future>.value( - <_i17.StackThemeMetaData>[], + returnValue: _i10.Future>.value( + <_i18.StackThemeMetaData>[], ), ) - as _i10.Future>); + as _i10.Future>); @override - _i10.Future<_i19.Uint8List> fetchTheme({ - required _i17.StackThemeMetaData? themeMetaData, + _i10.Future<_i20.Uint8List> fetchTheme({ + required _i18.StackThemeMetaData? themeMetaData, }) => (super.noSuchMethod( Invocation.method(#fetchTheme, [], {#themeMetaData: themeMetaData}), - returnValue: _i10.Future<_i19.Uint8List>.value(_i19.Uint8List(0)), + returnValue: _i10.Future<_i20.Uint8List>.value(_i20.Uint8List(0)), ) - as _i10.Future<_i19.Uint8List>); + as _i10.Future<_i20.Uint8List>); @override - _i18.StackTheme? getTheme({required String? themeId}) => + _i19.StackTheme? getTheme({required String? themeId}) => (super.noSuchMethod(Invocation.method(#getTheme, [], {#themeId: themeId})) - as _i18.StackTheme?); + as _i19.StackTheme?); } /// A class which mocks [Prefs]. @@ -562,12 +621,12 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { as List); @override - _i20.SyncingType get syncType => + _i21.SyncingType get syncType => (super.noSuchMethod( Invocation.getter(#syncType), - returnValue: _i20.SyncingType.currentWalletOnly, + returnValue: _i21.SyncingType.currentWalletOnly, ) - as _i20.SyncingType); + as _i21.SyncingType); @override bool get wifiOnly => @@ -586,7 +645,7 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { String get language => (super.noSuchMethod( Invocation.getter(#language), - returnValue: _i16.dummyValue( + returnValue: _i17.dummyValue( this, Invocation.getter(#language), ), @@ -597,7 +656,7 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { String get currency => (super.noSuchMethod( Invocation.getter(#currency), - returnValue: _i16.dummyValue( + returnValue: _i17.dummyValue( this, Invocation.getter(#currency), ), @@ -659,12 +718,12 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { as bool); @override - _i21.BackupFrequencyType get backupFrequencyType => + _i22.BackupFrequencyType get backupFrequencyType => (super.noSuchMethod( Invocation.getter(#backupFrequencyType), - returnValue: _i21.BackupFrequencyType.everyTenMinutes, + returnValue: _i22.BackupFrequencyType.everyTenMinutes, ) - as _i21.BackupFrequencyType); + as _i22.BackupFrequencyType); @override bool get hideBlockExplorerWarning => @@ -707,7 +766,7 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { String get themeId => (super.noSuchMethod( Invocation.getter(#themeId), - returnValue: _i16.dummyValue( + returnValue: _i17.dummyValue( this, Invocation.getter(#themeId), ), @@ -718,7 +777,7 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { String get systemBrightnessLightThemeId => (super.noSuchMethod( Invocation.getter(#systemBrightnessLightThemeId), - returnValue: _i16.dummyValue( + returnValue: _i17.dummyValue( this, Invocation.getter(#systemBrightnessLightThemeId), ), @@ -729,7 +788,7 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { String get systemBrightnessDarkThemeId => (super.noSuchMethod( Invocation.getter(#systemBrightnessDarkThemeId), - returnValue: _i16.dummyValue( + returnValue: _i17.dummyValue( this, Invocation.getter(#systemBrightnessDarkThemeId), ), @@ -763,12 +822,12 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { as bool); @override - _i22.Level get logLevel => + _i23.Level get logLevel => (super.noSuchMethod( Invocation.getter(#logLevel), - returnValue: _i22.Level.all, + returnValue: _i23.Level.all, ) - as _i22.Level); + as _i23.Level); @override ({bool enabled, int minutes}) get autoLockInfo => @@ -778,6 +837,19 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -798,7 +870,7 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { ); @override - set syncType(_i20.SyncingType? syncType) => super.noSuchMethod( + set syncType(_i21.SyncingType? syncType) => super.noSuchMethod( Invocation.setter(#syncType, syncType), returnValueForMissingStub: null, ); @@ -888,7 +960,7 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { ); @override - set backupFrequencyType(_i21.BackupFrequencyType? backupFrequencyType) => + set backupFrequencyType(_i22.BackupFrequencyType? backupFrequencyType) => super.noSuchMethod( Invocation.setter(#backupFrequencyType, backupFrequencyType), returnValueForMissingStub: null, @@ -995,7 +1067,7 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { ); @override - set logLevel(_i22.Level? logLevel) => super.noSuchMethod( + set logLevel(_i23.Level? logLevel) => super.noSuchMethod( Invocation.setter(#logLevel, logLevel), returnValueForMissingStub: null, ); @@ -1007,6 +1079,18 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) @@ -1022,13 +1106,12 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { as _i10.Future); @override - _i10.Future incrementCurrentNotificationIndex() => + _i10.Future incrementCurrentNotificationIndex() => (super.noSuchMethod( Invocation.method(#incrementCurrentNotificationIndex, []), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), + returnValue: _i10.Future.value(0), ) - as _i10.Future); + as _i10.Future); @override _i10.Future isExternalCallsSet() => @@ -1057,17 +1140,17 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { as _i10.Future); @override - _i23.AmountUnit amountUnit(_i4.CryptoCurrency? coin) => + _i24.AmountUnit amountUnit(_i4.CryptoCurrency? coin) => (super.noSuchMethod( Invocation.method(#amountUnit, [coin]), - returnValue: _i23.AmountUnit.normal, + returnValue: _i24.AmountUnit.normal, ) - as _i23.AmountUnit); + as _i24.AmountUnit); @override void updateAmountUnit({ required _i4.CryptoCurrency? coin, - required _i23.AmountUnit? amountUnit, + required _i24.AmountUnit? amountUnit, }) => super.noSuchMethod( Invocation.method(#updateAmountUnit, [], { #coin: coin, @@ -1117,13 +1200,13 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { ); @override - void addListener(_i14.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i15.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#addListener, [listener]), returnValueForMissingStub: null, ); @override - void removeListener(_i14.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i15.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#removeListener, [listener]), returnValueForMissingStub: null, ); diff --git a/test/paynym_p2tr_test.dart b/test/paynym_p2tr_test.dart new file mode 100644 index 0000000000..e4fd1abb0d --- /dev/null +++ b/test/paynym_p2tr_test.dart @@ -0,0 +1,118 @@ +import 'package:bip32/bip32.dart' as bip32; +import 'package:bip39/bip39.dart' as bip39; +import 'package:bip47/bip47.dart'; +import 'package:bitcoindart/bitcoindart.dart' as bitcoindart; +import 'package:stackwallet/models/paynym/paynym_account_lite.dart'; +import 'package:test/test.dart'; + +void main() { + const mnemonic = + 'response seminar brave million suit skate inhale proud weapon daring champion'; + + final networkType = bip32.NetworkType( + wif: bitcoindart.bitcoin.wif, + bip32: bip32.Bip32Type( + public: bitcoindart.bitcoin.bip32.public, + private: bitcoindart.bitcoin.bip32.private, + ), + ); + + late String v1PaymentCodeString; + late String taprootPaymentCodeString; + + setUpAll(() { + final seed = bip39.mnemonicToSeed(mnemonic); + final root = bip32.BIP32.fromSeed(seed, networkType); + final paymentCodeNode = root.derivePath("m/47'/0'/0'"); + + // Build a standard v1 payment code (no taproot, no segwit). + final v1Code = PaymentCode.fromBip32Node( + paymentCodeNode, + networkType: bitcoindart.bitcoin, + shouldSetSegwitBit: false, + ); + v1PaymentCodeString = v1Code.toString(); + + // Build a taproot-enabled payment code. + final taprootCode = PaymentCode.fromBip32Node( + paymentCodeNode, + networkType: bitcoindart.bitcoin, + shouldSetSegwitBit: true, + shouldSetTaprootBit: true, + ); + taprootPaymentCodeString = taprootCode.toString(); + }); + + group('PaynymAccountLite taproot inference', () { + test('inferTaproot returns true for taproot-enabled payment code', () { + final result = PaynymAccountLite.inferTaproot(taprootPaymentCodeString); + expect(result, isTrue); + }); + + test('inferTaproot returns false for standard v1 payment code', () { + final result = PaynymAccountLite.inferTaproot(v1PaymentCodeString); + expect(result, isFalse); + }); + + test('inferTaproot returns false for invalid payment code string', () { + final result = PaynymAccountLite.inferTaproot('not-a-payment-code'); + expect(result, isFalse); + }); + }); + + group('PaynymAccountLite.fromMap taproot inference', () { + test('fromMap infers taproot=true when taproot key is absent ' + 'but payment code has taproot bit set', () { + final map = { + 'nymId': 'test-id', + 'nymName': 'test-name', + 'code': taprootPaymentCodeString, + 'segwit': true, + // No 'taproot' key — should be inferred from the code. + }; + + final account = PaynymAccountLite.fromMap(map); + expect(account.taproot, isTrue); + }); + + test('fromMap infers taproot=false when taproot key is absent ' + 'and payment code does not have taproot bit set', () { + final map = { + 'nymId': 'test-id', + 'nymName': 'test-name', + 'code': v1PaymentCodeString, + 'segwit': false, + // No 'taproot' key — should be inferred from the code. + }; + + final account = PaynymAccountLite.fromMap(map); + expect(account.taproot, isFalse); + }); + + test('fromMap uses explicit taproot=true from map when provided', () { + final map = { + 'nymId': 'test-id', + 'nymName': 'test-name', + 'code': v1PaymentCodeString, + 'segwit': false, + 'taproot': true, + }; + + final account = PaynymAccountLite.fromMap(map); + expect(account.taproot, isTrue); + }); + + test('fromMap uses explicit taproot=false from map when provided', () { + final map = { + 'nymId': 'test-id', + 'nymName': 'test-name', + 'code': taprootPaymentCodeString, + 'segwit': true, + 'taproot': false, + }; + + final account = PaynymAccountLite.fromMap(map); + expect(account.taproot, isFalse); + }); + }); +} diff --git a/test/price_test.dart b/test/price_test.dart index e7a8b401a4..468295b79b 100644 --- a/test/price_test.dart +++ b/test/price_test.dart @@ -4,22 +4,21 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; -import 'package:hive_test/hive_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:stackwallet/db/hive/db.dart'; import 'package:stackwallet/networking/http.dart'; import 'package:stackwallet/services/price.dart'; +import 'hive/hive_ce_test_utils.dart'; import 'price_test.mocks.dart'; @GenerateMocks([HTTP]) void main() { setUp(() async { - await setUpTestHive(); - await Hive.openBox(DB.boxNamePriceCache); - await Hive.openBox(DB.boxNamePrefs); + await setUpHiveCeTest(); + await DB.instance.hive.openBox(DB.boxNamePriceCache); + await DB.instance.hive.openBox(DB.boxNamePrefs); await DB.instance.put( boxName: DB.boxNamePrefs, key: "externalCalls", @@ -27,19 +26,71 @@ void main() { ); }); + void expectFetchedPriceSnapshot(String prices) { + expect( + prices, + contains("Instance of 'Bitcoin': (change24h: 0.0, value: 1)"), + ); + expect( + prices, + contains( + "Instance of 'Monero': (change24h: -0.77656, value: 0.00717236)", + ), + ); + expect( + prices, + contains( + "Instance of 'Dogecoin': (change24h: -2.68533, value: 0.00000315)", + ), + ); + expect( + prices, + contains( + "Instance of 'Epiccash': (change24h: 7.27524, value: 0.00002803)", + ), + ); + expect( + prices, + contains("Instance of 'Firo': (change24h: -0.89304, value: 0.0001096)"), + ); + expect( + prices, + contains("Instance of 'Xelis': (change24h: 5.67, value: 0.00001234)"), + ); + expect( + prices, + contains("Instance of 'Cardano': (change24h: 0.0, value: 0)"), + ); + expect( + prices, + contains("Instance of 'Fact0rn': (change24h: 0.0, value: 0)"), + ); + expect( + prices, + contains("Instance of 'Peercoin': (change24h: 0.0, value: 0)"), + ); + expect( + prices, + contains("Instance of 'Salvium': (change24h: 0.0, value: 0)"), + ); + expect( + prices, + contains("Instance of 'Solana': (change24h: 0.0, value: 0)"), + ); + expect(prices, isNot('{}')); + } + + void expectEmptyPriceSnapshot(String prices) { + expect(prices, '{}'); + } + test("getPricesAnd24hChange fetch", () async { final client = MockHTTP(); when( client.get( proxyInfo: null, - url: Uri.parse( - "https://api.coingecko.com/api/v3/coins/markets?vs_currency=btc&ids" - "=monero,bitcoin,litecoin,ecash,epic-cash,zcoin,dogecoin,bitcoin-cash" - ",namecoin,wownero,ethereum,particl,nano,banano,stellar,tezos,xelis" - "&order=market_cap_desc&per_page=50" - "&page=1&sparkline=false", - ), + url: anyNamed('url'), headers: {'Content-Type': 'application/json'}, ), ).thenAnswer( @@ -115,44 +166,11 @@ void main() { final price = await priceAPI.getPricesAnd24hChange(baseCurrency: "btc"); - expect( - price.toString(), - '{' - 'Coin.bitcoin: [1, 0.0], ' - 'Coin.monero: [0.00717236, -0.77656], ' - 'Coin.banano: [0, 0.0], ' - 'Coin.bitcoincash: [0, 0.0], ' - 'Coin.dogecoin: [0.00000315, -2.68533], ' - 'Coin.eCash: [0, 0.0], ' - 'Coin.epicCash: [0.00002803, 7.27524], ' - 'Coin.ethereum: [0, 0.0], ' - 'Coin.firo: [0.0001096, -0.89304], ' - 'Coin.litecoin: [0, 0.0], ' - 'Coin.namecoin: [0, 0.0], ' - 'Coin.nano: [0, 0.0], ' - 'Coin.particl: [0, 0.0], ' - 'Coin.stellar: [0, 0.0], ' - 'Coin.tezos: [0, 0.0], ' - 'Coin.wownero: [0, 0.0], ' - 'Coin.bitcoinTestNet: [0, 0.0], ' - 'Coin.bitcoincashTestnet: [0, 0.0], ' - 'Coin.dogecoinTestNet: [0, 0.0], ' - 'Coin.firoTestNet: [0, 0.0], ' - 'Coin.litecoinTestNet: [0, 0.0], ' - 'Coin.stellarTestnet: [0, 0.0], ' - 'Coin.xelis: [0.00001234, 5.67]' - '}', - ); + expectFetchedPriceSnapshot(price.toString()); verify( client.get( proxyInfo: null, - url: Uri.parse( - "https://api.coingecko.com/api/v3/coins/markets?vs_currency=btc" - "&ids=monero,bitcoin,litecoin,ecash,epic-cash,zcoin,dogecoin," - "bitcoin-cash,namecoin,wownero,ethereum,particl,nano,banano,stellar" - ",tezos,xelis" - "&order=market_cap_desc&per_page=50&page=1&sparkline=false", - ), + url: anyNamed('url'), headers: {'Content-Type': 'application/json'}, ), ).called(1); @@ -166,13 +184,7 @@ void main() { when( client.get( proxyInfo: null, - url: Uri.parse( - "https://api.coingecko.com/api/v3/coins/markets?vs_currency=btc&" - "ids=monero,bitcoin,litecoin,ecash,epic-cash,zcoin,dogecoin," - "bitcoin-cash,namecoin,wownero,ethereum,particl,nano,banano,stellar" - ",tezos,xelis" - "&order=market_cap_desc&per_page=50&page=1&sparkline=false", - ), + url: anyNamed('url'), headers: {'Content-Type': 'application/json'}, ), ).thenAnswer( @@ -254,43 +266,13 @@ void main() { baseCurrency: "btc", ); - expect( - cachedPrice.toString(), - '{' - 'Coin.bitcoin: [1, 0.0], ' - 'Coin.monero: [0.00717236, -0.77656], ' - 'Coin.banano: [0, 0.0], Coin.bitcoincash: [0, 0.0], ' - 'Coin.dogecoin: [0.00000315, -2.68533], ' - 'Coin.eCash: [0, 0.0], ' - 'Coin.epicCash: [0.00002803, 7.27524], Coin.ethereum: [0, 0.0], ' - 'Coin.firo: [0.0001096, -0.89304], ' - 'Coin.litecoin: [0, 0.0], ' - 'Coin.namecoin: [0, 0.0], ' - 'Coin.nano: [0, 0.0], ' - 'Coin.particl: [0, 0.0], ' - 'Coin.stellar: [0, 0.0], ' - 'Coin.tezos: [0, 0.0], ' - 'Coin.wownero: [0, 0.0], ' - 'Coin.bitcoinTestNet: [0, 0.0], ' - 'Coin.bitcoincashTestnet: [0, 0.0], Coin.dogecoinTestNet: [0, 0.0], ' - 'Coin.firoTestNet: [0, 0.0], ' - 'Coin.litecoinTestNet: [0, 0.0], ' - 'Coin.stellarTestnet: [0, 0.0], ' - 'Coin.xelis: [0.00001234, 5.67]' - '}', - ); + expectFetchedPriceSnapshot(cachedPrice.toString()); // verify only called once during filling of cache verify( client.get( proxyInfo: null, - url: Uri.parse( - "https://api.coingecko.com/api/v3/coins/markets?vs_currency=btc&ids" - "=monero,bitcoin,litecoin,ecash,epic-cash,zcoin,dogecoin," - "bitcoin-cash,namecoin,wownero,ethereum,particl,nano,banano,stellar" - ",tezos,xelis" - "&order=market_cap_desc&per_page=50&page=1&sparkline=false", - ), + url: anyNamed('url'), headers: {'Content-Type': 'application/json'}, ), ).called(1); @@ -304,13 +286,7 @@ void main() { when( client.get( proxyInfo: null, - url: Uri.parse( - "https://api.coingecko.com/api/v3/coins/markets?vs_currency=btc" - "&ids=monero,bitcoin,litecoin,ecash,epic-cash,zcoin,dogecoin," - "bitcoin-cash,namecoin,wownero,ethereum,particl,nano,banano,stellar" - ",tezos,xelis" - "&order=market_cap_desc&per_page=50&page=1&sparkline=false", - ), + url: anyNamed('url'), headers: {'Content-Type': 'application/json'}, ), ).thenAnswer( @@ -386,33 +362,7 @@ void main() { final price = await priceAPI.getPricesAnd24hChange(baseCurrency: "btc"); - expect( - price.toString(), - '{' - 'Coin.bitcoin: [0, 0.0], Coin.monero: [0, 0.0], ' - 'Coin.banano: [0, 0.0], ' - 'Coin.bitcoincash: [0, 0.0], ' - 'Coin.dogecoin: [0, 0.0], ' - 'Coin.eCash: [0, 0.0], ' - 'Coin.epicCash: [0, 0.0], ' - 'Coin.ethereum: [0, 0.0], ' - 'Coin.firo: [0, 0.0], ' - 'Coin.litecoin: [0, 0.0], ' - 'Coin.namecoin: [0, 0.0], ' - 'Coin.nano: [0, 0.0], ' - 'Coin.particl: [0, 0.0], ' - 'Coin.stellar: [0, 0.0], ' - 'Coin.tezos: [0, 0.0], ' - 'Coin.wownero: [0, 0.0], ' - 'Coin.bitcoinTestNet: [0, 0.0], ' - 'Coin.bitcoincashTestnet: [0, 0.0], ' - 'Coin.dogecoinTestNet: [0, 0.0], ' - 'Coin.firoTestNet: [0, 0.0], ' - 'Coin.litecoinTestNet: [0, 0.0], ' - 'Coin.stellarTestnet: [0, 0.0], ' - 'Coin.xelis: [0, 0.0]' - '}', - ); + expectEmptyPriceSnapshot(price.toString()); }); test("no internet available", () async { @@ -421,13 +371,7 @@ void main() { when( client.get( proxyInfo: null, - url: Uri.parse( - "https://api.coingecko.com/api/v3/coins/markets?vs_currency=btc" - "&ids=monero,bitcoin,litecoin,ecash,epic-cash,zcoin,dogecoin," - "bitcoin-cash,namecoin,wownero,ethereum,particl,nano,banano,stellar" - ",tezos,xelis" - "&order=market_cap_desc&per_page=50&page=1&sparkline=false", - ), + url: anyNamed('url'), headers: {'Content-Type': 'application/json'}, ), ).thenThrow( @@ -441,37 +385,10 @@ void main() { final price = await priceAPI.getPricesAnd24hChange(baseCurrency: "btc"); - expect( - price.toString(), - '{' - 'Coin.bitcoin: [0, 0.0], ' - 'Coin.monero: [0, 0.0], ' - 'Coin.banano: [0, 0.0], ' - 'Coin.bitcoincash: [0, 0.0], ' - 'Coin.dogecoin: [0, 0.0], ' - 'Coin.eCash: [0, 0.0], ' - 'Coin.epicCash: [0, 0.0], ' - 'Coin.ethereum: [0, 0.0], ' - 'Coin.firo: [0, 0.0], ' - 'Coin.litecoin: [0, 0.0], ' - 'Coin.namecoin: [0, 0.0], ' - 'Coin.nano: [0, 0.0], ' - 'Coin.particl: [0, 0.0], ' - 'Coin.stellar: [0, 0.0], ' - 'Coin.tezos: [0, 0.0], ' - 'Coin.wownero: [0, 0.0], ' - 'Coin.bitcoinTestNet: [0, 0.0], ' - 'Coin.bitcoincashTestnet: [0, 0.0], ' - 'Coin.dogecoinTestNet: [0, 0.0], ' - 'Coin.firoTestNet: [0, 0.0], ' - 'Coin.litecoinTestNet: [0, 0.0], ' - 'Coin.stellarTestnet: [0, 0.0], ' - 'Coin.xelis: [0, 0.0]' - '}', - ); + expectEmptyPriceSnapshot(price.toString()); }); tearDown(() async { - await tearDownTestHive(); + await tearDownHiveCeTest(); }); } diff --git a/test/price_test.mocks.dart b/test/price_test.mocks.dart index 36619fe9c1..5deee0bba5 100644 --- a/test/price_test.mocks.dart +++ b/test/price_test.mocks.dart @@ -43,12 +43,14 @@ class MockHTTP extends _i1.Mock implements _i2.HTTP { required Uri? url, Map? headers, required ({_i4.InternetAddress host, int port})? proxyInfo, + Duration? connectionTimeout, }) => (super.noSuchMethod( Invocation.method(#get, [], { #url: url, #headers: headers, #proxyInfo: proxyInfo, + #connectionTimeout: connectionTimeout, }), returnValue: _i3.Future<_i2.Response>.value( _FakeResponse_0( @@ -57,6 +59,7 @@ class MockHTTP extends _i1.Mock implements _i2.HTTP { #url: url, #headers: headers, #proxyInfo: proxyInfo, + #connectionTimeout: connectionTimeout, }), ), ), @@ -93,4 +96,113 @@ class MockHTTP extends _i1.Mock implements _i2.HTTP { ), ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> postBytes({ + required Uri? url, + Map? headers, + required List? bodyBytes, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#postBytes, [], { + #url: url, + #headers: headers, + #bodyBytes: bodyBytes, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#postBytes, [], { + #url: url, + #headers: headers, + #bodyBytes: bodyBytes, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> put({ + required Uri? url, + Map? headers, + Object? body, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#put, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#put, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> patch({ + required Uri? url, + Map? headers, + Object? body, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#patch, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#patch, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> delete({ + required Uri? url, + Map? headers, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#delete, [], { + #url: url, + #headers: headers, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#delete, [], { + #url: url, + #headers: headers, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); } diff --git a/test/screen_tests/exchange/exchange_view_test.mocks.dart b/test/screen_tests/exchange/exchange_view_test.mocks.dart index f27f332742..95337f8f6b 100644 --- a/test/screen_tests/exchange/exchange_view_test.mocks.dart +++ b/test/screen_tests/exchange/exchange_view_test.mocks.dart @@ -326,6 +326,19 @@ class MockPrefs extends _i1.Mock implements _i5.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -555,6 +568,18 @@ class MockPrefs extends _i1.Mock implements _i5.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) @@ -570,13 +595,12 @@ class MockPrefs extends _i1.Mock implements _i5.Prefs { as _i10.Future); @override - _i10.Future incrementCurrentNotificationIndex() => + _i10.Future incrementCurrentNotificationIndex() => (super.noSuchMethod( Invocation.method(#incrementCurrentNotificationIndex, []), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), + returnValue: _i10.Future.value(0), ) - as _i10.Future); + as _i10.Future); @override _i10.Future isExternalCallsSet() => diff --git a/test/screen_tests/lockscreen_view_screen_test.dart b/test/screen_tests/lockscreen_view_screen_test.dart index 8019ad22da..c513f715c1 100644 --- a/test/screen_tests/lockscreen_view_screen_test.dart +++ b/test/screen_tests/lockscreen_view_screen_test.dart @@ -1,312 +1,227 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; -// import 'package:stackwallet/pages/pinpad_views/lock_screen_view.dart'; - -import 'package:stackwallet/services/node_service.dart'; -import 'package:stackwallet/services/wallets_service.dart'; - -@GenerateMocks( - [], - customMocks: [ - MockSpec(), - MockSpec(), - ], -) +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/pinpad_views/lock_screen_view.dart'; +import 'package:stackwallet/providers/global/duress_provider.dart'; +import 'package:stackwallet/providers/global/prefs_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_service.dart'; +import 'package:stackwallet/utilities/biometrics.dart'; +import 'package:stackwallet/widgets/custom_pin_put/pin_keyboard.dart'; + +import '../sample_data/theme_json.dart'; +import '../widget_tests/custom_loading_overlay_test.mocks.dart'; +import '../widget_tests/node_options_sheet_test.mocks.dart'; +import '../widget_tests/support/platform_test_overrides.dart'; + +class SpyBiometrics extends Biometrics { + SpyBiometrics({this.result = false}); + + final bool result; + int calls = 0; + + @override + Future authenticate({ + required String cancelButtonText, + required String localizedReason, + required String title, + }) async { + calls += 1; + return result; + } +} + void main() { - testWidgets("LockscreenView builds correctly", (tester) async { - // final navigator = mockingjay.MockNavigator(); - // final walletsService = MockWalletsService(); - // final nodeService = MockNodeService(); - // final wallet = MockManager(); - // final secureStore = FakeSecureStorage(); - // - // secureStore.write(key: "walletID", value: "1234"); - // - // when(walletsService.getWalletId("My Firo Wallet")) - // .thenAnswer((_) async => "walletID"); - // - // await tester.pumpWidget( - // MaterialApp( - // home: mockingjay.MockNavigatorProvider( - // navigator: navigator, - // child: MultiProvider( - // providers: [ - // ChangeNotifierProvider( - // create: (_) => walletsService, - // ), - // ChangeNotifierProvider( - // create: (_) => nodeService, - // ), - // ChangeNotifierProvider( - // create: (_) => manager, - // ), - // ], - // child: LockscreenView( - // routeOnSuccess: "/mainview", - // secureStore: secureStore, - // ), - // ), - // ), - // ), - // ); - // - // await tester.pumpAndSettle(); - // - // expect(find.byType(AppBarIconButton), findsOneWidget); - // expect(find.byType(SvgPicture), findsOneWidget); - // - // expect(find.text("My Firo Wallet"), findsOneWidget); - // expect(find.text("Enter PIN"), findsOneWidget); - // - // expect(find.byType(CustomPinPut), findsOneWidget); - }); + ThemeData buildTheme() { + return ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ); + } - testWidgets("enter valid pin", (tester) async { - // final navigator = mockingjay.MockNavigator(); - // final walletsService = MockWalletsService(); - // final nodeService = MockNodeService(); - // final wallet = MockManager(); - // final secureStore = FakeSecureStorage(); - // - // secureStore.write(key: "walletID_pin", value: "1234"); - // - // when(walletsService.getWalletId("My Firo Wallet")) - // .thenAnswer((_) async => "walletID"); - // - // mockingjay - // .when(() => navigator.pushReplacementNamed("/mainview")) - // .thenAnswer((_) async => {}); - // - // await tester.pumpWidget( - // MaterialApp( - // home: mockingjay.MockNavigatorProvider( - // navigator: navigator, - // child: MultiProvider( - // providers: [ - // ChangeNotifierProvider( - // create: (_) => walletsService, - // ), - // ChangeNotifierProvider( - // create: (_) => nodeService, - // ), - // ChangeNotifierProvider( - // create: (_) => manager, - // ), - // ], - // child: LockscreenView( - // routeOnSuccess: "/mainview", - // secureStore: secureStore, - // ), - // ), - // ), - // ), - // ); - // - // await tester.pumpAndSettle(); - // - // await tester.tap(find.byWidgetPredicate( - // (widget) => widget is NumberKey && widget.number == "1")); - // await tester.pump(const Duration(milliseconds: 200)); - // await tester.tap(find.byWidgetPredicate( - // (widget) => widget is NumberKey && widget.number == "2")); - // await tester.pump(const Duration(milliseconds: 200)); - // await tester.tap(find.byWidgetPredicate( - // (widget) => widget is NumberKey && widget.number == "3")); - // await tester.pump(const Duration(milliseconds: 200)); - // await tester.tap(find.byWidgetPredicate( - // (widget) => widget is NumberKey && widget.number == "4")); - // await tester.pump(const Duration(milliseconds: 500)); - // - // expect(find.text("PIN code correct. Unlocking wallet..."), findsOneWidget); - // - // await tester.pump(const Duration(seconds: 2)); - // - // mockingjay - // .verify(() => navigator.pushReplacementNamed("/mainview")) - // .called(1); - }); + void stubPrefs(MockPrefs prefs) { + when(prefs.isInitialized).thenReturn(true); + when(prefs.randomizePIN).thenReturn(false); + when(prefs.autoPin).thenReturn(false); + when(prefs.useBiometrics).thenReturn(false); + when(prefs.biometricsDuress).thenReturn(false); + when(prefs.lastUnlocked).thenReturn(0); + } + + Future pumpLockscreenView( + WidgetTester tester, { + required MockPrefs prefs, + required SpyBiometrics biometrics, + required List overrides, + required bool isDuress, + VoidCallback? onSuccess, + }) async { + final mockThemeService = MockThemeService(); + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + + when(mockThemeService.getTheme(themeId: 'light')).thenReturn(theme); + + final container = ProviderContainer( + overrides: [ + pThemeService.overrideWithValue(mockThemeService), + prefsChangeNotifierProvider.overrideWithValue(prefs), + ...overrides, + ], + ); + + addTearDown(container.dispose); + container.read(pDuress.notifier).state = isDuress; + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: buildTheme(), + routes: { + '/unlocked': (_) => const Scaffold(body: Text('unlocked route')), + }, + home: LockscreenView( + routeOnSuccess: '/unlocked', + biometricsAuthenticationTitle: 'Unlock wallet', + biometricsLocalizedReason: 'Unlock Stack Wallet', + biometricsCancelButtonString: 'Cancel', + biometrics: biometrics, + onSuccess: onSuccess, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + return container; + } + + Future tapDigit(WidgetTester tester, String digit) async { + await tester.tap( + find.byWidgetPredicate( + (widget) => widget is NumberKey && widget.number == digit, + ), + ); + await tester.pump(const Duration(milliseconds: 250)); + } + + Future enterAndSubmitPin(WidgetTester tester, String pin) async { + for (final digit in pin.split('')) { + await tapDigit(tester, digit); + } + + await tester.tap(find.byType(SubmitKey)); + await tester.pump(); + } - testWidgets("wallet initialization fails", (tester) async { - // final navigator = mockingjay.MockNavigator(); - // final walletsService = MockWalletsService(); - // final nodeService = MockNodeService(); - // final wallet = MockManager(); - // final secureStore = FakeSecureStorage(); - // - // secureStore.write(key: "walletID_pin", value: "1234"); - // - // when(walletsService.getWalletId("My Firo Wallet")) - // .thenAnswer((_) async => "walletID"); - // - // mockingjay - // .when(() => navigator.pushReplacementNamed("/mainview")) - // .thenAnswer((_) async => {}); - // - // await tester.pumpWidget( - // MaterialApp( - // home: mockingjay.MockNavigatorProvider( - // navigator: navigator, - // child: MultiProvider( - // providers: [ - // ChangeNotifierProvider( - // create: (_) => walletsService, - // ), - // ChangeNotifierProvider( - // create: (_) => nodeService, - // ), - // ChangeNotifierProvider( - // create: (_) => manager, - // ), - // ], - // child: LockscreenView( - // routeOnSuccess: "/mainview", - // secureStore: secureStore, - // ), - // ), - // ), - // ), - // ); - // - // await tester.pumpAndSettle(); - // - // await tester.tap(find.byWidgetPredicate( - // (widget) => widget is NumberKey && widget.number == "1")); - // await tester.pump(const Duration(milliseconds: 200)); - // await tester.tap(find.byWidgetPredicate( - // (widget) => widget is NumberKey && widget.number == "2")); - // await tester.pump(const Duration(milliseconds: 200)); - // await tester.tap(find.byWidgetPredicate( - // (widget) => widget is NumberKey && widget.number == "3")); - // await tester.pump(const Duration(milliseconds: 200)); - // await tester.tap(find.byWidgetPredicate( - // (widget) => widget is NumberKey && widget.number == "4")); - // await tester.pump(const Duration(milliseconds: 500)); - // - // expect(find.text("PIN code correct. Unlocking wallet..."), findsOneWidget); - // - // await tester.pump(const Duration(seconds: 2)); - // - // expect( - // find.text( - // "Failed to connect to network. Check your internet connection and make sure the Electrum X node you are connected to is not having any issues."), - // findsOneWidget); - // - // await tester.tap(find.byKey(Key("campfireAlertOKButtonKey"))); - // await tester.pump(const Duration(seconds: 2)); - // await tester.pump(const Duration(seconds: 2)); - // - // expect( - // find.text( - // "Failed to connect to network. Check your internet connection and make sure the Electrum X node you are connected to is not having any issues."), - // findsNothing); - // - // mockingjay - // .verify(() => navigator.pushReplacementNamed("/mainview")) - // .called(1); + testWidgets('valid standard PIN unlocks through fake storage seam', ( + tester, + ) async { + final prefs = MockPrefs(); + final biometrics = SpyBiometrics(); + final platformOverrides = await createPlatformTestOverrides( + secureStorageEntries: {kPinKey: '1234', kDuressPinKey: '9876'}, + ); + var onSuccessCalls = 0; + + stubPrefs(prefs); + + await pumpLockscreenView( + tester, + prefs: prefs, + biometrics: biometrics, + overrides: platformOverrides.overrides, + isDuress: false, + onSuccess: () => onSuccessCalls += 1, + ); + + expect(find.text('Enter PIN'), findsOneWidget); + expect( + platformOverrides.secureStorage.writtenKeys, + containsAll([kPinKey, kDuressPinKey]), + ); + + await enterAndSubmitPin(tester, '1234'); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + expect(platformOverrides.secureStorage.readKeys, [kPinKey]); + expect(platformOverrides.secureStorage.reads, 1); + expect(onSuccessCalls, 1); + expect(biometrics.calls, 0); + expect(find.text('unlocked route'), findsOneWidget); + + verify(prefs.lastUnlocked = any).called(1); }); - testWidgets("enter invalid pin", (tester) async { - // final navigator = mockingjay.MockNavigator(); - // final walletsService = MockWalletsService(); - // final nodeService = MockNodeService(); - // final wallet = MockManager(); - // final secureStore = FakeSecureStorage(); - // - // secureStore.write(key: "walletID_pin", value: "1234"); - // - // when(walletsService.getWalletId("My Firo Wallet")) - // .thenAnswer((_) async => "walletID"); - // - // mockingjay - // .when(() => navigator.pushReplacementNamed("/mainview")) - // .thenAnswer((_) async => {}); - - // await tester.pumpWidget( - // MaterialApp( - // home: mockingjay.MockNavigatorProvider( - // navigator: navigator, - // child: MultiProvider( - // providers: [ - // ChangeNotifierProvider( - // create: (_) => walletsService, - // ), - // ChangeNotifierProvider( - // create: (_) => nodeService, - // ), - // ChangeNotifierProvider( - // create: (_) => manager, - // ), - // ], - // child: LockscreenView( - // routeOnSuccess: "/mainview", - // secureStore: secureStore, - // ), - // ), - // ), - // ), - // ); - - // await tester.pumpAndSettle(); - // - // await tester.tap(find.byWidgetPredicate( - // (widget) => widget is NumberKey && widget.number == "1")); - // await tester.pump(const Duration(milliseconds: 200)); - // await tester.tap(find.byWidgetPredicate( - // (widget) => widget is NumberKey && widget.number == "1")); - // await tester.pump(const Duration(milliseconds: 200)); - // await tester.tap(find.byWidgetPredicate( - // (widget) => widget is NumberKey && widget.number == "3")); - // await tester.pump(const Duration(milliseconds: 200)); - // await tester.tap(find.byWidgetPredicate( - // (widget) => widget is NumberKey && widget.number == "4")); - // await tester.pump(const Duration(milliseconds: 500)); - // - // expect(find.text("Incorrect PIN. Please try again"), findsOneWidget); - // - // await tester.pump(const Duration(seconds: 2)); - // - // mockingjay.verifyNever(() => navigator.pushReplacementNamed("/mainview")); + testWidgets('duress mode unlocks with the duress PIN only', (tester) async { + final prefs = MockPrefs(); + final biometrics = SpyBiometrics(); + final platformOverrides = await createPlatformTestOverrides( + secureStorageEntries: {kPinKey: '1234', kDuressPinKey: '9876'}, + ); + var onSuccessCalls = 0; + + stubPrefs(prefs); + + final container = await pumpLockscreenView( + tester, + prefs: prefs, + biometrics: biometrics, + overrides: platformOverrides.overrides, + isDuress: true, + onSuccess: () => onSuccessCalls += 1, + ); + + await enterAndSubmitPin(tester, '9876'); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + expect(platformOverrides.secureStorage.readKeys, [kDuressPinKey]); + expect(platformOverrides.secureStorage.reads, 1); + expect(container.read(pDuress), isTrue); + expect(onSuccessCalls, 1); + expect(biometrics.calls, 0); + expect(find.text('unlocked route'), findsOneWidget); + + verify(prefs.lastUnlocked = any).called(1); }); - testWidgets("tap back", (tester) async { - // final navigator = mockingjay.MockNavigator(); - // final walletsService = MockWalletsService(); - // final nodeService = MockNodeService(); - // final wallet = MockManager(); - // final secureStore = FakeSecureStorage(); - // - // mockingjay.when(() => navigator.pop()).thenAnswer((_) async => {}); - - // await tester.pumpWidget( - // MaterialApp( - // home: mockingjay.MockNavigatorProvider( - // navigator: navigator, - // child: MultiProvider( - // providers: [ - // ChangeNotifierProvider( - // create: (_) => walletsService, - // ), - // ChangeNotifierProvider( - // create: (_) => nodeService, - // ), - // ChangeNotifierProvider( - // create: (_) => manager, - // ), - // ], - // child: LockscreenView( - // routeOnSuccess: "/mainview", - // secureStore: secureStore, - // ), - // ), - // ), - // ), - // ); - - // await tester.pumpAndSettle(); - // - // await tester.tap(find.byType(AppBarIconButton)); - // await tester.pumpAndSettle(); - // - // mockingjay.verify(() => navigator.pop()).called(1); + testWidgets('duress mode rejects the standard PIN without unlocking', ( + tester, + ) async { + final prefs = MockPrefs(); + final biometrics = SpyBiometrics(); + final platformOverrides = await createPlatformTestOverrides( + secureStorageEntries: {kPinKey: '1234', kDuressPinKey: '9876'}, + ); + var onSuccessCalls = 0; + + stubPrefs(prefs); + + final container = await pumpLockscreenView( + tester, + prefs: prefs, + biometrics: biometrics, + overrides: platformOverrides.overrides, + isDuress: true, + onSuccess: () => onSuccessCalls += 1, + ); + + await enterAndSubmitPin(tester, '1234'); + await tester.pump(const Duration(milliseconds: 900)); + + expect(platformOverrides.secureStorage.readKeys, [kDuressPinKey]); + expect(platformOverrides.secureStorage.reads, 1); + expect(container.read(pDuress), isTrue); + expect(onSuccessCalls, 0); + expect(biometrics.calls, 0); + expect(find.text('Enter PIN'), findsOneWidget); + expect(find.text('unlocked route'), findsNothing); + + verifyNever(prefs.lastUnlocked = any); }); } diff --git a/test/screen_tests/lockscreen_view_screen_test.mocks.dart b/test/screen_tests/lockscreen_view_screen_test.mocks.dart deleted file mode 100644 index c54cd49630..0000000000 --- a/test/screen_tests/lockscreen_view_screen_test.mocks.dart +++ /dev/null @@ -1,254 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in stackwallet/test/screen_tests/lockscreen_view_screen_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i4; -import 'dart:ui' as _i5; - -import 'package:mockito/mockito.dart' as _i1; -import 'package:stackwallet/models/node_model.dart' as _i7; -import 'package:stackwallet/services/node_service.dart' as _i6; -import 'package:stackwallet/services/wallets_service.dart' as _i3; -import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart' - as _i2; -import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart' - as _i8; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class -// ignore_for_file: invalid_use_of_internal_member - -class _FakeSecureStorageInterface_0 extends _i1.SmartFake - implements _i2.SecureStorageInterface { - _FakeSecureStorageInterface_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [WalletsService]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockWalletsService extends _i1.Mock implements _i3.WalletsService { - MockWalletsService() { - _i1.throwOnMissingStub(this); - } - - @override - _i4.Future> get walletNames => - (super.noSuchMethod( - Invocation.getter(#walletNames), - returnValue: _i4.Future>.value( - {}, - ), - ) - as _i4.Future>); - - @override - bool get hasListeners => - (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) - as bool); - - @override - void addListener(_i5.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#addListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void removeListener(_i5.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#removeListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); - - @override - void notifyListeners() => super.noSuchMethod( - Invocation.method(#notifyListeners, []), - returnValueForMissingStub: null, - ); -} - -/// A class which mocks [NodeService]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockNodeService extends _i1.Mock implements _i6.NodeService { - MockNodeService() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.SecureStorageInterface get secureStorageInterface => - (super.noSuchMethod( - Invocation.getter(#secureStorageInterface), - returnValue: _FakeSecureStorageInterface_0( - this, - Invocation.getter(#secureStorageInterface), - ), - ) - as _i2.SecureStorageInterface); - - @override - List<_i7.NodeModel> get primaryNodes => - (super.noSuchMethod( - Invocation.getter(#primaryNodes), - returnValue: <_i7.NodeModel>[], - ) - as List<_i7.NodeModel>); - - @override - List<_i7.NodeModel> get nodes => - (super.noSuchMethod( - Invocation.getter(#nodes), - returnValue: <_i7.NodeModel>[], - ) - as List<_i7.NodeModel>); - - @override - bool get hasListeners => - (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) - as bool); - - @override - _i4.Future updateDefaults() => - (super.noSuchMethod( - Invocation.method(#updateDefaults, []), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) - as _i4.Future); - - @override - _i4.Future setPrimaryNodeFor({ - required _i8.CryptoCurrency? coin, - required _i7.NodeModel? node, - bool? shouldNotifyListeners = false, - }) => - (super.noSuchMethod( - Invocation.method(#setPrimaryNodeFor, [], { - #coin: coin, - #node: node, - #shouldNotifyListeners: shouldNotifyListeners, - }), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) - as _i4.Future); - - @override - _i7.NodeModel? getPrimaryNodeFor({required _i8.CryptoCurrency? currency}) => - (super.noSuchMethod( - Invocation.method(#getPrimaryNodeFor, [], {#currency: currency}), - ) - as _i7.NodeModel?); - - @override - List<_i7.NodeModel> getNodesFor(_i8.CryptoCurrency? coin) => - (super.noSuchMethod( - Invocation.method(#getNodesFor, [coin]), - returnValue: <_i7.NodeModel>[], - ) - as List<_i7.NodeModel>); - - @override - _i7.NodeModel? getNodeById({required String? id}) => - (super.noSuchMethod(Invocation.method(#getNodeById, [], {#id: id})) - as _i7.NodeModel?); - - @override - List<_i7.NodeModel> failoverNodesFor({ - required _i8.CryptoCurrency? currency, - }) => - (super.noSuchMethod( - Invocation.method(#failoverNodesFor, [], {#currency: currency}), - returnValue: <_i7.NodeModel>[], - ) - as List<_i7.NodeModel>); - - @override - _i4.Future save( - _i7.NodeModel? node, - String? password, - bool? shouldNotifyListeners, - ) => - (super.noSuchMethod( - Invocation.method(#save, [node, password, shouldNotifyListeners]), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) - as _i4.Future); - - @override - _i4.Future delete(String? id, bool? shouldNotifyListeners) => - (super.noSuchMethod( - Invocation.method(#delete, [id, shouldNotifyListeners]), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) - as _i4.Future); - - @override - _i4.Future setEnabledState( - String? id, - bool? enabled, - bool? shouldNotifyListeners, - ) => - (super.noSuchMethod( - Invocation.method(#setEnabledState, [ - id, - enabled, - shouldNotifyListeners, - ]), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) - as _i4.Future); - - @override - _i4.Future updateCommunityNodes() => - (super.noSuchMethod( - Invocation.method(#updateCommunityNodes, []), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) - as _i4.Future); - - @override - void addListener(_i5.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#addListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void removeListener(_i5.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#removeListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); - - @override - void notifyListeners() => super.noSuchMethod( - Invocation.method(#notifyListeners, []), - returnValueForMissingStub: null, - ); -} diff --git a/test/screen_tests/onboarding/create_pin_view_screen_test.dart b/test/screen_tests/onboarding/create_pin_view_screen_test.dart index fcd0aa2c56..455d347c45 100644 --- a/test/screen_tests/onboarding/create_pin_view_screen_test.dart +++ b/test/screen_tests/onboarding/create_pin_view_screen_test.dart @@ -1,382 +1,207 @@ -// import 'package:flutter/material.dart'; -// import 'package:flutter_test/flutter_test.dart'; -// import 'package:mockingjay/mockingjay.dart' as mockingjay; -import 'package:mockito/annotations.dart'; -// import 'package:mockito/mockito.dart'; -// import 'package:stackwallet/pages/onboarding_view/create_pin_view.dart'; -// import 'package:stackwallet/pages/onboarding_view/helpers/create_wallet_type.dart'; - -import 'package:stackwallet/services/node_service.dart'; -import 'package:stackwallet/services/wallets_service.dart'; -// import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; -// import 'package:stackwallet/utilities/misc_global_constants.dart'; -// import 'package:stackwallet/widgets/custom_buttons/gradient_button.dart'; -// import 'package:stackwallet/widgets/custom_pin_put/custom_pin_put.dart'; -// import 'package:stackwallet/widgets/custom_pin_put/pin_keyboard.dart'; -// import 'package:provider/provider.dart'; -// -// import 'create_pin_view_screen_test.mocks.dart'; - -@GenerateMocks([], customMocks: [ - MockSpec(), - MockSpec(), -]) +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/home_view/home_view.dart'; +import 'package:stackwallet/pages/pinpad_views/create_pin_view.dart'; +import 'package:stackwallet/pages/pinpad_views/lock_screen_view.dart'; +import 'package:stackwallet/providers/global/prefs_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_service.dart'; +import 'package:stackwallet/utilities/biometrics.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/widgets/custom_pin_put/pin_keyboard.dart'; + +import '../../sample_data/theme_json.dart'; +import '../../widget_tests/custom_loading_overlay_test.mocks.dart'; +import '../../widget_tests/node_options_sheet_test.mocks.dart'; +import '../../widget_tests/support/platform_test_overrides.dart'; + +class SpyBiometrics extends Biometrics { + SpyBiometrics({this.result = false}); + + final bool result; + int calls = 0; + + @override + Future authenticate({ + required String cancelButtonText, + required String localizedReason, + required String title, + }) async { + calls += 1; + return result; + } +} + void main() { -// testWidgets("CreatePinView builds correctly", (tester) async { -// await tester.pumpWidget( -// MaterialApp( -// home: CreatePinView( -// type: CreateWalletType.NEW, -// walletName: "My Firo Wallet", -// useTestNet: false, -// ), -// ), -// ); -// -// expect(find.byKey(Key("onboardingAppBarBackButton")), findsOneWidget); -// expect(find.byKey(Key("onboardingAppBarBackButtonChevronSvg")), -// findsOneWidget); -// -// final imageFinder = find.byType(Image); -// expect(imageFinder, findsOneWidget); -// -// final imageSource = -// ((imageFinder.evaluate().single.widget as Image).image as AssetImage) -// .assetName; -// expect(imageSource, "assets/images/logo.png"); -// -// expect(find.text("Create a PIN"), findsOneWidget); -// -// expect(find.byType(CustomPinPut), findsOneWidget); -// }); -// -// testWidgets("back button test", (tester) async { -// final navigator = mockingjay.MockNavigator(); -// -// mockingjay.when(() => navigator.pop()).thenAnswer((_) async => {}); -// -// await tester.pumpWidget( -// MaterialApp( -// home: mockingjay.MockNavigatorProvider( -// navigator: navigator, -// child: CreatePinView( -// type: CreateWalletType.NEW, -// walletName: "My Firo Wallet", -// useTestNet: false, -// ), -// ), -// ), -// ); -// -// await tester.tap(find.byKey(Key("onboardingAppBarBackButton"))); -// await tester.pumpAndSettle(); -// -// mockingjay.verify(() => navigator.pop()).called(1); -// }); -// -// testWidgets("Entering unmatched PINs", (tester) async { -// await tester.pumpWidget( -// MaterialApp( -// home: CreatePinView( -// type: CreateWalletType.NEW, -// walletName: "My Firo Wallet", -// useTestNet: false, -// ), -// ), -// ); -// -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "1")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "2")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "3")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester -// .tap(find.byWidgetPredicate((widget) => widget is BackspaceKey)); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "6")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "4")); -// await tester.pumpAndSettle(Duration(seconds: 1)); -// -// expect(find.text("Confirm PIN"), findsOneWidget); -// -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "7")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "9")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "8")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "5")); -// await tester.pumpAndSettle(Duration(seconds: 2)); -// -// expect(find.text("Create a PIN"), findsOneWidget); -// }); -// -// testWidgets("Entering matched PINs on a new wallet", (tester) async { -// final navigator = mockingjay.MockNavigator(); -// final walletsService = MockWalletsService(); -// final wallet = MockManager(); -// final nodeService = MockNodeService(); -// -// final store = FakeSecureStorage(); -// -// mockingjay -// .when(() => navigator.push(mockingjay.any())) -// .thenAnswer((_) async => {}); -// -// when(walletsService.addNewWalletName("My Firo Wallet", "main")) -// .thenAnswer((_) async => true); -// when(walletsService.getWalletId("My Firo Wallet")) -// .thenAnswer((_) async => "walletID"); -// -// when(nodeService.reInit()).thenAnswer((_) => {}); -// when( -// nodeService.createNode( -// name: CampfireConstants.defaultNodeName, -// ipAddress: CampfireConstants.defaultIpAddress, -// port: CampfireConstants.defaultPort.toString(), -// useSSL: CampfireConstants.defaultUseSSL, -// ), -// ).thenAnswer((_) async => true); -// -// when(manager.initializeWallet()).thenAnswer((_) async => true); -// -// await tester.pumpWidget( -// MaterialApp( -// home: mockingjay.MockNavigatorProvider( -// navigator: navigator, -// child: MultiProvider( -// // home: MultiProvider( -// providers: [ -// ChangeNotifierProvider( -// create: (_) => walletsService, -// ), -// ChangeNotifierProvider( -// create: (_) => manager, -// ), -// ChangeNotifierProvider( -// create: (_) => nodeService, -// ), -// ], -// child: CreatePinView( -// type: CreateWalletType.NEW, -// walletName: "My Firo Wallet", -// useTestNet: false, -// secureStore: store, -// ), -// ), -// ), -// ), -// ); -// -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "1")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "2")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "3")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "4")); -// await tester.pumpAndSettle(Duration(seconds: 1)); -// -// expect(find.text("Confirm PIN"), findsOneWidget); -// -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "1")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "2")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "3")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "4")); -// await tester.pump(Duration(seconds: 2)); -// -// expect(find.byType(CircularProgressIndicator), findsOneWidget); -// -// await tester.pump(Duration(seconds: 20)); -// -// mockingjay.verify(() => navigator.push(mockingjay.any())).called(1); -// }); -// -// testWidgets("Wallet init fails on entering matched PINs", (tester) async { -// final navigator = mockingjay.MockNavigator(); -// final walletsService = MockWalletsService(); -// final wallet = MockManager(); -// final nodeService = MockNodeService(); -// -// final store = FakeSecureStorage(); -// -// mockingjay.when(() => navigator.pop()).thenAnswer((_) async => {}); -// -// when(walletsService.addNewWalletName("My Firo Wallet", "main")) -// .thenAnswer((_) async => true); -// when(walletsService.getWalletId("My Firo Wallet")) -// .thenAnswer((_) async => "walletID"); -// -// when(nodeService.reInit()).thenAnswer((_) => {}); -// when( -// nodeService.createNode( -// name: CampfireConstants.defaultNodeNameTestNet, -// ipAddress: CampfireConstants.defaultIpAddressTestNet, -// port: CampfireConstants.defaultPortTestNet.toString(), -// useSSL: CampfireConstants.defaultUseSSLTestNet, -// ), -// ).thenAnswer((_) async => true); -// -// when(manager.initializeWallet()).thenAnswer((_) async => false); -// when(manager.exitCurrentWallet()).thenAnswer((_) async => {}); -// when(walletsService.deleteWallet("My Firo Wallet")) -// .thenAnswer((_) async => 0); -// -// await tester.pumpWidget( -// MaterialApp( -// home: mockingjay.MockNavigatorProvider( -// navigator: navigator, -// child: MultiProvider( -// // home: MultiProvider( -// providers: [ -// ChangeNotifierProvider( -// create: (_) => walletsService, -// ), -// ChangeNotifierProvider( -// create: (_) => manager, -// ), -// ChangeNotifierProvider( -// create: (_) => nodeService, -// ), -// ], -// child: CreatePinView( -// type: CreateWalletType.NEW, -// walletName: "My Firo Wallet", -// useTestNet: true, -// secureStore: store, -// ), -// ), -// ), -// ), -// ); -// -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "1")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "2")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "3")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "4")); -// await tester.pumpAndSettle(Duration(seconds: 1)); -// -// expect(find.text("Confirm PIN"), findsOneWidget); -// -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "1")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "2")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "3")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "4")); -// await tester.pump(Duration(seconds: 2)); -// -// expect( -// find.text( -// "Failed to connect to network. Check your internet connection."), -// findsOneWidget); -// expect(find.text("OK"), findsOneWidget); -// -// await tester.tap(find.byType(GradientButton)); -// await tester.pump(Duration(seconds: 1)); -// -// mockingjay.verify(() => navigator.pop()).called(4); -// }); -// -// testWidgets("Entering matched PINs on a restore", (tester) async { -// final navigator = mockingjay.MockNavigator(); -// final walletsService = MockWalletsService(); -// -// final store = FakeSecureStorage(); -// -// mockingjay -// .when(() => navigator.push(mockingjay.any())) -// .thenAnswer((_) async => {}); -// -// when(walletsService.addNewWalletName("My Firo Wallet", "main")) -// .thenAnswer((_) async => true); -// when(walletsService.getWalletId("My Firo Wallet")) -// .thenAnswer((_) async => "walletID"); -// -// await tester.pumpWidget( -// MaterialApp( -// home: mockingjay.MockNavigatorProvider( -// navigator: navigator, -// child: MultiProvider( -// // home: MultiProvider( -// providers: [ -// ChangeNotifierProvider( -// create: (_) => walletsService, -// ), -// ], -// child: CreatePinView( -// type: CreateWalletType.RESTORE, -// walletName: "My Firo Wallet", -// useTestNet: false, -// secureStore: store, -// ), -// ), -// ), -// ), -// ); -// -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "1")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "2")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "3")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "4")); -// await tester.pumpAndSettle(Duration(seconds: 1)); -// -// expect(find.text("Confirm PIN"), findsOneWidget); -// -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "1")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "2")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "3")); -// await tester.pumpAndSettle(Duration(milliseconds: 100)); -// await tester.tap(find.byWidgetPredicate( -// (widget) => widget is NumberKey && widget.number == "4")); -// await tester.pumpAndSettle(Duration(seconds: 6)); -// -// mockingjay.verify(() => navigator.push(mockingjay.any())).called(1); -// }); + ThemeData buildTheme() { + return ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ); + } + + void stubPrefs(MockPrefs prefs) { + when(prefs.randomizePIN).thenReturn(false); + when(prefs.hasPin).thenReturn(false); + } + + Future pumpCreatePinView( + WidgetTester tester, { + required MockPrefs prefs, + required SpyBiometrics biometrics, + required List overrides, + }) async { + final mockThemeService = MockThemeService(); + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + + when(mockThemeService.getTheme(themeId: 'light')).thenReturn(theme); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + pThemeService.overrideWithValue(mockThemeService), + prefsChangeNotifierProvider.overrideWithValue(prefs), + ...overrides, + ], + child: MaterialApp( + theme: buildTheme(), + routes: { + HomeView.routeName: (_) => const Scaffold(body: Text('home route')), + }, + home: CreatePinView(biometrics: biometrics), + ), + ), + ); + + await tester.pumpAndSettle(); + } + + Future tapDigit(WidgetTester tester, String digit) async { + await tester.tap( + find.byWidgetPredicate( + (widget) => widget is NumberKey && widget.number == digit, + ), + ); + await tester.pump(const Duration(milliseconds: 250)); + } + + Future enterPin(WidgetTester tester, String pin) async { + for (final digit in pin.split('')) { + await tapDigit(tester, digit); + } + } + + Future submitCurrentPin(WidgetTester tester) async { + await tester.tap(find.byType(SubmitKey)); + await tester.pump(); + } + + testWidgets('matching PIN persists through fake secure storage seam', ( + tester, + ) async { + final prefs = MockPrefs(); + final biometrics = SpyBiometrics(); + final platformOverrides = await createPlatformTestOverrides(); + + stubPrefs(prefs); + + await pumpCreatePinView( + tester, + prefs: prefs, + biometrics: biometrics, + overrides: platformOverrides.overrides, + ); + + expect(find.text('Create a PIN'), findsOneWidget); + + await enterPin(tester, '1234'); + await submitCurrentPin(tester); + await tester.pumpAndSettle(); + + expect(find.text('Confirm PIN'), findsOneWidget); + + await enterPin(tester, '1234'); + await submitCurrentPin(tester); + await tester.pumpAndSettle(const Duration(milliseconds: 300)); + + expect(await platformOverrides.secureStorage.read(key: kPinKey), '1234'); + expect(platformOverrides.secureStorage.writes, 1); + expect(biometrics.calls, 0); + + verify(prefs.useBiometrics = false).called(1); + verify(prefs.hasPin = true).called(1); + expect(find.text('home route'), findsOneWidget); + }); + + testWidgets('short PIN submission is blocked before confirmation page', ( + tester, + ) async { + final prefs = MockPrefs(); + final biometrics = SpyBiometrics(); + final platformOverrides = await createPlatformTestOverrides(); + + stubPrefs(prefs); + + await pumpCreatePinView( + tester, + prefs: prefs, + biometrics: biometrics, + overrides: platformOverrides.overrides, + ); + + await enterPin(tester, '123'); + await submitCurrentPin(tester); + await tester.pumpAndSettle(); + + expect(find.text('Create a PIN'), findsOneWidget); + expect(find.text('Confirm PIN'), findsNothing); + expect(await platformOverrides.secureStorage.read(key: kPinKey), isNull); + expect(platformOverrides.secureStorage.writes, 0); + expect(biometrics.calls, 0); + + verifyNever(prefs.useBiometrics = false); + verifyNever(prefs.hasPin = true); + }); + + testWidgets('mismatched confirmation resets flow without storage writes', ( + tester, + ) async { + final prefs = MockPrefs(); + final biometrics = SpyBiometrics(); + final platformOverrides = await createPlatformTestOverrides(); + + stubPrefs(prefs); + + await pumpCreatePinView( + tester, + prefs: prefs, + biometrics: biometrics, + overrides: platformOverrides.overrides, + ); + + await enterPin(tester, '1234'); + await submitCurrentPin(tester); + await tester.pumpAndSettle(); + + expect(find.text('Confirm PIN'), findsOneWidget); + + await enterPin(tester, '9876'); + await submitCurrentPin(tester); + await tester.pumpAndSettle(); + + expect(find.text('Create a PIN'), findsOneWidget); + expect(find.text('Confirm PIN'), findsNothing); + expect(await platformOverrides.secureStorage.read(key: kPinKey), isNull); + expect(platformOverrides.secureStorage.writes, 0); + expect(biometrics.calls, 0); + + verifyNever(prefs.useBiometrics = false); + verifyNever(prefs.hasPin = true); + }); } diff --git a/test/screen_tests/onboarding/create_pin_view_screen_test.mocks.dart b/test/screen_tests/onboarding/create_pin_view_screen_test.mocks.dart deleted file mode 100644 index de530dad1f..0000000000 --- a/test/screen_tests/onboarding/create_pin_view_screen_test.mocks.dart +++ /dev/null @@ -1,254 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in stackwallet/test/screen_tests/onboarding/create_pin_view_screen_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i4; -import 'dart:ui' as _i5; - -import 'package:mockito/mockito.dart' as _i1; -import 'package:stackwallet/models/node_model.dart' as _i7; -import 'package:stackwallet/services/node_service.dart' as _i6; -import 'package:stackwallet/services/wallets_service.dart' as _i3; -import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart' - as _i2; -import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart' - as _i8; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class -// ignore_for_file: invalid_use_of_internal_member - -class _FakeSecureStorageInterface_0 extends _i1.SmartFake - implements _i2.SecureStorageInterface { - _FakeSecureStorageInterface_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [WalletsService]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockWalletsService extends _i1.Mock implements _i3.WalletsService { - MockWalletsService() { - _i1.throwOnMissingStub(this); - } - - @override - _i4.Future> get walletNames => - (super.noSuchMethod( - Invocation.getter(#walletNames), - returnValue: _i4.Future>.value( - {}, - ), - ) - as _i4.Future>); - - @override - bool get hasListeners => - (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) - as bool); - - @override - void addListener(_i5.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#addListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void removeListener(_i5.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#removeListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); - - @override - void notifyListeners() => super.noSuchMethod( - Invocation.method(#notifyListeners, []), - returnValueForMissingStub: null, - ); -} - -/// A class which mocks [NodeService]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockNodeService extends _i1.Mock implements _i6.NodeService { - MockNodeService() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.SecureStorageInterface get secureStorageInterface => - (super.noSuchMethod( - Invocation.getter(#secureStorageInterface), - returnValue: _FakeSecureStorageInterface_0( - this, - Invocation.getter(#secureStorageInterface), - ), - ) - as _i2.SecureStorageInterface); - - @override - List<_i7.NodeModel> get primaryNodes => - (super.noSuchMethod( - Invocation.getter(#primaryNodes), - returnValue: <_i7.NodeModel>[], - ) - as List<_i7.NodeModel>); - - @override - List<_i7.NodeModel> get nodes => - (super.noSuchMethod( - Invocation.getter(#nodes), - returnValue: <_i7.NodeModel>[], - ) - as List<_i7.NodeModel>); - - @override - bool get hasListeners => - (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) - as bool); - - @override - _i4.Future updateDefaults() => - (super.noSuchMethod( - Invocation.method(#updateDefaults, []), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) - as _i4.Future); - - @override - _i4.Future setPrimaryNodeFor({ - required _i8.CryptoCurrency? coin, - required _i7.NodeModel? node, - bool? shouldNotifyListeners = false, - }) => - (super.noSuchMethod( - Invocation.method(#setPrimaryNodeFor, [], { - #coin: coin, - #node: node, - #shouldNotifyListeners: shouldNotifyListeners, - }), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) - as _i4.Future); - - @override - _i7.NodeModel? getPrimaryNodeFor({required _i8.CryptoCurrency? currency}) => - (super.noSuchMethod( - Invocation.method(#getPrimaryNodeFor, [], {#currency: currency}), - ) - as _i7.NodeModel?); - - @override - List<_i7.NodeModel> getNodesFor(_i8.CryptoCurrency? coin) => - (super.noSuchMethod( - Invocation.method(#getNodesFor, [coin]), - returnValue: <_i7.NodeModel>[], - ) - as List<_i7.NodeModel>); - - @override - _i7.NodeModel? getNodeById({required String? id}) => - (super.noSuchMethod(Invocation.method(#getNodeById, [], {#id: id})) - as _i7.NodeModel?); - - @override - List<_i7.NodeModel> failoverNodesFor({ - required _i8.CryptoCurrency? currency, - }) => - (super.noSuchMethod( - Invocation.method(#failoverNodesFor, [], {#currency: currency}), - returnValue: <_i7.NodeModel>[], - ) - as List<_i7.NodeModel>); - - @override - _i4.Future save( - _i7.NodeModel? node, - String? password, - bool? shouldNotifyListeners, - ) => - (super.noSuchMethod( - Invocation.method(#save, [node, password, shouldNotifyListeners]), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) - as _i4.Future); - - @override - _i4.Future delete(String? id, bool? shouldNotifyListeners) => - (super.noSuchMethod( - Invocation.method(#delete, [id, shouldNotifyListeners]), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) - as _i4.Future); - - @override - _i4.Future setEnabledState( - String? id, - bool? enabled, - bool? shouldNotifyListeners, - ) => - (super.noSuchMethod( - Invocation.method(#setEnabledState, [ - id, - enabled, - shouldNotifyListeners, - ]), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) - as _i4.Future); - - @override - _i4.Future updateCommunityNodes() => - (super.noSuchMethod( - Invocation.method(#updateCommunityNodes, []), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) - as _i4.Future); - - @override - void addListener(_i5.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#addListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void removeListener(_i5.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#removeListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); - - @override - void notifyListeners() => super.noSuchMethod( - Invocation.method(#notifyListeners, []), - returnValueForMissingStub: null, - ); -} diff --git a/test/screen_tests/onboarding/restore_wallet_view_screen_test.mocks.dart b/test/screen_tests/onboarding/restore_wallet_view_screen_test.mocks.dart index 1b9698d161..4f133dc983 100644 --- a/test/screen_tests/onboarding/restore_wallet_view_screen_test.mocks.dart +++ b/test/screen_tests/onboarding/restore_wallet_view_screen_test.mocks.dart @@ -8,6 +8,7 @@ import 'dart:ui' as _i7; import 'package:flutter/material.dart' as _i5; import 'package:mockito/mockito.dart' as _i1; +import 'package:stackwallet/models/epicbox_server_model.dart' as _i11; import 'package:stackwallet/models/node_model.dart' as _i9; import 'package:stackwallet/services/node_service.dart' as _i8; import 'package:stackwallet/services/wallets_service.dart' as _i6; @@ -249,6 +250,64 @@ class MockNodeService extends _i1.Mock implements _i8.NodeService { ) as _i4.Future); + @override + _i4.Future updateDefaultEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#updateDefaultEpicBoxes, []), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setPrimaryEpicBox({ + required _i11.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners = false, + }) => + (super.noSuchMethod( + Invocation.method(#setPrimaryEpicBox, [], { + #epicBox: epicBox, + #shouldNotifyListeners: shouldNotifyListeners, + }), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + List<_i11.EpicBoxServerModel> getEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#getEpicBoxes, []), + returnValue: <_i11.EpicBoxServerModel>[], + ) + as List<_i11.EpicBoxServerModel>); + + @override + _i11.EpicBoxServerModel? getEpicBoxById({required String? id}) => + (super.noSuchMethod(Invocation.method(#getEpicBoxById, [], {#id: id})) + as _i11.EpicBoxServerModel?); + + @override + _i4.Future addEpicBox( + _i11.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners, + ) => + (super.noSuchMethod( + Invocation.method(#addEpicBox, [epicBox, shouldNotifyListeners]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future deleteEpicBox(String? id, bool? shouldNotifyListeners) => + (super.noSuchMethod( + Invocation.method(#deleteEpicBox, [id, shouldNotifyListeners]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + @override _i4.Future updateCommunityNodes() => (super.noSuchMethod( diff --git a/test/screen_tests/settings_view/settings_subviews/network_settings_subviews/add_custom_node_view_screen_test.mocks.dart b/test/screen_tests/settings_view/settings_subviews/network_settings_subviews/add_custom_node_view_screen_test.mocks.dart index 7448120ce1..2e86d9e57e 100644 --- a/test/screen_tests/settings_view/settings_subviews/network_settings_subviews/add_custom_node_view_screen_test.mocks.dart +++ b/test/screen_tests/settings_view/settings_subviews/network_settings_subviews/add_custom_node_view_screen_test.mocks.dart @@ -4,9 +4,10 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i5; -import 'dart:ui' as _i7; +import 'dart:ui' as _i8; import 'package:mockito/mockito.dart' as _i1; +import 'package:stackwallet/models/epicbox_server_model.dart' as _i7; import 'package:stackwallet/models/node_model.dart' as _i4; import 'package:stackwallet/services/node_service.dart' as _i3; import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart' @@ -170,6 +171,64 @@ class MockNodeService extends _i1.Mock implements _i3.NodeService { ) as _i5.Future); + @override + _i5.Future updateDefaultEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#updateDefaultEpicBoxes, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future setPrimaryEpicBox({ + required _i7.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners = false, + }) => + (super.noSuchMethod( + Invocation.method(#setPrimaryEpicBox, [], { + #epicBox: epicBox, + #shouldNotifyListeners: shouldNotifyListeners, + }), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + List<_i7.EpicBoxServerModel> getEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#getEpicBoxes, []), + returnValue: <_i7.EpicBoxServerModel>[], + ) + as List<_i7.EpicBoxServerModel>); + + @override + _i7.EpicBoxServerModel? getEpicBoxById({required String? id}) => + (super.noSuchMethod(Invocation.method(#getEpicBoxById, [], {#id: id})) + as _i7.EpicBoxServerModel?); + + @override + _i5.Future addEpicBox( + _i7.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners, + ) => + (super.noSuchMethod( + Invocation.method(#addEpicBox, [epicBox, shouldNotifyListeners]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future deleteEpicBox(String? id, bool? shouldNotifyListeners) => + (super.noSuchMethod( + Invocation.method(#deleteEpicBox, [id, shouldNotifyListeners]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + @override _i5.Future updateCommunityNodes() => (super.noSuchMethod( @@ -180,13 +239,13 @@ class MockNodeService extends _i1.Mock implements _i3.NodeService { as _i5.Future); @override - void addListener(_i7.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i8.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#addListener, [listener]), returnValueForMissingStub: null, ); @override - void removeListener(_i7.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i8.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#removeListener, [listener]), returnValueForMissingStub: null, ); diff --git a/test/screen_tests/settings_view/settings_subviews/network_settings_subviews/node_details_view_screen_test.mocks.dart b/test/screen_tests/settings_view/settings_subviews/network_settings_subviews/node_details_view_screen_test.mocks.dart index da7af34723..eeb6f02923 100644 --- a/test/screen_tests/settings_view/settings_subviews/network_settings_subviews/node_details_view_screen_test.mocks.dart +++ b/test/screen_tests/settings_view/settings_subviews/network_settings_subviews/node_details_view_screen_test.mocks.dart @@ -4,9 +4,10 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i5; -import 'dart:ui' as _i7; +import 'dart:ui' as _i8; import 'package:mockito/mockito.dart' as _i1; +import 'package:stackwallet/models/epicbox_server_model.dart' as _i7; import 'package:stackwallet/models/node_model.dart' as _i4; import 'package:stackwallet/services/node_service.dart' as _i3; import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart' @@ -170,6 +171,64 @@ class MockNodeService extends _i1.Mock implements _i3.NodeService { ) as _i5.Future); + @override + _i5.Future updateDefaultEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#updateDefaultEpicBoxes, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future setPrimaryEpicBox({ + required _i7.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners = false, + }) => + (super.noSuchMethod( + Invocation.method(#setPrimaryEpicBox, [], { + #epicBox: epicBox, + #shouldNotifyListeners: shouldNotifyListeners, + }), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + List<_i7.EpicBoxServerModel> getEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#getEpicBoxes, []), + returnValue: <_i7.EpicBoxServerModel>[], + ) + as List<_i7.EpicBoxServerModel>); + + @override + _i7.EpicBoxServerModel? getEpicBoxById({required String? id}) => + (super.noSuchMethod(Invocation.method(#getEpicBoxById, [], {#id: id})) + as _i7.EpicBoxServerModel?); + + @override + _i5.Future addEpicBox( + _i7.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners, + ) => + (super.noSuchMethod( + Invocation.method(#addEpicBox, [epicBox, shouldNotifyListeners]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future deleteEpicBox(String? id, bool? shouldNotifyListeners) => + (super.noSuchMethod( + Invocation.method(#deleteEpicBox, [id, shouldNotifyListeners]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + @override _i5.Future updateCommunityNodes() => (super.noSuchMethod( @@ -180,13 +239,13 @@ class MockNodeService extends _i1.Mock implements _i3.NodeService { as _i5.Future); @override - void addListener(_i7.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i8.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#addListener, [listener]), returnValueForMissingStub: null, ); @override - void removeListener(_i7.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i8.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#removeListener, [listener]), returnValueForMissingStub: null, ); diff --git a/test/screen_tests/settings_view/settings_subviews/network_settings_view_screen_test.mocks.dart b/test/screen_tests/settings_view/settings_subviews/network_settings_view_screen_test.mocks.dart index 241eb0d584..0d75015217 100644 --- a/test/screen_tests/settings_view/settings_subviews/network_settings_view_screen_test.mocks.dart +++ b/test/screen_tests/settings_view/settings_subviews/network_settings_view_screen_test.mocks.dart @@ -4,9 +4,10 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i5; -import 'dart:ui' as _i7; +import 'dart:ui' as _i8; import 'package:mockito/mockito.dart' as _i1; +import 'package:stackwallet/models/epicbox_server_model.dart' as _i7; import 'package:stackwallet/models/node_model.dart' as _i4; import 'package:stackwallet/services/node_service.dart' as _i3; import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart' @@ -170,6 +171,64 @@ class MockNodeService extends _i1.Mock implements _i3.NodeService { ) as _i5.Future); + @override + _i5.Future updateDefaultEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#updateDefaultEpicBoxes, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future setPrimaryEpicBox({ + required _i7.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners = false, + }) => + (super.noSuchMethod( + Invocation.method(#setPrimaryEpicBox, [], { + #epicBox: epicBox, + #shouldNotifyListeners: shouldNotifyListeners, + }), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + List<_i7.EpicBoxServerModel> getEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#getEpicBoxes, []), + returnValue: <_i7.EpicBoxServerModel>[], + ) + as List<_i7.EpicBoxServerModel>); + + @override + _i7.EpicBoxServerModel? getEpicBoxById({required String? id}) => + (super.noSuchMethod(Invocation.method(#getEpicBoxById, [], {#id: id})) + as _i7.EpicBoxServerModel?); + + @override + _i5.Future addEpicBox( + _i7.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners, + ) => + (super.noSuchMethod( + Invocation.method(#addEpicBox, [epicBox, shouldNotifyListeners]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future deleteEpicBox(String? id, bool? shouldNotifyListeners) => + (super.noSuchMethod( + Invocation.method(#deleteEpicBox, [id, shouldNotifyListeners]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + @override _i5.Future updateCommunityNodes() => (super.noSuchMethod( @@ -180,13 +239,13 @@ class MockNodeService extends _i1.Mock implements _i3.NodeService { as _i5.Future); @override - void addListener(_i7.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i8.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#addListener, [listener]), returnValueForMissingStub: null, ); @override - void removeListener(_i7.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i8.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#removeListener, [listener]), returnValueForMissingStub: null, ); diff --git a/test/screen_tests/settings_view/settings_subviews/wallet_settings_view_screen_test.mocks.dart b/test/screen_tests/settings_view/settings_subviews/wallet_settings_view_screen_test.mocks.dart index 6105ab2b81..453540f038 100644 --- a/test/screen_tests/settings_view/settings_subviews/wallet_settings_view_screen_test.mocks.dart +++ b/test/screen_tests/settings_view/settings_subviews/wallet_settings_view_screen_test.mocks.dart @@ -100,6 +100,22 @@ class MockCachedElectrumXClient extends _i1.Mock ) as _i5.Future>); + @override + _i5.Future>> getBatchTransactions({ + required List? txHashes, + required _i6.CryptoCurrency? cryptoCurrency, + }) => + (super.noSuchMethod( + Invocation.method(#getBatchTransactions, [], { + #txHashes: txHashes, + #cryptoCurrency: cryptoCurrency, + }), + returnValue: _i5.Future>>.value( + >[], + ), + ) + as _i5.Future>>); + @override _i5.Future clearSharedTransactionCache({ required _i6.CryptoCurrency? cryptoCurrency, diff --git a/test/services/change_now/change_now_sample_data.dart b/test/services/change_now/change_now_sample_data.dart index 4e63dbf884..b396edbf9d 100644 --- a/test/services/change_now/change_now_sample_data.dart +++ b/test/services/change_now/change_now_sample_data.dart @@ -7,7 +7,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eth", @@ -17,7 +17,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ethbsc", @@ -27,7 +27,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdt", @@ -37,7 +37,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdterc20", @@ -48,7 +48,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": true, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdttrc20", @@ -59,7 +59,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdtbsc", @@ -69,7 +69,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdc", @@ -79,7 +79,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": true, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdcmatic", @@ -90,7 +90,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bnbmainnet", @@ -101,7 +101,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bnbbsc", @@ -111,7 +111,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "busd", @@ -121,7 +121,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "busdbsc", @@ -131,7 +131,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xrp", @@ -141,7 +141,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xrpbsc", @@ -151,7 +151,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ada", @@ -161,7 +161,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "adabsc", @@ -171,7 +171,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sol", @@ -181,7 +181,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "doge", @@ -191,7 +191,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dot", @@ -201,7 +201,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dotbsc", @@ -211,7 +211,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dai", @@ -221,7 +221,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "matic", @@ -231,7 +231,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "maticmainnet", @@ -242,7 +242,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "shib", @@ -252,7 +252,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "shibbsc", @@ -262,7 +262,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "trx", @@ -272,7 +272,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avax", @@ -282,7 +282,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avaxc", @@ -292,7 +292,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "wbtc", @@ -302,7 +302,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "leo", @@ -312,7 +312,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "uni", @@ -322,7 +322,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "etc", @@ -332,7 +332,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ltc", @@ -342,7 +342,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ltcbsc", @@ -352,7 +352,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ftt", @@ -362,7 +362,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "atom", @@ -372,7 +372,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "link", @@ -382,7 +382,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cro", @@ -392,7 +392,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "near", @@ -402,7 +402,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xmr", @@ -412,7 +412,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xlm", @@ -422,7 +422,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bch", @@ -432,7 +432,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "algo", @@ -442,7 +442,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "flow", @@ -452,7 +452,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vet", @@ -462,7 +462,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "icp", @@ -472,7 +472,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fil", @@ -482,7 +482,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ape", @@ -492,7 +492,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eos", @@ -502,7 +502,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mana", @@ -512,7 +512,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sand", @@ -522,7 +522,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hbar", @@ -532,7 +532,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xtz", @@ -542,7 +542,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xtzbsc", @@ -552,7 +552,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "chz", @@ -562,7 +562,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "qnt", @@ -572,7 +572,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "egld", @@ -582,7 +582,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aave", @@ -592,7 +592,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "theta", @@ -602,7 +602,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "axs", @@ -612,7 +612,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tusd", @@ -622,7 +622,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bsv", @@ -632,7 +632,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "okb", @@ -642,7 +642,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "galabsc", @@ -652,7 +652,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zec", @@ -662,7 +662,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdp", @@ -672,7 +672,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bttbsc", @@ -682,7 +682,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "iota", @@ -692,7 +692,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mkr", @@ -702,7 +702,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hnt", @@ -712,7 +712,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ht", @@ -722,7 +722,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "snx", @@ -732,7 +732,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "grt", @@ -742,7 +742,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ftm", @@ -752,7 +752,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ftmmainnet", @@ -762,7 +762,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "klay", @@ -772,7 +772,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "neo", @@ -782,7 +782,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rune", @@ -792,7 +792,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "paxg", @@ -802,7 +802,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ldo", @@ -812,7 +812,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cake", @@ -822,7 +822,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "crv", @@ -832,7 +832,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nexo", @@ -842,7 +842,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bat", @@ -852,7 +852,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dash", @@ -862,7 +862,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "waves", @@ -872,7 +872,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zil", @@ -882,7 +882,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lrc", @@ -892,7 +892,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "enj", @@ -902,7 +902,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ksm", @@ -912,7 +912,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dcr", @@ -922,7 +922,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "btg", @@ -932,7 +932,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gmt", @@ -942,7 +942,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "twt", @@ -952,7 +952,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gno", @@ -962,7 +962,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xem", @@ -972,7 +972,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "1inch", @@ -982,7 +982,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "1inchbsc", @@ -992,7 +992,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "celo", @@ -1002,7 +1002,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hot", @@ -1012,7 +1012,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ust", @@ -1023,7 +1023,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "galaerc20", @@ -1034,7 +1034,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ankr", @@ -1044,7 +1044,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "comp", @@ -1054,7 +1054,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gt", @@ -1064,7 +1064,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "cvx", @@ -1074,7 +1074,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "qtum", @@ -1084,7 +1084,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "yfi", @@ -1094,7 +1094,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xdc", @@ -1104,7 +1104,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kda", @@ -1114,7 +1114,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "iotx", @@ -1124,7 +1124,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cel", @@ -1134,7 +1134,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gusd", @@ -1144,7 +1144,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tfuel", @@ -1154,7 +1154,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rvn", @@ -1164,7 +1164,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "flux", @@ -1174,7 +1174,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bal", @@ -1184,7 +1184,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "amp", @@ -1194,7 +1194,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "op", @@ -1204,7 +1204,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "omg", @@ -1214,7 +1214,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "one", @@ -1224,7 +1224,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zrx", @@ -1234,7 +1234,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rsr", @@ -1244,7 +1244,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "jst", @@ -1254,7 +1254,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "icx", @@ -1264,7 +1264,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xym", @@ -1274,7 +1274,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "iost", @@ -1284,7 +1284,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ens", @@ -1294,7 +1294,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lpt", @@ -1304,7 +1304,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "glm", @@ -1314,7 +1314,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "audio", @@ -1324,7 +1324,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "storj", @@ -1334,7 +1334,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ont", @@ -1344,7 +1344,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ontbsc", @@ -1354,7 +1354,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "waxp", @@ -1364,7 +1364,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "srm", @@ -1374,7 +1374,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sc", @@ -1384,7 +1384,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "imx", @@ -1394,7 +1394,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zen", @@ -1404,7 +1404,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "uma", @@ -1414,7 +1414,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "scrt", @@ -1424,7 +1424,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mxc", @@ -1434,7 +1434,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "btrst", @@ -1444,7 +1444,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "skl", @@ -1454,7 +1454,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "poly", @@ -1464,7 +1464,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "slp", @@ -1474,7 +1474,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "woobsc", @@ -1484,7 +1484,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "woo", @@ -1494,7 +1494,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "chsb", @@ -1504,7 +1504,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cspr", @@ -1514,7 +1514,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "dgb", @@ -1524,7 +1524,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eur", @@ -1534,7 +1534,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "elon", @@ -1544,7 +1544,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dao", @@ -1554,7 +1554,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pla", @@ -1564,7 +1564,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cvc", @@ -1574,7 +1574,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ceek", @@ -1584,7 +1584,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "spell", @@ -1594,7 +1594,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rndr", @@ -1604,7 +1604,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sushi", @@ -1614,7 +1614,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "btcst", @@ -1624,7 +1624,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lsk", @@ -1634,7 +1634,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eps", @@ -1644,7 +1644,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pundix", @@ -1654,7 +1654,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "celr", @@ -1664,7 +1664,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ren", @@ -1674,7 +1674,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xyo", @@ -1684,7 +1684,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nano", @@ -1694,7 +1694,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "win", @@ -1704,7 +1704,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ong", @@ -1714,7 +1714,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "uos", @@ -1724,7 +1724,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "people", @@ -1734,7 +1734,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cfx", @@ -1744,7 +1744,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "req", @@ -1754,7 +1754,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tribe", @@ -1764,7 +1764,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "dydx", @@ -1774,7 +1774,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ardr", @@ -1784,7 +1784,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rly", @@ -1794,7 +1794,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "coti", @@ -1804,7 +1804,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mx", @@ -1814,7 +1814,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "rlc", @@ -1824,7 +1824,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "powr", @@ -1834,7 +1834,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nmr", @@ -1844,7 +1844,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "snt", @@ -1854,7 +1854,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ocean", @@ -1864,7 +1864,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "chr", @@ -1874,7 +1874,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "api3", @@ -1884,7 +1884,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dent", @@ -1894,7 +1894,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bnt", @@ -1904,7 +1904,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fxs", @@ -1914,7 +1914,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hex", @@ -1924,7 +1924,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "steth", @@ -1934,7 +1934,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "btcb", @@ -1944,7 +1944,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "frax", @@ -1954,7 +1954,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "lunc", @@ -1964,7 +1964,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dfi", @@ -1974,7 +1974,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bnx", @@ -1984,7 +1984,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rpl", @@ -1994,7 +1994,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "luna", @@ -2004,7 +2004,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "husd", @@ -2014,7 +2014,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "babydoge", @@ -2024,7 +2024,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "metis", @@ -2034,7 +2034,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "raca", @@ -2044,7 +2044,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "prom", @@ -2054,7 +2054,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "sys", @@ -2064,7 +2064,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gal", @@ -2074,7 +2074,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bico", @@ -2084,7 +2084,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "steem", @@ -2094,7 +2094,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "c98", @@ -2104,7 +2104,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "susd", @@ -2114,7 +2114,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ctsi", @@ -2124,7 +2124,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hxro", @@ -2134,7 +2134,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rep", @@ -2144,7 +2144,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fun", @@ -2154,7 +2154,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pyr", @@ -2164,7 +2164,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "strax", @@ -2174,7 +2174,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bsw", @@ -2184,7 +2184,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lyxe", @@ -2194,7 +2194,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mtl", @@ -2204,7 +2204,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "stmx", @@ -2214,7 +2214,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "stpt", @@ -2224,7 +2224,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "elf", @@ -2234,7 +2234,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ufo", @@ -2244,7 +2244,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "oxt", @@ -2254,7 +2254,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ach", @@ -2264,7 +2264,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ogn", @@ -2274,7 +2274,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sfund", @@ -2284,7 +2284,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tlm", @@ -2294,7 +2294,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "loom", @@ -2304,7 +2304,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ant", @@ -2314,7 +2314,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "alice", @@ -2324,7 +2324,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fet", @@ -2334,7 +2334,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ygg", @@ -2344,7 +2344,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ark", @@ -2354,7 +2354,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "utk", @@ -2364,7 +2364,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "super", @@ -2374,7 +2374,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dusk", @@ -2384,7 +2384,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ilv", @@ -2394,7 +2394,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mbox", @@ -2404,7 +2404,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sun", @@ -2414,7 +2414,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vra", @@ -2424,7 +2424,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aergo", @@ -2434,7 +2434,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bake", @@ -2444,7 +2444,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xvg", @@ -2454,7 +2454,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dpi", @@ -2464,7 +2464,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pols", @@ -2474,7 +2474,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mln", @@ -2484,7 +2484,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xcad", @@ -2494,7 +2494,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "divi", @@ -2504,7 +2504,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "divierc20", @@ -2515,7 +2515,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "sfp", @@ -2525,7 +2525,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tomo", @@ -2535,7 +2535,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "arpa", @@ -2545,7 +2545,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "band", @@ -2555,7 +2555,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bandmainnet", @@ -2566,7 +2566,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sps", @@ -2576,7 +2576,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ava", @@ -2586,7 +2586,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avaerc20", @@ -2596,7 +2596,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avabsc", @@ -2606,7 +2606,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "jasmy", @@ -2616,7 +2616,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cult", @@ -2626,7 +2626,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "starl", @@ -2636,7 +2636,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aioz", @@ -2646,7 +2646,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "alpaca", @@ -2656,7 +2656,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "blz", @@ -2666,7 +2666,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "alcx", @@ -2676,7 +2676,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kmd", @@ -2686,7 +2686,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "yfii", @@ -2696,7 +2696,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "unfi", @@ -2706,7 +2706,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "bel", @@ -2716,7 +2716,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mc", @@ -2726,7 +2726,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dia", @@ -2736,7 +2736,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tko", @@ -2746,7 +2746,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bcd", @@ -2756,7 +2756,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "anc", @@ -2766,7 +2766,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "farm", @@ -2776,7 +2776,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bifi", @@ -2786,7 +2786,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "ata", @@ -2796,7 +2796,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fio", @@ -2806,7 +2806,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ubt", @@ -2816,7 +2816,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pit", @@ -2826,7 +2826,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "dnt", @@ -2836,7 +2836,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "burger", @@ -2846,7 +2846,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "om", @@ -2856,7 +2856,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "grs", @@ -2866,7 +2866,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gas", @@ -2876,7 +2876,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hoge", @@ -2886,7 +2886,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "fox", @@ -2896,7 +2896,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "firo", @@ -2906,7 +2906,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aion", @@ -2916,7 +2916,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "adx", @@ -2926,7 +2926,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nwc", @@ -2937,7 +2937,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "solve", @@ -2947,7 +2947,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cudos", @@ -2957,7 +2957,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rook", @@ -2967,7 +2967,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "klv", @@ -2977,7 +2977,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "front", @@ -2987,7 +2987,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "wtc", @@ -2997,7 +2997,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "beam", @@ -3007,7 +3007,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gto", @@ -3017,7 +3017,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "akro", @@ -3027,7 +3027,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mdt", @@ -3037,7 +3037,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hez", @@ -3047,7 +3047,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pnk", @@ -3057,7 +3057,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ast", @@ -3067,7 +3067,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "snm", @@ -3077,7 +3077,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "qsp", @@ -3087,7 +3087,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "xdb", @@ -3097,7 +3097,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pivx", @@ -3107,7 +3107,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mir", @@ -3117,7 +3117,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "perl", @@ -3127,7 +3127,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "go", @@ -3137,7 +3137,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "urus", @@ -3147,7 +3147,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cell", @@ -3157,7 +3157,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "arv", @@ -3167,7 +3167,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "caps", @@ -3177,7 +3177,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "wabi", @@ -3187,7 +3187,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "swftc", @@ -3197,7 +3197,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "shr", @@ -3207,7 +3207,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "san", @@ -3217,7 +3217,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dobo", @@ -3227,7 +3227,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "hc", @@ -3237,7 +3237,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "fuse", @@ -3247,7 +3247,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dogedash", @@ -3257,7 +3257,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "poolz", @@ -3267,7 +3267,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vib", @@ -3277,7 +3277,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "now", @@ -3287,7 +3287,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "muse", @@ -3297,7 +3297,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mint", @@ -3307,7 +3307,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xor", @@ -3317,7 +3317,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mtv", @@ -3327,7 +3327,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "spi", @@ -3337,7 +3337,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "belt", @@ -3347,7 +3347,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ppt", @@ -3357,7 +3357,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "awc", @@ -3367,7 +3367,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "defit", @@ -3377,7 +3377,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "srk", @@ -3387,7 +3387,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "swrv", @@ -3397,7 +3397,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "pay", @@ -3407,7 +3407,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "lgcy", @@ -3417,7 +3417,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nftb", @@ -3427,7 +3427,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "open", @@ -3437,7 +3437,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "hotcross", @@ -3447,7 +3447,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bin", @@ -3457,7 +3457,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rcn", @@ -3467,7 +3467,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "srn", @@ -3477,7 +3477,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "tking", @@ -3487,7 +3487,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mph", @@ -3497,7 +3497,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mda", @@ -3507,7 +3507,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "skill", @@ -3517,7 +3517,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xio", @@ -3527,7 +3527,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zoon", @@ -3537,7 +3537,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "naft", @@ -3547,7 +3547,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lxt", @@ -3557,7 +3557,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "rainbow", @@ -3567,7 +3567,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "marsh", @@ -3577,7 +3577,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "spo", @@ -3587,7 +3587,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "brd", @@ -3597,7 +3597,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "eved", @@ -3607,7 +3607,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lead", @@ -3617,7 +3617,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cns", @@ -3627,7 +3627,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sfuel", @@ -3637,7 +3637,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bunny", @@ -3647,7 +3647,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "leash", @@ -3657,7 +3657,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "flokibsc", @@ -3667,7 +3667,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "floki", @@ -3677,7 +3677,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "volt", @@ -3687,7 +3687,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "brise", @@ -3697,7 +3697,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kishu", @@ -3707,7 +3707,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "shinja", @@ -3717,7 +3717,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ntvrk", @@ -3727,7 +3727,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "akita", @@ -3737,7 +3737,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zinu", @@ -3747,7 +3747,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gafa", @@ -3757,7 +3757,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rbif", @@ -3767,7 +3767,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "trvl", @@ -3777,7 +3777,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kibabsc", @@ -3787,7 +3787,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kiba", @@ -3797,7 +3797,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "guard", @@ -3807,7 +3807,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "feg", @@ -3817,7 +3817,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "fegbsc", @@ -3827,7 +3827,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "blocks", @@ -3837,7 +3837,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "copi", @@ -3847,7 +3847,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dogecoin", @@ -3857,7 +3857,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "klee", @@ -3867,7 +3867,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "lblock", @@ -3877,7 +3877,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gspi", @@ -3887,7 +3887,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "asia", @@ -3897,7 +3897,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "wise", @@ -3907,7 +3907,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gmr", @@ -3917,7 +3917,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "knc", @@ -3927,7 +3927,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fjb", @@ -3937,7 +3937,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tenfi", @@ -3947,7 +3947,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "btfa", @@ -3957,7 +3957,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aquagoat", @@ -3967,7 +3967,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "titano", @@ -3977,7 +3977,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "sanshu", @@ -3987,7 +3987,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "avn", @@ -3997,7 +3997,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "geth", @@ -4007,7 +4007,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tenshi", @@ -4017,7 +4017,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "poodl", @@ -4027,7 +4027,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pika", @@ -4037,7 +4037,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "defc", @@ -4047,7 +4047,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "keanu", @@ -4057,7 +4057,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "rxcg", @@ -4067,7 +4067,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "dgmoon", @@ -4077,7 +4077,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "koromaru", @@ -4087,7 +4087,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "nsh", @@ -4097,7 +4097,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "fluf", @@ -4107,7 +4107,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lof", @@ -4117,7 +4117,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hmc", @@ -4127,7 +4127,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "nyxt", @@ -4137,7 +4137,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usd", @@ -4147,7 +4147,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "gbp", @@ -4157,7 +4157,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "cad", @@ -4167,7 +4167,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "jpy", @@ -4177,7 +4177,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "rub", @@ -4187,7 +4187,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "aud", @@ -4197,7 +4197,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "chf", @@ -4207,7 +4207,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "czk", @@ -4217,7 +4217,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "dkk", @@ -4227,7 +4227,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "nok", @@ -4237,7 +4237,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "nzd", @@ -4247,7 +4247,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "pln", @@ -4257,7 +4257,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "sek", @@ -4267,7 +4267,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "try", @@ -4277,7 +4277,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "zar", @@ -4287,7 +4287,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "huf", @@ -4297,7 +4297,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "ils", @@ -4307,7 +4307,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "brl", @@ -4317,7 +4317,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "fetbsc", @@ -4327,7 +4327,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mononoke", @@ -4338,7 +4338,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "daibsc", @@ -4348,7 +4348,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "miota", @@ -4358,7 +4358,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "luffy", @@ -4368,7 +4368,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vgx", @@ -4378,7 +4378,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdtsol", @@ -4389,7 +4389,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nearbsc", @@ -4399,7 +4399,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "iotxbsc", @@ -4409,7 +4409,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "metiserc20", @@ -4419,7 +4419,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nowbep2", @@ -4429,7 +4429,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "saitamav2", @@ -4439,7 +4439,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "vlxbsc", @@ -4449,7 +4449,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "dfibsc", @@ -4459,7 +4459,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "usdcsol", @@ -4469,7 +4469,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "clear", @@ -4479,7 +4479,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdcbsc", @@ -4489,7 +4489,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bttcbsc", @@ -4499,7 +4499,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "maticbsc", @@ -4509,7 +4509,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avaxbsc", @@ -4519,7 +4519,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ppm", @@ -4529,7 +4529,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bttc", @@ -4539,7 +4539,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "trxbsc", @@ -4549,7 +4549,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "etcbsc", @@ -4559,7 +4559,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "atombsc", @@ -4569,7 +4569,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bchbsc", @@ -4579,7 +4579,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vetbsc", @@ -4589,7 +4589,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "filbsc", @@ -4599,7 +4599,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "egldbsc", @@ -4609,7 +4609,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "axsbsc", @@ -4619,7 +4619,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tusdbsc", @@ -4629,7 +4629,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eosbsc", @@ -4639,7 +4639,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mkrbsc", @@ -4649,7 +4649,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdpbsc", @@ -4659,7 +4659,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "daimatic", @@ -4669,7 +4669,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "zecbsc", @@ -4679,7 +4679,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ftmbsc", @@ -4689,7 +4689,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "manabsc", @@ -4699,7 +4699,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "batbsc", @@ -4709,7 +4709,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sxpmainnet", @@ -4720,7 +4720,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "zilbsc", @@ -4730,7 +4730,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "compbsc", @@ -4740,7 +4740,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "snxbsc", @@ -4750,7 +4750,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "solbsc", @@ -4760,7 +4760,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ceekerc20", @@ -4771,7 +4771,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "yfibsc", @@ -4781,7 +4781,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kncbsc", @@ -4791,7 +4791,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "chrbsc", @@ -4801,7 +4801,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sushibsc", @@ -4811,7 +4811,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdtmatic", @@ -4821,7 +4821,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "ankrbsc", @@ -4831,7 +4831,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "celrbsc", @@ -4841,7 +4841,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sandmatic", @@ -4852,7 +4852,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "busdbnb", @@ -4862,7 +4862,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "xcnbsc", @@ -4872,7 +4872,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "plamatic", @@ -4882,7 +4882,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fluxerc20", @@ -4892,7 +4892,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "c98erc20", @@ -4902,7 +4902,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "krw", @@ -4912,7 +4912,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "world", @@ -4922,7 +4922,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "all", @@ -4932,7 +4932,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "amd", @@ -4942,7 +4942,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "ang", @@ -4952,7 +4952,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "bam", @@ -4962,7 +4962,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "bbd", @@ -4972,7 +4972,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "bdt", @@ -4982,7 +4982,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "bmd", @@ -4992,7 +4992,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "bnd", @@ -5002,7 +5002,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "bob", @@ -5012,7 +5012,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "bwp", @@ -5022,7 +5022,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "byn", @@ -5032,7 +5032,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "cny", @@ -5042,7 +5042,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "djf", @@ -5052,7 +5052,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "egp", @@ -5062,7 +5062,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "ghs", @@ -5072,7 +5072,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "gtq", @@ -5082,7 +5082,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "hnl", @@ -5092,7 +5092,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "hrk", @@ -5102,7 +5102,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "isk", @@ -5112,7 +5112,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "jmd", @@ -5122,7 +5122,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "kes", @@ -5132,7 +5132,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "kgs", @@ -5142,7 +5142,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "khr", @@ -5152,7 +5152,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "kyd", @@ -5162,7 +5162,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "lbp", @@ -5172,7 +5172,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "lkr", @@ -5182,7 +5182,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "mkd", @@ -5192,7 +5192,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "mnt", @@ -5202,7 +5202,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "mop", @@ -5212,7 +5212,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "mur", @@ -5222,7 +5222,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "mzn", @@ -5232,7 +5232,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "pab", @@ -5242,7 +5242,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "pgk", @@ -5252,7 +5252,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "pkr", @@ -5262,7 +5262,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "pyg", @@ -5272,7 +5272,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "rsd", @@ -5282,7 +5282,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "sos", @@ -5292,7 +5292,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "thb", @@ -5302,7 +5302,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "ttd", @@ -5312,7 +5312,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "tzs", @@ -5322,7 +5322,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "ugx", @@ -5332,7 +5332,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "xaf", @@ -5342,7 +5342,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "xof", @@ -5352,7 +5352,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "zmw", @@ -5362,7 +5362,7 @@ const List> availableCurrenciesJSON = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "momento", @@ -5372,7 +5372,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fire", @@ -5382,7 +5382,7 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ghc", @@ -5392,8 +5392,8 @@ const List> availableCurrenciesJSON = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true - } + "supportsFixedRate": true, + }, ]; const List> availableCurrenciesJSONActive = [ @@ -5405,7 +5405,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eth", @@ -5415,7 +5415,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ethbsc", @@ -5425,7 +5425,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdt", @@ -5435,7 +5435,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdterc20", @@ -5446,7 +5446,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": true, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdttrc20", @@ -5457,7 +5457,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdtbsc", @@ -5467,7 +5467,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdc", @@ -5477,7 +5477,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": true, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdcmatic", @@ -5488,7 +5488,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bnbmainnet", @@ -5499,7 +5499,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bnbbsc", @@ -5509,7 +5509,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "busd", @@ -5519,7 +5519,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "busdbsc", @@ -5529,7 +5529,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xrp", @@ -5539,7 +5539,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xrpbsc", @@ -5549,7 +5549,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ada", @@ -5559,7 +5559,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "adabsc", @@ -5569,7 +5569,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sol", @@ -5579,7 +5579,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "doge", @@ -5589,7 +5589,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dot", @@ -5599,7 +5599,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dotbsc", @@ -5609,7 +5609,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dai", @@ -5619,7 +5619,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "matic", @@ -5629,7 +5629,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "maticmainnet", @@ -5640,7 +5640,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "shib", @@ -5650,7 +5650,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "shibbsc", @@ -5660,7 +5660,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "trx", @@ -5670,7 +5670,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avax", @@ -5680,7 +5680,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avaxc", @@ -5690,7 +5690,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "wbtc", @@ -5700,7 +5700,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "leo", @@ -5710,7 +5710,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "uni", @@ -5720,7 +5720,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "etc", @@ -5730,7 +5730,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ltc", @@ -5740,7 +5740,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ltcbsc", @@ -5750,7 +5750,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ftt", @@ -5760,7 +5760,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "link", @@ -5770,7 +5770,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "atom", @@ -5780,7 +5780,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cro", @@ -5790,7 +5790,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "near", @@ -5800,7 +5800,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xmr", @@ -5810,7 +5810,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xlm", @@ -5820,7 +5820,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bch", @@ -5830,7 +5830,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "algo", @@ -5840,7 +5840,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "flow", @@ -5850,7 +5850,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vet", @@ -5860,7 +5860,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "icp", @@ -5870,7 +5870,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fil", @@ -5880,7 +5880,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ape", @@ -5890,7 +5890,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eos", @@ -5900,7 +5900,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mana", @@ -5910,7 +5910,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sand", @@ -5920,7 +5920,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hbar", @@ -5930,7 +5930,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xtz", @@ -5940,7 +5940,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xtzbsc", @@ -5950,7 +5950,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "chz", @@ -5960,7 +5960,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "qnt", @@ -5970,7 +5970,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "egld", @@ -5980,7 +5980,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aave", @@ -5990,7 +5990,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "theta", @@ -6000,7 +6000,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "axs", @@ -6010,7 +6010,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tusd", @@ -6020,7 +6020,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bsv", @@ -6030,7 +6030,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "okb", @@ -6040,7 +6040,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "galabsc", @@ -6050,7 +6050,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zec", @@ -6060,7 +6060,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdp", @@ -6070,7 +6070,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bttbsc", @@ -6080,7 +6080,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "iota", @@ -6090,7 +6090,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mkr", @@ -6100,7 +6100,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hnt", @@ -6110,7 +6110,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ht", @@ -6120,7 +6120,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "snx", @@ -6130,7 +6130,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "grt", @@ -6140,7 +6140,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "klay", @@ -6150,7 +6150,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ftm", @@ -6160,7 +6160,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ftmmainnet", @@ -6170,7 +6170,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "neo", @@ -6180,7 +6180,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rune", @@ -6190,7 +6190,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "paxg", @@ -6200,7 +6200,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ldo", @@ -6210,7 +6210,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cake", @@ -6220,7 +6220,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "crv", @@ -6230,7 +6230,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nexo", @@ -6240,7 +6240,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bat", @@ -6250,7 +6250,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dash", @@ -6260,7 +6260,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "waves", @@ -6270,7 +6270,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zil", @@ -6280,7 +6280,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lrc", @@ -6290,7 +6290,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "enj", @@ -6300,7 +6300,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ksm", @@ -6310,7 +6310,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dcr", @@ -6320,7 +6320,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "btg", @@ -6330,7 +6330,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gmt", @@ -6340,7 +6340,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gno", @@ -6350,7 +6350,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "twt", @@ -6360,7 +6360,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xem", @@ -6370,7 +6370,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "1inch", @@ -6380,7 +6380,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "1inchbsc", @@ -6390,7 +6390,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "celo", @@ -6400,7 +6400,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hot", @@ -6410,7 +6410,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "galaerc20", @@ -6421,7 +6421,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ankr", @@ -6431,7 +6431,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "comp", @@ -6441,7 +6441,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gt", @@ -6451,7 +6451,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "cvx", @@ -6461,7 +6461,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "qtum", @@ -6471,7 +6471,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "yfi", @@ -6481,7 +6481,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xdc", @@ -6491,7 +6491,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "iotx", @@ -6501,7 +6501,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cel", @@ -6511,7 +6511,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gusd", @@ -6521,7 +6521,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tfuel", @@ -6531,7 +6531,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rvn", @@ -6541,7 +6541,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "flux", @@ -6551,7 +6551,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bal", @@ -6561,7 +6561,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "amp", @@ -6571,7 +6571,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "omg", @@ -6581,7 +6581,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zrx", @@ -6591,7 +6591,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rsr", @@ -6601,7 +6601,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "one", @@ -6611,7 +6611,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "jst", @@ -6621,7 +6621,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "icx", @@ -6631,7 +6631,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xym", @@ -6641,7 +6641,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "iost", @@ -6651,7 +6651,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ens", @@ -6661,7 +6661,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lpt", @@ -6671,7 +6671,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "glm", @@ -6681,7 +6681,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "audio", @@ -6691,7 +6691,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "storj", @@ -6701,7 +6701,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ont", @@ -6711,7 +6711,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ontbsc", @@ -6721,7 +6721,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "waxp", @@ -6731,7 +6731,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "srm", @@ -6741,7 +6741,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sc", @@ -6751,7 +6751,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "imx", @@ -6761,7 +6761,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zen", @@ -6771,7 +6771,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "uma", @@ -6781,7 +6781,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "scrt", @@ -6791,7 +6791,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mxc", @@ -6801,7 +6801,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "btrst", @@ -6811,7 +6811,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "skl", @@ -6821,7 +6821,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "poly", @@ -6831,7 +6831,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "slp", @@ -6841,7 +6841,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "woobsc", @@ -6851,7 +6851,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "woo", @@ -6861,7 +6861,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "chsb", @@ -6871,7 +6871,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cspr", @@ -6881,7 +6881,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "dgb", @@ -6891,7 +6891,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eur", @@ -6901,7 +6901,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "elon", @@ -6911,7 +6911,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dao", @@ -6921,7 +6921,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pla", @@ -6931,7 +6931,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cvc", @@ -6941,7 +6941,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ceek", @@ -6951,7 +6951,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "spell", @@ -6961,7 +6961,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rndr", @@ -6971,7 +6971,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sushi", @@ -6981,7 +6981,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "btcst", @@ -6991,7 +6991,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lsk", @@ -7001,7 +7001,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eps", @@ -7011,7 +7011,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pundix", @@ -7021,7 +7021,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "celr", @@ -7031,7 +7031,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ren", @@ -7041,7 +7041,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nano", @@ -7051,7 +7051,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xyo", @@ -7061,7 +7061,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "win", @@ -7071,7 +7071,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ong", @@ -7081,7 +7081,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "people", @@ -7091,7 +7091,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "uos", @@ -7101,7 +7101,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cfx", @@ -7111,7 +7111,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "req", @@ -7121,7 +7121,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tribe", @@ -7131,7 +7131,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "dydx", @@ -7141,7 +7141,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ardr", @@ -7151,7 +7151,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rly", @@ -7161,7 +7161,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "coti", @@ -7171,7 +7171,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mx", @@ -7181,7 +7181,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "rlc", @@ -7191,7 +7191,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "powr", @@ -7201,7 +7201,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nmr", @@ -7211,7 +7211,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "snt", @@ -7221,7 +7221,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ocean", @@ -7231,7 +7231,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "chr", @@ -7241,7 +7241,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "api3", @@ -7251,7 +7251,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dent", @@ -7261,7 +7261,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bnt", @@ -7271,7 +7271,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fxs", @@ -7281,7 +7281,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hex", @@ -7291,7 +7291,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "steth", @@ -7301,7 +7301,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "btcb", @@ -7311,7 +7311,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lunc", @@ -7321,7 +7321,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dfi", @@ -7331,7 +7331,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bnx", @@ -7341,7 +7341,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rpl", @@ -7351,7 +7351,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "luna", @@ -7361,7 +7361,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "husd", @@ -7371,7 +7371,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "babydoge", @@ -7381,7 +7381,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "metis", @@ -7391,7 +7391,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "raca", @@ -7401,7 +7401,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "prom", @@ -7411,7 +7411,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "sys", @@ -7421,7 +7421,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gal", @@ -7431,7 +7431,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bico", @@ -7441,7 +7441,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "steem", @@ -7451,7 +7451,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "c98", @@ -7461,7 +7461,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "susd", @@ -7471,7 +7471,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ctsi", @@ -7481,7 +7481,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hxro", @@ -7491,7 +7491,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rep", @@ -7501,7 +7501,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fun", @@ -7511,7 +7511,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pyr", @@ -7521,7 +7521,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "strax", @@ -7531,7 +7531,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bsw", @@ -7541,7 +7541,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lyxe", @@ -7551,7 +7551,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mtl", @@ -7561,7 +7561,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "stmx", @@ -7571,7 +7571,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "stpt", @@ -7581,7 +7581,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "elf", @@ -7591,7 +7591,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "oxt", @@ -7601,7 +7601,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ufo", @@ -7611,7 +7611,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ach", @@ -7621,7 +7621,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ogn", @@ -7631,7 +7631,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sfund", @@ -7641,7 +7641,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tlm", @@ -7651,7 +7651,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "loom", @@ -7661,7 +7661,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ant", @@ -7671,7 +7671,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "alice", @@ -7681,7 +7681,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fet", @@ -7691,7 +7691,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ygg", @@ -7701,7 +7701,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ark", @@ -7711,7 +7711,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "utk", @@ -7721,7 +7721,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "super", @@ -7731,7 +7731,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dusk", @@ -7741,7 +7741,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ilv", @@ -7751,7 +7751,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mbox", @@ -7761,7 +7761,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sun", @@ -7771,7 +7771,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aergo", @@ -7781,7 +7781,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vra", @@ -7791,7 +7791,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bake", @@ -7801,7 +7801,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xvg", @@ -7811,7 +7811,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dpi", @@ -7821,7 +7821,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pols", @@ -7831,7 +7831,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mln", @@ -7841,7 +7841,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xcad", @@ -7851,7 +7851,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "divi", @@ -7861,7 +7861,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "divierc20", @@ -7872,7 +7872,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "sfp", @@ -7882,7 +7882,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tomo", @@ -7892,7 +7892,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "arpa", @@ -7902,7 +7902,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "band", @@ -7912,7 +7912,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bandmainnet", @@ -7923,7 +7923,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sps", @@ -7933,7 +7933,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ava", @@ -7943,7 +7943,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avaerc20", @@ -7953,7 +7953,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avabsc", @@ -7963,7 +7963,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "jasmy", @@ -7973,7 +7973,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cult", @@ -7983,7 +7983,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "starl", @@ -7993,7 +7993,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aioz", @@ -8003,7 +8003,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "alpaca", @@ -8013,7 +8013,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "blz", @@ -8023,7 +8023,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kmd", @@ -8033,7 +8033,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "alcx", @@ -8043,7 +8043,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "yfii", @@ -8053,7 +8053,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "unfi", @@ -8063,7 +8063,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "bel", @@ -8073,7 +8073,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mc", @@ -8083,7 +8083,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dia", @@ -8093,7 +8093,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tko", @@ -8103,7 +8103,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bcd", @@ -8113,7 +8113,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "farm", @@ -8123,7 +8123,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bifi", @@ -8133,7 +8133,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "ata", @@ -8143,7 +8143,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fio", @@ -8153,7 +8153,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ubt", @@ -8163,7 +8163,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pit", @@ -8173,7 +8173,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "dnt", @@ -8183,7 +8183,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "burger", @@ -8193,7 +8193,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "grs", @@ -8203,7 +8203,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "om", @@ -8213,7 +8213,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gas", @@ -8223,7 +8223,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hoge", @@ -8233,7 +8233,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "fox", @@ -8243,7 +8243,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "firo", @@ -8253,7 +8253,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aion", @@ -8263,7 +8263,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "adx", @@ -8273,7 +8273,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nwc", @@ -8284,7 +8284,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cudos", @@ -8294,7 +8294,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "solve", @@ -8304,7 +8304,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "klv", @@ -8314,7 +8314,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rook", @@ -8324,7 +8324,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "front", @@ -8334,7 +8334,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "wtc", @@ -8344,7 +8344,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "beam", @@ -8354,7 +8354,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gto", @@ -8364,7 +8364,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "akro", @@ -8374,7 +8374,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mdt", @@ -8384,7 +8384,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hez", @@ -8394,7 +8394,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pnk", @@ -8404,7 +8404,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ast", @@ -8414,7 +8414,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "snm", @@ -8424,7 +8424,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "qsp", @@ -8434,7 +8434,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "pivx", @@ -8444,7 +8444,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xdb", @@ -8454,7 +8454,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mir", @@ -8464,7 +8464,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "perl", @@ -8474,7 +8474,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "go", @@ -8484,7 +8484,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "urus", @@ -8494,7 +8494,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "arv", @@ -8504,7 +8504,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cell", @@ -8514,7 +8514,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "caps", @@ -8524,7 +8524,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "wabi", @@ -8534,7 +8534,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "swftc", @@ -8544,7 +8544,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "shr", @@ -8554,7 +8554,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "san", @@ -8564,7 +8564,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dobo", @@ -8574,7 +8574,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "hc", @@ -8584,7 +8584,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "fuse", @@ -8594,7 +8594,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dogedash", @@ -8604,7 +8604,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "poolz", @@ -8614,7 +8614,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vib", @@ -8624,7 +8624,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "now", @@ -8634,7 +8634,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "muse", @@ -8644,7 +8644,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mint", @@ -8654,7 +8654,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xor", @@ -8664,7 +8664,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mtv", @@ -8674,7 +8674,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "spi", @@ -8684,7 +8684,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "belt", @@ -8694,7 +8694,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ppt", @@ -8704,7 +8704,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "awc", @@ -8714,7 +8714,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "defit", @@ -8724,7 +8724,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "srk", @@ -8734,7 +8734,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "swrv", @@ -8744,7 +8744,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "pay", @@ -8754,7 +8754,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "lgcy", @@ -8764,7 +8764,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nftb", @@ -8774,7 +8774,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "open", @@ -8784,7 +8784,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "hotcross", @@ -8794,7 +8794,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bin", @@ -8804,7 +8804,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rcn", @@ -8814,7 +8814,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "srn", @@ -8824,7 +8824,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "tking", @@ -8834,7 +8834,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mph", @@ -8844,7 +8844,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mda", @@ -8854,7 +8854,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "skill", @@ -8864,7 +8864,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xio", @@ -8874,7 +8874,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zoon", @@ -8884,7 +8884,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lxt", @@ -8894,7 +8894,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "naft", @@ -8904,7 +8904,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rainbow", @@ -8914,7 +8914,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "marsh", @@ -8924,7 +8924,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "spo", @@ -8934,7 +8934,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "brd", @@ -8944,7 +8944,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "eved", @@ -8954,7 +8954,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lead", @@ -8964,7 +8964,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cns", @@ -8974,7 +8974,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sfuel", @@ -8984,7 +8984,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bunny", @@ -8994,7 +8994,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "leash", @@ -9004,7 +9004,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "flokibsc", @@ -9014,7 +9014,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "floki", @@ -9024,7 +9024,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "volt", @@ -9034,7 +9034,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "brise", @@ -9044,7 +9044,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kishu", @@ -9054,7 +9054,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "shinja", @@ -9064,7 +9064,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ntvrk", @@ -9074,7 +9074,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "akita", @@ -9084,7 +9084,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zinu", @@ -9094,7 +9094,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gafa", @@ -9104,7 +9104,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rbif", @@ -9114,7 +9114,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "trvl", @@ -9124,7 +9124,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kibabsc", @@ -9134,7 +9134,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kiba", @@ -9144,7 +9144,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "guard", @@ -9154,7 +9154,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "feg", @@ -9164,7 +9164,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "fegbsc", @@ -9174,7 +9174,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "blocks", @@ -9184,7 +9184,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "copi", @@ -9194,7 +9194,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dogecoin", @@ -9204,7 +9204,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "klee", @@ -9214,7 +9214,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "lblock", @@ -9224,7 +9224,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gspi", @@ -9234,7 +9234,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "asia", @@ -9244,7 +9244,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "wise", @@ -9254,7 +9254,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gmr", @@ -9264,7 +9264,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "knc", @@ -9274,7 +9274,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fjb", @@ -9284,7 +9284,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tenfi", @@ -9294,7 +9294,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "btfa", @@ -9304,7 +9304,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aquagoat", @@ -9314,7 +9314,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "titano", @@ -9324,7 +9324,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "sanshu", @@ -9334,7 +9334,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "avn", @@ -9344,7 +9344,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tenshi", @@ -9354,7 +9354,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "poodl", @@ -9364,7 +9364,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pika", @@ -9374,7 +9374,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "geth", @@ -9384,7 +9384,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "defc", @@ -9394,7 +9394,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "keanu", @@ -9404,7 +9404,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "dgmoon", @@ -9414,7 +9414,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "koromaru", @@ -9424,7 +9424,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "nsh", @@ -9434,7 +9434,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "fluf", @@ -9444,7 +9444,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lof", @@ -9454,7 +9454,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hmc", @@ -9464,7 +9464,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "nyxt", @@ -9474,7 +9474,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usd", @@ -9484,7 +9484,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "gbp", @@ -9494,7 +9494,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "cad", @@ -9504,7 +9504,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "jpy", @@ -9514,7 +9514,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "rub", @@ -9524,7 +9524,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "aud", @@ -9534,7 +9534,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "chf", @@ -9544,7 +9544,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "czk", @@ -9554,7 +9554,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "dkk", @@ -9564,7 +9564,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "nok", @@ -9574,7 +9574,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "nzd", @@ -9584,7 +9584,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "pln", @@ -9594,7 +9594,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "sek", @@ -9604,7 +9604,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "try", @@ -9614,7 +9614,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "zar", @@ -9624,7 +9624,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "huf", @@ -9634,7 +9634,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "ils", @@ -9644,7 +9644,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "brl", @@ -9654,7 +9654,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "fetbsc", @@ -9664,7 +9664,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mononoke", @@ -9675,7 +9675,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "daibsc", @@ -9685,7 +9685,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "miota", @@ -9695,7 +9695,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "luffy", @@ -9705,7 +9705,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vgx", @@ -9715,7 +9715,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdtsol", @@ -9726,7 +9726,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nearbsc", @@ -9736,7 +9736,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "iotxbsc", @@ -9746,7 +9746,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "metiserc20", @@ -9756,7 +9756,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nowbep2", @@ -9766,7 +9766,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "saitamav2", @@ -9776,7 +9776,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "vlxbsc", @@ -9786,7 +9786,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "dfibsc", @@ -9796,7 +9796,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "usdcsol", @@ -9806,7 +9806,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "clear", @@ -9816,7 +9816,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdcbsc", @@ -9826,7 +9826,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bttcbsc", @@ -9836,7 +9836,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "maticbsc", @@ -9846,7 +9846,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avaxbsc", @@ -9856,7 +9856,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ppm", @@ -9866,7 +9866,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bttc", @@ -9876,7 +9876,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "trxbsc", @@ -9886,7 +9886,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "etcbsc", @@ -9896,7 +9896,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "atombsc", @@ -9906,7 +9906,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bchbsc", @@ -9916,7 +9916,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vetbsc", @@ -9926,7 +9926,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "filbsc", @@ -9936,7 +9936,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "egldbsc", @@ -9946,7 +9946,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "axsbsc", @@ -9956,7 +9956,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tusdbsc", @@ -9966,7 +9966,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eosbsc", @@ -9976,7 +9976,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mkrbsc", @@ -9986,7 +9986,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdpbsc", @@ -9996,7 +9996,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "daimatic", @@ -10006,7 +10006,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "zecbsc", @@ -10016,7 +10016,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ftmbsc", @@ -10026,7 +10026,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "manabsc", @@ -10036,7 +10036,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "batbsc", @@ -10046,7 +10046,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sxpmainnet", @@ -10057,7 +10057,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "zilbsc", @@ -10067,7 +10067,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "compbsc", @@ -10077,7 +10077,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "snxbsc", @@ -10087,7 +10087,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "solbsc", @@ -10097,7 +10097,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ceekerc20", @@ -10108,7 +10108,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "yfibsc", @@ -10118,7 +10118,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kncbsc", @@ -10128,7 +10128,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "chrbsc", @@ -10138,7 +10138,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sushibsc", @@ -10148,7 +10148,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdtmatic", @@ -10158,7 +10158,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "ankrbsc", @@ -10168,7 +10168,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "celrbsc", @@ -10178,7 +10178,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sandmatic", @@ -10189,7 +10189,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xcnbsc", @@ -10199,7 +10199,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "plamatic", @@ -10209,7 +10209,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fluxerc20", @@ -10219,7 +10219,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "c98erc20", @@ -10229,7 +10229,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "krw", @@ -10239,7 +10239,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "world", @@ -10249,7 +10249,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "all", @@ -10259,7 +10259,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "amd", @@ -10269,7 +10269,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "ang", @@ -10279,7 +10279,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "bam", @@ -10289,7 +10289,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "bbd", @@ -10299,7 +10299,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "bdt", @@ -10309,7 +10309,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "bmd", @@ -10319,7 +10319,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "bnd", @@ -10329,7 +10329,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "bob", @@ -10339,7 +10339,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "bwp", @@ -10349,7 +10349,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "byn", @@ -10359,7 +10359,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "cny", @@ -10369,7 +10369,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "djf", @@ -10379,7 +10379,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "egp", @@ -10389,7 +10389,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "ghs", @@ -10399,7 +10399,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "gtq", @@ -10409,7 +10409,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "hnl", @@ -10419,7 +10419,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "hrk", @@ -10429,7 +10429,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "isk", @@ -10439,7 +10439,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "jmd", @@ -10449,7 +10449,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "kes", @@ -10459,7 +10459,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "kgs", @@ -10469,7 +10469,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "khr", @@ -10479,7 +10479,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "kyd", @@ -10489,7 +10489,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "lbp", @@ -10499,7 +10499,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "lkr", @@ -10509,7 +10509,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "mkd", @@ -10519,7 +10519,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "mnt", @@ -10529,7 +10529,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "mop", @@ -10539,7 +10539,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "mur", @@ -10549,7 +10549,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "mzn", @@ -10559,7 +10559,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "pab", @@ -10569,7 +10569,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "pgk", @@ -10579,7 +10579,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "pkr", @@ -10589,7 +10589,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "pyg", @@ -10599,7 +10599,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "rsd", @@ -10609,7 +10609,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "sos", @@ -10619,7 +10619,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "thb", @@ -10629,7 +10629,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "ttd", @@ -10639,7 +10639,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "tzs", @@ -10649,7 +10649,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "ugx", @@ -10659,7 +10659,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "xaf", @@ -10669,7 +10669,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "xof", @@ -10679,7 +10679,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "zmw", @@ -10689,7 +10689,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": true, "featured": false, "isStable": false, - "supportsFixedRate": false + "supportsFixedRate": false, }, { "ticker": "momento", @@ -10699,7 +10699,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fire", @@ -10709,7 +10709,7 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ghc", @@ -10719,8 +10719,8 @@ const List> availableCurrenciesJSONActive = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true - } + "supportsFixedRate": true, + }, ]; const List> availableCurrenciesJSONFixedRate = [ @@ -10732,7 +10732,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eth", @@ -10742,7 +10742,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ethbsc", @@ -10752,7 +10752,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdt", @@ -10762,7 +10762,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdterc20", @@ -10773,7 +10773,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": true, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdttrc20", @@ -10784,7 +10784,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdtbsc", @@ -10794,7 +10794,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdc", @@ -10804,7 +10804,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": true, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdcmatic", @@ -10815,7 +10815,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bnbmainnet", @@ -10826,7 +10826,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bnbbsc", @@ -10836,7 +10836,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "busd", @@ -10846,7 +10846,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "busdbsc", @@ -10856,7 +10856,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xrp", @@ -10866,7 +10866,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xrpbsc", @@ -10876,7 +10876,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ada", @@ -10886,7 +10886,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "adabsc", @@ -10896,7 +10896,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sol", @@ -10906,7 +10906,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "doge", @@ -10916,7 +10916,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dot", @@ -10926,7 +10926,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dotbsc", @@ -10936,7 +10936,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dai", @@ -10946,7 +10946,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "matic", @@ -10956,7 +10956,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "maticmainnet", @@ -10967,7 +10967,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "shib", @@ -10977,7 +10977,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "shibbsc", @@ -10987,7 +10987,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "trx", @@ -10997,7 +10997,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avax", @@ -11007,7 +11007,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avaxc", @@ -11017,7 +11017,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "wbtc", @@ -11027,7 +11027,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "leo", @@ -11037,7 +11037,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "uni", @@ -11047,7 +11047,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "etc", @@ -11057,7 +11057,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ltc", @@ -11067,7 +11067,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ltcbsc", @@ -11077,7 +11077,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ftt", @@ -11087,7 +11087,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "link", @@ -11097,7 +11097,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "atom", @@ -11107,7 +11107,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cro", @@ -11117,7 +11117,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "near", @@ -11127,7 +11127,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xmr", @@ -11137,7 +11137,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xlm", @@ -11147,7 +11147,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bch", @@ -11157,7 +11157,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "algo", @@ -11167,7 +11167,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "flow", @@ -11177,7 +11177,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vet", @@ -11187,7 +11187,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "icp", @@ -11197,7 +11197,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fil", @@ -11207,7 +11207,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ape", @@ -11217,7 +11217,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eos", @@ -11227,7 +11227,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mana", @@ -11237,7 +11237,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sand", @@ -11247,7 +11247,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hbar", @@ -11257,7 +11257,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xtz", @@ -11267,7 +11267,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xtzbsc", @@ -11277,7 +11277,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "chz", @@ -11287,7 +11287,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "qnt", @@ -11297,7 +11297,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "egld", @@ -11307,7 +11307,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aave", @@ -11317,7 +11317,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "theta", @@ -11327,7 +11327,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "axs", @@ -11337,7 +11337,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tusd", @@ -11347,7 +11347,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bsv", @@ -11357,7 +11357,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "okb", @@ -11367,7 +11367,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "galabsc", @@ -11377,7 +11377,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zec", @@ -11387,7 +11387,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdp", @@ -11397,7 +11397,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bttbsc", @@ -11407,7 +11407,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "iota", @@ -11417,7 +11417,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mkr", @@ -11427,7 +11427,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hnt", @@ -11437,7 +11437,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ht", @@ -11447,7 +11447,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "snx", @@ -11457,7 +11457,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "grt", @@ -11467,7 +11467,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "klay", @@ -11477,7 +11477,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ftm", @@ -11487,7 +11487,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ftmmainnet", @@ -11497,7 +11497,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "neo", @@ -11507,7 +11507,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "paxg", @@ -11517,7 +11517,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ldo", @@ -11527,7 +11527,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cake", @@ -11537,7 +11537,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "crv", @@ -11547,7 +11547,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nexo", @@ -11557,7 +11557,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bat", @@ -11567,7 +11567,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dash", @@ -11577,7 +11577,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "waves", @@ -11587,7 +11587,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zil", @@ -11597,7 +11597,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lrc", @@ -11607,7 +11607,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "enj", @@ -11617,7 +11617,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ksm", @@ -11627,7 +11627,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dcr", @@ -11637,7 +11637,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "btg", @@ -11647,7 +11647,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gmt", @@ -11657,7 +11657,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gno", @@ -11667,7 +11667,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "twt", @@ -11677,7 +11677,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xem", @@ -11687,7 +11687,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "1inch", @@ -11697,7 +11697,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "1inchbsc", @@ -11707,7 +11707,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "celo", @@ -11717,7 +11717,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hot", @@ -11727,7 +11727,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "galaerc20", @@ -11738,7 +11738,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ankr", @@ -11748,7 +11748,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "comp", @@ -11758,7 +11758,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cvx", @@ -11768,7 +11768,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "qtum", @@ -11778,7 +11778,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "yfi", @@ -11788,7 +11788,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xdc", @@ -11798,7 +11798,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "iotx", @@ -11808,7 +11808,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cel", @@ -11818,7 +11818,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gusd", @@ -11828,7 +11828,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tfuel", @@ -11838,7 +11838,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rvn", @@ -11848,7 +11848,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "flux", @@ -11858,7 +11858,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bal", @@ -11868,7 +11868,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "amp", @@ -11878,7 +11878,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "omg", @@ -11888,7 +11888,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zrx", @@ -11898,7 +11898,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rsr", @@ -11908,7 +11908,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "one", @@ -11918,7 +11918,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "jst", @@ -11928,7 +11928,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "icx", @@ -11938,7 +11938,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xym", @@ -11948,7 +11948,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "iost", @@ -11958,7 +11958,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ens", @@ -11968,7 +11968,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lpt", @@ -11978,7 +11978,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "glm", @@ -11988,7 +11988,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "audio", @@ -11998,7 +11998,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "storj", @@ -12008,7 +12008,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ont", @@ -12018,7 +12018,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ontbsc", @@ -12028,7 +12028,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "waxp", @@ -12038,7 +12038,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "srm", @@ -12048,7 +12048,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sc", @@ -12058,7 +12058,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "imx", @@ -12068,7 +12068,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zen", @@ -12078,7 +12078,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "uma", @@ -12088,7 +12088,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "scrt", @@ -12098,7 +12098,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "skl", @@ -12108,7 +12108,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "poly", @@ -12118,7 +12118,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "slp", @@ -12128,7 +12128,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "woobsc", @@ -12138,7 +12138,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "woo", @@ -12148,7 +12148,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "chsb", @@ -12158,7 +12158,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dgb", @@ -12168,7 +12168,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "elon", @@ -12178,7 +12178,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dao", @@ -12188,7 +12188,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pla", @@ -12198,7 +12198,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cvc", @@ -12208,7 +12208,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "spell", @@ -12218,7 +12218,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rndr", @@ -12228,7 +12228,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sushi", @@ -12238,7 +12238,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "btcst", @@ -12248,7 +12248,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lsk", @@ -12258,7 +12258,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eps", @@ -12268,7 +12268,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pundix", @@ -12278,7 +12278,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "celr", @@ -12288,7 +12288,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ren", @@ -12298,7 +12298,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nano", @@ -12308,7 +12308,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xyo", @@ -12318,7 +12318,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "win", @@ -12328,7 +12328,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ong", @@ -12338,7 +12338,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "people", @@ -12348,7 +12348,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "uos", @@ -12358,7 +12358,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cfx", @@ -12368,7 +12368,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "req", @@ -12378,7 +12378,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dydx", @@ -12388,7 +12388,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ardr", @@ -12398,7 +12398,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rly", @@ -12408,7 +12408,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "coti", @@ -12418,7 +12418,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rlc", @@ -12428,7 +12428,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "powr", @@ -12438,7 +12438,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nmr", @@ -12448,7 +12448,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "snt", @@ -12458,7 +12458,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ocean", @@ -12468,7 +12468,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "chr", @@ -12478,7 +12478,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "api3", @@ -12488,7 +12488,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dent", @@ -12498,7 +12498,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bnt", @@ -12508,7 +12508,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fxs", @@ -12518,7 +12518,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hex", @@ -12528,7 +12528,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "steth", @@ -12538,7 +12538,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "btcb", @@ -12548,7 +12548,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lunc", @@ -12558,7 +12558,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dfi", @@ -12568,7 +12568,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bnx", @@ -12578,7 +12578,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rpl", @@ -12588,7 +12588,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "luna", @@ -12598,7 +12598,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "babydoge", @@ -12608,7 +12608,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "raca", @@ -12618,7 +12618,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sys", @@ -12628,7 +12628,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gal", @@ -12638,7 +12638,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bico", @@ -12648,7 +12648,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "steem", @@ -12658,7 +12658,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "c98", @@ -12668,7 +12668,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "susd", @@ -12678,7 +12678,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ctsi", @@ -12688,7 +12688,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hxro", @@ -12698,7 +12698,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rep", @@ -12708,7 +12708,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fun", @@ -12718,7 +12718,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pyr", @@ -12728,7 +12728,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "strax", @@ -12738,7 +12738,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bsw", @@ -12748,7 +12748,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lyxe", @@ -12758,7 +12758,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mtl", @@ -12768,7 +12768,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "stmx", @@ -12778,7 +12778,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "stpt", @@ -12788,7 +12788,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "elf", @@ -12798,7 +12798,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "oxt", @@ -12808,7 +12808,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ufo", @@ -12818,7 +12818,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ach", @@ -12828,7 +12828,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ogn", @@ -12838,7 +12838,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sfund", @@ -12848,7 +12848,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tlm", @@ -12858,7 +12858,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "loom", @@ -12868,7 +12868,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ant", @@ -12878,7 +12878,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "alice", @@ -12888,7 +12888,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fet", @@ -12898,7 +12898,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ygg", @@ -12908,7 +12908,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ark", @@ -12918,7 +12918,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "utk", @@ -12928,7 +12928,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "super", @@ -12938,7 +12938,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dusk", @@ -12948,7 +12948,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ilv", @@ -12958,7 +12958,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mbox", @@ -12968,7 +12968,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sun", @@ -12978,7 +12978,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aergo", @@ -12988,7 +12988,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vra", @@ -12998,7 +12998,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bake", @@ -13008,7 +13008,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xvg", @@ -13018,7 +13018,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dpi", @@ -13028,7 +13028,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pols", @@ -13038,7 +13038,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mln", @@ -13048,7 +13048,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xcad", @@ -13058,7 +13058,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "divi", @@ -13068,7 +13068,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sfp", @@ -13078,7 +13078,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tomo", @@ -13088,7 +13088,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "arpa", @@ -13098,7 +13098,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "band", @@ -13108,7 +13108,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bandmainnet", @@ -13119,7 +13119,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sps", @@ -13129,7 +13129,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ava", @@ -13139,7 +13139,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avaerc20", @@ -13149,7 +13149,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avabsc", @@ -13159,7 +13159,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "jasmy", @@ -13169,7 +13169,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cult", @@ -13179,7 +13179,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "starl", @@ -13189,7 +13189,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "alpaca", @@ -13199,7 +13199,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "blz", @@ -13209,7 +13209,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kmd", @@ -13219,7 +13219,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "alcx", @@ -13229,7 +13229,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "yfii", @@ -13239,7 +13239,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bel", @@ -13249,7 +13249,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mc", @@ -13259,7 +13259,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dia", @@ -13269,7 +13269,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tko", @@ -13279,7 +13279,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bcd", @@ -13289,7 +13289,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "farm", @@ -13299,7 +13299,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ata", @@ -13309,7 +13309,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fio", @@ -13319,7 +13319,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ubt", @@ -13329,7 +13329,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dnt", @@ -13339,7 +13339,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "grs", @@ -13349,7 +13349,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "om", @@ -13359,7 +13359,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gas", @@ -13369,7 +13369,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fox", @@ -13379,7 +13379,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "firo", @@ -13389,7 +13389,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aion", @@ -13399,7 +13399,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "adx", @@ -13409,7 +13409,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nwc", @@ -13420,7 +13420,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cudos", @@ -13430,7 +13430,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "solve", @@ -13440,7 +13440,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "klv", @@ -13450,7 +13450,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rook", @@ -13460,7 +13460,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "front", @@ -13470,7 +13470,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "wtc", @@ -13480,7 +13480,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "beam", @@ -13490,7 +13490,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gto", @@ -13500,7 +13500,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "akro", @@ -13510,7 +13510,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mdt", @@ -13520,7 +13520,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hez", @@ -13530,7 +13530,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pnk", @@ -13540,7 +13540,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ast", @@ -13550,7 +13550,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "snm", @@ -13560,7 +13560,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pivx", @@ -13570,7 +13570,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xdb", @@ -13580,7 +13580,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mir", @@ -13590,7 +13590,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "perl", @@ -13600,7 +13600,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "go", @@ -13610,7 +13610,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "urus", @@ -13620,7 +13620,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "arv", @@ -13630,7 +13630,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cell", @@ -13640,7 +13640,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "caps", @@ -13650,7 +13650,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "wabi", @@ -13660,7 +13660,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "shr", @@ -13670,7 +13670,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "san", @@ -13680,7 +13680,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fuse", @@ -13690,7 +13690,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "poolz", @@ -13700,7 +13700,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vib", @@ -13710,7 +13710,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "now", @@ -13720,7 +13720,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "muse", @@ -13730,7 +13730,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mint", @@ -13740,7 +13740,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xor", @@ -13750,7 +13750,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mtv", @@ -13760,7 +13760,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "spi", @@ -13770,7 +13770,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "belt", @@ -13780,7 +13780,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ppt", @@ -13790,7 +13790,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "awc", @@ -13800,7 +13800,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "defit", @@ -13810,7 +13810,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "srk", @@ -13820,7 +13820,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lgcy", @@ -13830,7 +13830,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nftb", @@ -13840,7 +13840,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hotcross", @@ -13850,7 +13850,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bin", @@ -13860,7 +13860,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tking", @@ -13870,7 +13870,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mph", @@ -13880,7 +13880,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "skill", @@ -13890,7 +13890,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xio", @@ -13900,7 +13900,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zoon", @@ -13910,7 +13910,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "naft", @@ -13920,7 +13920,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "marsh", @@ -13930,7 +13930,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "spo", @@ -13940,7 +13940,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eved", @@ -13950,7 +13950,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lead", @@ -13960,7 +13960,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cns", @@ -13970,7 +13970,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sfuel", @@ -13980,7 +13980,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "leash", @@ -13990,7 +13990,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "flokibsc", @@ -14000,7 +14000,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "floki", @@ -14010,7 +14010,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "volt", @@ -14020,7 +14020,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "brise", @@ -14030,7 +14030,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kishu", @@ -14040,7 +14040,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "shinja", @@ -14050,7 +14050,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ntvrk", @@ -14060,7 +14060,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "akita", @@ -14070,7 +14070,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zinu", @@ -14080,7 +14080,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gafa", @@ -14090,7 +14090,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "trvl", @@ -14100,7 +14100,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kibabsc", @@ -14110,7 +14110,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kiba", @@ -14120,7 +14120,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "guard", @@ -14130,7 +14130,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "blocks", @@ -14140,7 +14140,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "copi", @@ -14150,7 +14150,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dogecoin", @@ -14160,7 +14160,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lblock", @@ -14170,7 +14170,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gspi", @@ -14180,7 +14180,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "asia", @@ -14190,7 +14190,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "wise", @@ -14200,7 +14200,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gmr", @@ -14210,7 +14210,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "knc", @@ -14220,7 +14220,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fjb", @@ -14230,7 +14230,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tenfi", @@ -14240,7 +14240,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "btfa", @@ -14250,7 +14250,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aquagoat", @@ -14260,7 +14260,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avn", @@ -14270,7 +14270,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tenshi", @@ -14280,7 +14280,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "poodl", @@ -14290,7 +14290,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "geth", @@ -14300,7 +14300,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fluf", @@ -14310,7 +14310,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lof", @@ -14320,7 +14320,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nyxt", @@ -14330,7 +14330,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fetbsc", @@ -14340,7 +14340,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mononoke", @@ -14351,7 +14351,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "luffy", @@ -14361,7 +14361,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vgx", @@ -14371,7 +14371,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdtsol", @@ -14382,7 +14382,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nearbsc", @@ -14392,7 +14392,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "iotxbsc", @@ -14402,7 +14402,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "metiserc20", @@ -14412,7 +14412,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdcsol", @@ -14422,7 +14422,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "clear", @@ -14432,7 +14432,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdcbsc", @@ -14442,7 +14442,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bttcbsc", @@ -14452,7 +14452,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "maticbsc", @@ -14462,7 +14462,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avaxbsc", @@ -14472,7 +14472,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ppm", @@ -14482,7 +14482,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bttc", @@ -14492,7 +14492,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "trxbsc", @@ -14502,7 +14502,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "etcbsc", @@ -14512,7 +14512,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "atombsc", @@ -14522,7 +14522,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bchbsc", @@ -14532,7 +14532,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vetbsc", @@ -14542,7 +14542,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "filbsc", @@ -14552,7 +14552,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "egldbsc", @@ -14562,7 +14562,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "axsbsc", @@ -14572,7 +14572,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tusdbsc", @@ -14582,7 +14582,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eosbsc", @@ -14592,7 +14592,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mkrbsc", @@ -14602,7 +14602,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdpbsc", @@ -14612,7 +14612,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zecbsc", @@ -14622,7 +14622,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ftmbsc", @@ -14632,7 +14632,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "manabsc", @@ -14642,7 +14642,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "batbsc", @@ -14652,7 +14652,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zilbsc", @@ -14662,7 +14662,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "compbsc", @@ -14672,7 +14672,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "snxbsc", @@ -14682,7 +14682,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "solbsc", @@ -14692,7 +14692,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ceekerc20", @@ -14703,7 +14703,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "yfibsc", @@ -14713,7 +14713,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kncbsc", @@ -14723,7 +14723,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "chrbsc", @@ -14733,7 +14733,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sushibsc", @@ -14743,7 +14743,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ankrbsc", @@ -14753,7 +14753,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "celrbsc", @@ -14763,7 +14763,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sandmatic", @@ -14774,7 +14774,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xcnbsc", @@ -14784,7 +14784,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "plamatic", @@ -14794,7 +14794,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "c98erc20", @@ -14804,7 +14804,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "momento", @@ -14814,7 +14814,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fire", @@ -14824,7 +14824,7 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ghc", @@ -14834,8 +14834,8 @@ const List> availableCurrenciesJSONFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true - } + "supportsFixedRate": true, + }, ]; const List> availableCurrenciesJSONActiveFixedRate = [ @@ -14847,7 +14847,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eth", @@ -14857,7 +14857,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ethbsc", @@ -14867,7 +14867,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdt", @@ -14877,7 +14877,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdterc20", @@ -14888,7 +14888,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": true, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdttrc20", @@ -14899,7 +14899,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdtbsc", @@ -14909,7 +14909,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdc", @@ -14919,7 +14919,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": true, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdcmatic", @@ -14930,7 +14930,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bnbmainnet", @@ -14941,7 +14941,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bnbbsc", @@ -14951,7 +14951,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "busd", @@ -14961,7 +14961,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "busdbsc", @@ -14971,7 +14971,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xrp", @@ -14981,7 +14981,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xrpbsc", @@ -14991,7 +14991,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ada", @@ -15001,7 +15001,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "adabsc", @@ -15011,7 +15011,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sol", @@ -15021,7 +15021,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "doge", @@ -15031,7 +15031,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dot", @@ -15041,7 +15041,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dotbsc", @@ -15051,7 +15051,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dai", @@ -15061,7 +15061,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "matic", @@ -15071,7 +15071,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "maticmainnet", @@ -15082,7 +15082,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "shib", @@ -15092,7 +15092,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "shibbsc", @@ -15102,7 +15102,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "trx", @@ -15112,7 +15112,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avax", @@ -15122,7 +15122,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avaxc", @@ -15132,7 +15132,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "wbtc", @@ -15142,7 +15142,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "leo", @@ -15152,7 +15152,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "uni", @@ -15162,7 +15162,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "etc", @@ -15172,7 +15172,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ltc", @@ -15182,7 +15182,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ltcbsc", @@ -15192,7 +15192,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ftt", @@ -15202,7 +15202,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "link", @@ -15212,7 +15212,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "atom", @@ -15222,7 +15222,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cro", @@ -15232,7 +15232,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "near", @@ -15242,7 +15242,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xmr", @@ -15252,7 +15252,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xlm", @@ -15262,7 +15262,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bch", @@ -15272,7 +15272,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "algo", @@ -15282,7 +15282,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "flow", @@ -15292,7 +15292,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vet", @@ -15302,7 +15302,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "icp", @@ -15312,7 +15312,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fil", @@ -15322,7 +15322,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ape", @@ -15332,7 +15332,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eos", @@ -15342,7 +15342,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mana", @@ -15352,7 +15352,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sand", @@ -15362,7 +15362,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hbar", @@ -15372,7 +15372,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xtz", @@ -15382,7 +15382,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xtzbsc", @@ -15392,7 +15392,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "chz", @@ -15402,7 +15402,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "qnt", @@ -15412,7 +15412,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "egld", @@ -15422,7 +15422,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aave", @@ -15432,7 +15432,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "theta", @@ -15442,7 +15442,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "axs", @@ -15452,7 +15452,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tusd", @@ -15462,7 +15462,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bsv", @@ -15472,7 +15472,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "okb", @@ -15482,7 +15482,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "galabsc", @@ -15492,7 +15492,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zec", @@ -15502,7 +15502,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdp", @@ -15512,7 +15512,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bttbsc", @@ -15522,7 +15522,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "iota", @@ -15532,7 +15532,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mkr", @@ -15542,7 +15542,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hnt", @@ -15552,7 +15552,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ht", @@ -15562,7 +15562,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "snx", @@ -15572,7 +15572,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "grt", @@ -15582,7 +15582,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "klay", @@ -15592,7 +15592,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ftm", @@ -15602,7 +15602,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ftmmainnet", @@ -15612,7 +15612,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "neo", @@ -15622,7 +15622,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "paxg", @@ -15632,7 +15632,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ldo", @@ -15642,7 +15642,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cake", @@ -15652,7 +15652,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "crv", @@ -15662,7 +15662,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nexo", @@ -15672,7 +15672,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bat", @@ -15682,7 +15682,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dash", @@ -15692,7 +15692,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "waves", @@ -15702,7 +15702,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zil", @@ -15712,7 +15712,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": true, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lrc", @@ -15722,7 +15722,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "enj", @@ -15732,7 +15732,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ksm", @@ -15742,7 +15742,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dcr", @@ -15752,7 +15752,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "btg", @@ -15762,7 +15762,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gmt", @@ -15772,7 +15772,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gno", @@ -15782,7 +15782,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "twt", @@ -15792,7 +15792,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xem", @@ -15802,7 +15802,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "1inch", @@ -15812,7 +15812,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "1inchbsc", @@ -15822,7 +15822,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "celo", @@ -15832,7 +15832,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hot", @@ -15842,7 +15842,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "galaerc20", @@ -15853,7 +15853,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ankr", @@ -15863,7 +15863,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "comp", @@ -15873,7 +15873,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cvx", @@ -15883,7 +15883,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "qtum", @@ -15893,7 +15893,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "yfi", @@ -15903,7 +15903,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xdc", @@ -15913,7 +15913,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "iotx", @@ -15923,7 +15923,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cel", @@ -15933,7 +15933,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gusd", @@ -15943,7 +15943,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": true, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tfuel", @@ -15953,7 +15953,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rvn", @@ -15963,7 +15963,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "flux", @@ -15973,7 +15973,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bal", @@ -15983,7 +15983,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "amp", @@ -15993,7 +15993,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "omg", @@ -16003,7 +16003,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zrx", @@ -16013,7 +16013,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rsr", @@ -16023,7 +16023,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "one", @@ -16033,7 +16033,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "jst", @@ -16043,7 +16043,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "icx", @@ -16053,7 +16053,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xym", @@ -16063,7 +16063,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "iost", @@ -16073,7 +16073,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ens", @@ -16083,7 +16083,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lpt", @@ -16093,7 +16093,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "glm", @@ -16103,7 +16103,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "audio", @@ -16113,7 +16113,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "storj", @@ -16123,7 +16123,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ont", @@ -16133,7 +16133,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ontbsc", @@ -16143,7 +16143,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "waxp", @@ -16153,7 +16153,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "srm", @@ -16163,7 +16163,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sc", @@ -16173,7 +16173,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "imx", @@ -16183,7 +16183,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zen", @@ -16193,7 +16193,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "uma", @@ -16203,7 +16203,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "scrt", @@ -16213,7 +16213,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "skl", @@ -16223,7 +16223,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "poly", @@ -16233,7 +16233,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "slp", @@ -16243,7 +16243,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "woobsc", @@ -16253,7 +16253,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "woo", @@ -16263,7 +16263,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "chsb", @@ -16273,7 +16273,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dgb", @@ -16283,7 +16283,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "elon", @@ -16293,7 +16293,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dao", @@ -16303,7 +16303,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pla", @@ -16313,7 +16313,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cvc", @@ -16323,7 +16323,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "spell", @@ -16333,7 +16333,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rndr", @@ -16343,7 +16343,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sushi", @@ -16353,7 +16353,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "btcst", @@ -16363,7 +16363,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lsk", @@ -16373,7 +16373,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eps", @@ -16383,7 +16383,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pundix", @@ -16393,7 +16393,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "celr", @@ -16403,7 +16403,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ren", @@ -16413,7 +16413,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nano", @@ -16423,7 +16423,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xyo", @@ -16433,7 +16433,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "win", @@ -16443,7 +16443,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ong", @@ -16453,7 +16453,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "people", @@ -16463,7 +16463,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "uos", @@ -16473,7 +16473,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cfx", @@ -16483,7 +16483,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "req", @@ -16493,7 +16493,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dydx", @@ -16503,7 +16503,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ardr", @@ -16513,7 +16513,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rly", @@ -16523,7 +16523,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "coti", @@ -16533,7 +16533,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rlc", @@ -16543,7 +16543,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "powr", @@ -16553,7 +16553,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nmr", @@ -16563,7 +16563,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "snt", @@ -16573,7 +16573,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ocean", @@ -16583,7 +16583,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "chr", @@ -16593,7 +16593,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "api3", @@ -16603,7 +16603,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dent", @@ -16613,7 +16613,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bnt", @@ -16623,7 +16623,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fxs", @@ -16633,7 +16633,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hex", @@ -16643,7 +16643,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "steth", @@ -16653,7 +16653,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "btcb", @@ -16663,7 +16663,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lunc", @@ -16673,7 +16673,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dfi", @@ -16683,7 +16683,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bnx", @@ -16693,7 +16693,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rpl", @@ -16703,7 +16703,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "luna", @@ -16713,7 +16713,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "babydoge", @@ -16723,7 +16723,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "raca", @@ -16733,7 +16733,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sys", @@ -16743,7 +16743,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gal", @@ -16753,7 +16753,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bico", @@ -16763,7 +16763,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "steem", @@ -16773,7 +16773,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "c98", @@ -16783,7 +16783,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "susd", @@ -16793,7 +16793,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ctsi", @@ -16803,7 +16803,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hxro", @@ -16813,7 +16813,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rep", @@ -16823,7 +16823,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fun", @@ -16833,7 +16833,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pyr", @@ -16843,7 +16843,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "strax", @@ -16853,7 +16853,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bsw", @@ -16863,7 +16863,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lyxe", @@ -16873,7 +16873,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mtl", @@ -16883,7 +16883,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "stmx", @@ -16893,7 +16893,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "stpt", @@ -16903,7 +16903,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "elf", @@ -16913,7 +16913,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "oxt", @@ -16923,7 +16923,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ufo", @@ -16933,7 +16933,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ach", @@ -16943,7 +16943,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ogn", @@ -16953,7 +16953,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sfund", @@ -16963,7 +16963,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tlm", @@ -16973,7 +16973,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "loom", @@ -16983,7 +16983,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ant", @@ -16993,7 +16993,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "alice", @@ -17003,7 +17003,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fet", @@ -17013,7 +17013,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ygg", @@ -17023,7 +17023,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ark", @@ -17033,7 +17033,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "utk", @@ -17043,7 +17043,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "super", @@ -17053,7 +17053,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dusk", @@ -17063,7 +17063,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ilv", @@ -17073,7 +17073,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mbox", @@ -17083,7 +17083,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sun", @@ -17093,7 +17093,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aergo", @@ -17103,7 +17103,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vra", @@ -17113,7 +17113,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bake", @@ -17123,7 +17123,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xvg", @@ -17133,7 +17133,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dpi", @@ -17143,7 +17143,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pols", @@ -17153,7 +17153,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mln", @@ -17163,7 +17163,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xcad", @@ -17173,7 +17173,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "divi", @@ -17183,7 +17183,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sfp", @@ -17193,7 +17193,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tomo", @@ -17203,7 +17203,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "arpa", @@ -17213,7 +17213,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "band", @@ -17223,7 +17223,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bandmainnet", @@ -17234,7 +17234,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sps", @@ -17244,7 +17244,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ava", @@ -17254,7 +17254,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avaerc20", @@ -17264,7 +17264,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avabsc", @@ -17274,7 +17274,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "jasmy", @@ -17284,7 +17284,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cult", @@ -17294,7 +17294,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "starl", @@ -17304,7 +17304,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "alpaca", @@ -17314,7 +17314,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "blz", @@ -17324,7 +17324,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kmd", @@ -17334,7 +17334,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "alcx", @@ -17344,7 +17344,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "yfii", @@ -17354,7 +17354,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bel", @@ -17364,7 +17364,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mc", @@ -17374,7 +17374,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dia", @@ -17384,7 +17384,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tko", @@ -17394,7 +17394,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bcd", @@ -17404,7 +17404,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "farm", @@ -17414,7 +17414,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ata", @@ -17424,7 +17424,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fio", @@ -17434,7 +17434,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ubt", @@ -17444,7 +17444,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dnt", @@ -17454,7 +17454,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "grs", @@ -17464,7 +17464,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "om", @@ -17474,7 +17474,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gas", @@ -17484,7 +17484,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fox", @@ -17494,7 +17494,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "firo", @@ -17504,7 +17504,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aion", @@ -17514,7 +17514,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "adx", @@ -17524,7 +17524,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nwc", @@ -17535,7 +17535,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cudos", @@ -17545,7 +17545,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "solve", @@ -17555,7 +17555,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "klv", @@ -17565,7 +17565,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "rook", @@ -17575,7 +17575,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "front", @@ -17585,7 +17585,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "wtc", @@ -17595,7 +17595,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "beam", @@ -17605,7 +17605,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gto", @@ -17615,7 +17615,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "akro", @@ -17625,7 +17625,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mdt", @@ -17635,7 +17635,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hez", @@ -17645,7 +17645,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pnk", @@ -17655,7 +17655,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ast", @@ -17665,7 +17665,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "snm", @@ -17675,7 +17675,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "pivx", @@ -17685,7 +17685,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xdb", @@ -17695,7 +17695,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mir", @@ -17705,7 +17705,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "perl", @@ -17715,7 +17715,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "go", @@ -17725,7 +17725,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "urus", @@ -17735,7 +17735,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "arv", @@ -17745,7 +17745,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cell", @@ -17755,7 +17755,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "caps", @@ -17765,7 +17765,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "wabi", @@ -17775,7 +17775,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "shr", @@ -17785,7 +17785,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "san", @@ -17795,7 +17795,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fuse", @@ -17805,7 +17805,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "poolz", @@ -17815,7 +17815,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vib", @@ -17825,7 +17825,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "now", @@ -17835,7 +17835,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "muse", @@ -17845,7 +17845,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mint", @@ -17855,7 +17855,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xor", @@ -17865,7 +17865,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mtv", @@ -17875,7 +17875,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "spi", @@ -17885,7 +17885,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "belt", @@ -17895,7 +17895,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ppt", @@ -17905,7 +17905,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "awc", @@ -17915,7 +17915,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "defit", @@ -17925,7 +17925,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "srk", @@ -17935,7 +17935,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lgcy", @@ -17945,7 +17945,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nftb", @@ -17955,7 +17955,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "hotcross", @@ -17965,7 +17965,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bin", @@ -17975,7 +17975,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tking", @@ -17985,7 +17985,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mph", @@ -17995,7 +17995,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "skill", @@ -18005,7 +18005,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xio", @@ -18015,7 +18015,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zoon", @@ -18025,7 +18025,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "naft", @@ -18035,7 +18035,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "marsh", @@ -18045,7 +18045,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "spo", @@ -18055,7 +18055,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eved", @@ -18065,7 +18065,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lead", @@ -18075,7 +18075,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "cns", @@ -18085,7 +18085,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sfuel", @@ -18095,7 +18095,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "leash", @@ -18105,7 +18105,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "flokibsc", @@ -18115,7 +18115,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "floki", @@ -18125,7 +18125,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "volt", @@ -18135,7 +18135,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "brise", @@ -18145,7 +18145,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kishu", @@ -18155,7 +18155,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "shinja", @@ -18165,7 +18165,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ntvrk", @@ -18175,7 +18175,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "akita", @@ -18185,7 +18185,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zinu", @@ -18195,7 +18195,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gafa", @@ -18205,7 +18205,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "trvl", @@ -18215,7 +18215,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kibabsc", @@ -18225,7 +18225,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kiba", @@ -18235,7 +18235,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "guard", @@ -18245,7 +18245,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "blocks", @@ -18255,7 +18255,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "copi", @@ -18265,7 +18265,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "dogecoin", @@ -18275,7 +18275,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lblock", @@ -18285,7 +18285,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gspi", @@ -18295,7 +18295,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "asia", @@ -18305,7 +18305,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "wise", @@ -18315,7 +18315,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "gmr", @@ -18325,7 +18325,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "knc", @@ -18335,7 +18335,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fjb", @@ -18345,7 +18345,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tenfi", @@ -18355,7 +18355,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "btfa", @@ -18365,7 +18365,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "aquagoat", @@ -18375,7 +18375,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avn", @@ -18385,7 +18385,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tenshi", @@ -18395,7 +18395,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "poodl", @@ -18405,7 +18405,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "geth", @@ -18415,7 +18415,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fluf", @@ -18425,7 +18425,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "lof", @@ -18435,7 +18435,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nyxt", @@ -18445,7 +18445,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fetbsc", @@ -18455,7 +18455,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mononoke", @@ -18466,7 +18466,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "luffy", @@ -18476,7 +18476,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vgx", @@ -18486,7 +18486,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdtsol", @@ -18497,7 +18497,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "nearbsc", @@ -18507,7 +18507,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "iotxbsc", @@ -18517,7 +18517,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "metiserc20", @@ -18527,7 +18527,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdcsol", @@ -18537,7 +18537,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "clear", @@ -18547,7 +18547,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdcbsc", @@ -18557,7 +18557,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bttcbsc", @@ -18567,7 +18567,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "maticbsc", @@ -18577,7 +18577,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "avaxbsc", @@ -18587,7 +18587,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ppm", @@ -18597,7 +18597,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bttc", @@ -18607,7 +18607,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "trxbsc", @@ -18617,7 +18617,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "etcbsc", @@ -18627,7 +18627,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "atombsc", @@ -18637,7 +18637,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "bchbsc", @@ -18647,7 +18647,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "vetbsc", @@ -18657,7 +18657,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "filbsc", @@ -18667,7 +18667,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "egldbsc", @@ -18677,7 +18677,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "axsbsc", @@ -18687,7 +18687,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "tusdbsc", @@ -18697,7 +18697,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "eosbsc", @@ -18707,7 +18707,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "mkrbsc", @@ -18717,7 +18717,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "usdpbsc", @@ -18727,7 +18727,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zecbsc", @@ -18737,7 +18737,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ftmbsc", @@ -18747,7 +18747,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "manabsc", @@ -18757,7 +18757,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "batbsc", @@ -18767,7 +18767,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "zilbsc", @@ -18777,7 +18777,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "compbsc", @@ -18787,7 +18787,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "snxbsc", @@ -18797,7 +18797,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "solbsc", @@ -18807,7 +18807,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ceekerc20", @@ -18818,7 +18818,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "yfibsc", @@ -18828,7 +18828,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "kncbsc", @@ -18838,7 +18838,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "chrbsc", @@ -18848,7 +18848,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sushibsc", @@ -18858,7 +18858,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ankrbsc", @@ -18868,7 +18868,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "celrbsc", @@ -18878,7 +18878,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "sandmatic", @@ -18889,7 +18889,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "xcnbsc", @@ -18899,7 +18899,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "plamatic", @@ -18909,7 +18909,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "c98erc20", @@ -18919,7 +18919,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "momento", @@ -18929,7 +18929,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "fire", @@ -18939,7 +18939,7 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true + "supportsFixedRate": true, }, { "ticker": "ghc", @@ -18949,8 +18949,8 @@ const List> availableCurrenciesJSONActiveFixedRate = [ "isFiat": false, "featured": false, "isStable": false, - "supportsFixedRate": true - } + "supportsFixedRate": true, + }, ]; const List> getPairedCurrenciesJSON = [ @@ -18963,7 +18963,7 @@ const List> getPairedCurrenciesJSON = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "eth", @@ -18974,7 +18974,7 @@ const List> getPairedCurrenciesJSON = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ethbsc", @@ -18985,7 +18985,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdt", @@ -18996,7 +18996,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdterc20", @@ -19008,7 +19008,7 @@ const List> getPairedCurrenciesJSON = [ "featured": true, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdttrc20", @@ -19020,7 +19020,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdtbsc", @@ -19031,7 +19031,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdc", @@ -19042,7 +19042,7 @@ const List> getPairedCurrenciesJSON = [ "featured": true, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdcmatic", @@ -19054,7 +19054,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bnbmainnet", @@ -19066,7 +19066,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bnbbsc", @@ -19077,7 +19077,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "busd", @@ -19088,7 +19088,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "busdbsc", @@ -19099,7 +19099,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xrp", @@ -19110,7 +19110,7 @@ const List> getPairedCurrenciesJSON = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xrpbsc", @@ -19121,7 +19121,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ada", @@ -19132,7 +19132,7 @@ const List> getPairedCurrenciesJSON = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "adabsc", @@ -19143,7 +19143,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sol", @@ -19154,7 +19154,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "doge", @@ -19165,7 +19165,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dot", @@ -19176,7 +19176,7 @@ const List> getPairedCurrenciesJSON = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dotbsc", @@ -19187,7 +19187,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dai", @@ -19198,7 +19198,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "matic", @@ -19209,7 +19209,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "maticmainnet", @@ -19221,7 +19221,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "shib", @@ -19232,7 +19232,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "shibbsc", @@ -19243,7 +19243,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "trx", @@ -19254,7 +19254,7 @@ const List> getPairedCurrenciesJSON = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "avax", @@ -19265,7 +19265,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "avaxc", @@ -19276,7 +19276,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "wbtc", @@ -19287,7 +19287,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "leo", @@ -19298,7 +19298,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "uni", @@ -19309,7 +19309,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "etc", @@ -19320,7 +19320,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ltc", @@ -19331,7 +19331,7 @@ const List> getPairedCurrenciesJSON = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ltcbsc", @@ -19342,7 +19342,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ftt", @@ -19353,7 +19353,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "link", @@ -19364,7 +19364,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "atom", @@ -19375,7 +19375,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cro", @@ -19386,7 +19386,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "near", @@ -19397,7 +19397,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xlm", @@ -19408,7 +19408,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bch", @@ -19419,7 +19419,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "algo", @@ -19430,7 +19430,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "flow", @@ -19441,7 +19441,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "vet", @@ -19452,7 +19452,7 @@ const List> getPairedCurrenciesJSON = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "icp", @@ -19463,7 +19463,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fil", @@ -19474,7 +19474,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ape", @@ -19485,7 +19485,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "eos", @@ -19496,7 +19496,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mana", @@ -19507,7 +19507,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sand", @@ -19518,7 +19518,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hbar", @@ -19529,7 +19529,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xtz", @@ -19540,7 +19540,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xtzbsc", @@ -19551,7 +19551,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "chz", @@ -19562,7 +19562,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "qnt", @@ -19573,7 +19573,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "egld", @@ -19584,7 +19584,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "aave", @@ -19595,7 +19595,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "theta", @@ -19606,7 +19606,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "axs", @@ -19617,7 +19617,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tusd", @@ -19628,7 +19628,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bsv", @@ -19639,7 +19639,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "okb", @@ -19650,7 +19650,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "galabsc", @@ -19661,7 +19661,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "zec", @@ -19672,7 +19672,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdp", @@ -19683,7 +19683,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bttbsc", @@ -19694,7 +19694,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "iota", @@ -19705,7 +19705,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mkr", @@ -19716,7 +19716,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hnt", @@ -19727,7 +19727,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "snx", @@ -19738,7 +19738,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ht", @@ -19749,7 +19749,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "grt", @@ -19760,7 +19760,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ftm", @@ -19771,7 +19771,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ftmmainnet", @@ -19782,7 +19782,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "klay", @@ -19793,7 +19793,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "neo", @@ -19804,7 +19804,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rune", @@ -19815,7 +19815,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "paxg", @@ -19826,7 +19826,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ldo", @@ -19837,7 +19837,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cake", @@ -19848,7 +19848,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "crv", @@ -19859,7 +19859,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "nexo", @@ -19870,7 +19870,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bat", @@ -19881,7 +19881,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dash", @@ -19892,7 +19892,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "waves", @@ -19903,7 +19903,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "zil", @@ -19914,7 +19914,7 @@ const List> getPairedCurrenciesJSON = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lrc", @@ -19925,7 +19925,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "enj", @@ -19936,7 +19936,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ksm", @@ -19947,7 +19947,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dcr", @@ -19958,7 +19958,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "btg", @@ -19969,7 +19969,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gmt", @@ -19980,7 +19980,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "twt", @@ -19991,7 +19991,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gno", @@ -20002,7 +20002,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xem", @@ -20013,7 +20013,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "1inch", @@ -20024,7 +20024,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "1inchbsc", @@ -20035,7 +20035,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "celo", @@ -20046,7 +20046,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hot", @@ -20057,7 +20057,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ust", @@ -20069,7 +20069,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "galaerc20", @@ -20081,7 +20081,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ankr", @@ -20092,7 +20092,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "comp", @@ -20103,7 +20103,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gt", @@ -20114,7 +20114,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cvx", @@ -20125,7 +20125,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "qtum", @@ -20136,7 +20136,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "yfi", @@ -20147,7 +20147,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xdc", @@ -20158,7 +20158,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "kda", @@ -20169,7 +20169,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "iotx", @@ -20180,7 +20180,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cel", @@ -20191,7 +20191,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gusd", @@ -20202,7 +20202,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tfuel", @@ -20213,7 +20213,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rvn", @@ -20224,7 +20224,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "flux", @@ -20235,7 +20235,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bal", @@ -20246,7 +20246,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "amp", @@ -20257,7 +20257,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "op", @@ -20268,7 +20268,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "omg", @@ -20279,7 +20279,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "zrx", @@ -20290,7 +20290,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "one", @@ -20301,7 +20301,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rsr", @@ -20312,7 +20312,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "icx", @@ -20323,7 +20323,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ens", @@ -20334,7 +20334,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "jst", @@ -20345,7 +20345,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xym", @@ -20356,7 +20356,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "iost", @@ -20367,7 +20367,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lpt", @@ -20378,7 +20378,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "glm", @@ -20389,7 +20389,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "audio", @@ -20400,7 +20400,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "storj", @@ -20411,7 +20411,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ont", @@ -20422,7 +20422,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ontbsc", @@ -20433,7 +20433,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "waxp", @@ -20444,7 +20444,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "srm", @@ -20455,7 +20455,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sc", @@ -20466,7 +20466,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "imx", @@ -20477,7 +20477,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "zen", @@ -20488,7 +20488,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "uma", @@ -20499,7 +20499,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "scrt", @@ -20510,7 +20510,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mxc", @@ -20521,7 +20521,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "btrst", @@ -20532,7 +20532,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "skl", @@ -20543,7 +20543,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "poly", @@ -20554,7 +20554,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "slp", @@ -20565,7 +20565,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "woobsc", @@ -20576,7 +20576,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "woo", @@ -20587,7 +20587,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "chsb", @@ -20598,7 +20598,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cspr", @@ -20609,7 +20609,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dgb", @@ -20620,7 +20620,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "eur", @@ -20631,7 +20631,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "elon", @@ -20642,7 +20642,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dao", @@ -20653,7 +20653,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "pla", @@ -20664,7 +20664,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cvc", @@ -20675,7 +20675,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ceek", @@ -20686,7 +20686,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "spell", @@ -20697,7 +20697,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sushi", @@ -20708,7 +20708,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rndr", @@ -20719,7 +20719,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lsk", @@ -20730,7 +20730,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "btcst", @@ -20741,7 +20741,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "eps", @@ -20752,7 +20752,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "pundix", @@ -20763,7 +20763,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "celr", @@ -20774,7 +20774,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ren", @@ -20785,7 +20785,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "nano", @@ -20796,7 +20796,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xyo", @@ -20807,7 +20807,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "win", @@ -20818,7 +20818,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ong", @@ -20829,7 +20829,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "people", @@ -20840,7 +20840,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "uos", @@ -20851,7 +20851,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cfx", @@ -20862,7 +20862,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "req", @@ -20873,7 +20873,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tribe", @@ -20884,7 +20884,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dydx", @@ -20895,7 +20895,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ardr", @@ -20906,7 +20906,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rly", @@ -20917,7 +20917,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "powr", @@ -20928,7 +20928,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rlc", @@ -20939,7 +20939,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "coti", @@ -20950,7 +20950,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mx", @@ -20961,7 +20961,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "nmr", @@ -20972,7 +20972,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "snt", @@ -20983,7 +20983,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ocean", @@ -20994,7 +20994,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "api3", @@ -21005,7 +21005,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "chr", @@ -21016,7 +21016,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dent", @@ -21027,7 +21027,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bnt", @@ -21038,7 +21038,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fxs", @@ -21049,7 +21049,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hex", @@ -21060,7 +21060,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "steth", @@ -21071,7 +21071,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "btcb", @@ -21082,7 +21082,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "frax", @@ -21093,7 +21093,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "lunc", @@ -21104,7 +21104,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dfi", @@ -21115,7 +21115,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bnx", @@ -21126,7 +21126,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rpl", @@ -21137,7 +21137,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "luna", @@ -21148,7 +21148,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "husd", @@ -21159,7 +21159,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": true, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "babydoge", @@ -21170,7 +21170,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "metis", @@ -21181,7 +21181,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "raca", @@ -21192,7 +21192,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "prom", @@ -21203,7 +21203,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sys", @@ -21214,7 +21214,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gal", @@ -21225,7 +21225,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bico", @@ -21236,7 +21236,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "c98", @@ -21247,7 +21247,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "steem", @@ -21258,7 +21258,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "susd", @@ -21269,7 +21269,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ctsi", @@ -21280,7 +21280,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hxro", @@ -21291,7 +21291,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rep", @@ -21302,7 +21302,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fun", @@ -21313,7 +21313,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "pyr", @@ -21324,7 +21324,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bsw", @@ -21335,7 +21335,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "strax", @@ -21346,7 +21346,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lyxe", @@ -21357,7 +21357,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mtl", @@ -21368,7 +21368,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "stmx", @@ -21379,7 +21379,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "stpt", @@ -21390,7 +21390,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "elf", @@ -21401,7 +21401,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "oxt", @@ -21412,7 +21412,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ufo", @@ -21423,7 +21423,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ach", @@ -21434,7 +21434,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ogn", @@ -21445,7 +21445,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sfund", @@ -21456,7 +21456,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tlm", @@ -21467,7 +21467,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "loom", @@ -21478,7 +21478,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ant", @@ -21489,7 +21489,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "alice", @@ -21500,7 +21500,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fet", @@ -21511,7 +21511,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ygg", @@ -21522,7 +21522,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ark", @@ -21533,7 +21533,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "utk", @@ -21544,7 +21544,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "super", @@ -21555,7 +21555,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dusk", @@ -21566,7 +21566,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ilv", @@ -21577,7 +21577,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mbox", @@ -21588,7 +21588,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sun", @@ -21599,7 +21599,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "aergo", @@ -21610,7 +21610,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "vra", @@ -21621,7 +21621,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bake", @@ -21632,7 +21632,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xvg", @@ -21643,7 +21643,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dpi", @@ -21654,7 +21654,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "pols", @@ -21665,7 +21665,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mln", @@ -21676,7 +21676,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xcad", @@ -21687,7 +21687,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "divi", @@ -21698,7 +21698,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "divierc20", @@ -21710,7 +21710,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tomo", @@ -21721,7 +21721,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sfp", @@ -21732,7 +21732,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "arpa", @@ -21743,7 +21743,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "band", @@ -21754,7 +21754,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bandmainnet", @@ -21766,7 +21766,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sps", @@ -21777,7 +21777,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ava", @@ -21788,7 +21788,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "avaerc20", @@ -21799,7 +21799,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "avabsc", @@ -21810,7 +21810,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "jasmy", @@ -21821,7 +21821,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cult", @@ -21832,7 +21832,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "kmd", @@ -21843,7 +21843,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "starl", @@ -21854,7 +21854,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "aioz", @@ -21865,7 +21865,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "alpaca", @@ -21876,7 +21876,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "blz", @@ -21887,7 +21887,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "alcx", @@ -21898,7 +21898,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "yfii", @@ -21909,7 +21909,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "unfi", @@ -21920,7 +21920,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bel", @@ -21931,7 +21931,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mc", @@ -21942,7 +21942,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dia", @@ -21953,7 +21953,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tko", @@ -21964,7 +21964,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bcd", @@ -21975,7 +21975,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "anc", @@ -21986,7 +21986,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "farm", @@ -21997,7 +21997,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bifi", @@ -22008,7 +22008,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "ata", @@ -22019,7 +22019,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fio", @@ -22030,7 +22030,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ubt", @@ -22041,7 +22041,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dnt", @@ -22052,7 +22052,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "pit", @@ -22063,7 +22063,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "burger", @@ -22074,7 +22074,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "om", @@ -22085,7 +22085,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "grs", @@ -22096,7 +22096,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gas", @@ -22107,7 +22107,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hoge", @@ -22118,7 +22118,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fox", @@ -22129,7 +22129,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "firo", @@ -22140,7 +22140,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "aion", @@ -22151,7 +22151,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "adx", @@ -22162,7 +22162,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "solve", @@ -22173,7 +22173,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "nwc", @@ -22185,7 +22185,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rook", @@ -22196,7 +22196,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cudos", @@ -22207,7 +22207,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "klv", @@ -22218,7 +22218,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "front", @@ -22229,7 +22229,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "wtc", @@ -22240,7 +22240,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "beam", @@ -22251,7 +22251,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gto", @@ -22262,7 +22262,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "akro", @@ -22273,7 +22273,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mdt", @@ -22284,7 +22284,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hez", @@ -22295,7 +22295,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "pnk", @@ -22306,7 +22306,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ast", @@ -22317,7 +22317,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "snm", @@ -22328,7 +22328,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "qsp", @@ -22339,7 +22339,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "pivx", @@ -22350,7 +22350,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xdb", @@ -22361,7 +22361,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mir", @@ -22372,7 +22372,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "perl", @@ -22383,7 +22383,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "go", @@ -22394,7 +22394,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "urus", @@ -22405,7 +22405,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "arv", @@ -22416,7 +22416,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cell", @@ -22427,7 +22427,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "caps", @@ -22438,7 +22438,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "wabi", @@ -22449,7 +22449,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "swftc", @@ -22460,7 +22460,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "shr", @@ -22471,7 +22471,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "san", @@ -22482,7 +22482,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dobo", @@ -22493,7 +22493,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hc", @@ -22504,7 +22504,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fuse", @@ -22515,7 +22515,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dogedash", @@ -22526,7 +22526,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "poolz", @@ -22537,7 +22537,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "vib", @@ -22548,7 +22548,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "now", @@ -22559,7 +22559,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "muse", @@ -22570,7 +22570,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mint", @@ -22581,7 +22581,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xor", @@ -22592,7 +22592,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mtv", @@ -22603,7 +22603,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "spi", @@ -22614,7 +22614,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "belt", @@ -22625,7 +22625,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ppt", @@ -22636,7 +22636,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "awc", @@ -22647,7 +22647,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "defit", @@ -22658,7 +22658,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "srk", @@ -22669,7 +22669,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "swrv", @@ -22680,7 +22680,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "pay", @@ -22691,7 +22691,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lgcy", @@ -22702,7 +22702,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "nftb", @@ -22713,7 +22713,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "open", @@ -22724,7 +22724,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hotcross", @@ -22735,7 +22735,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bin", @@ -22746,7 +22746,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rcn", @@ -22757,7 +22757,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "srn", @@ -22768,7 +22768,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tking", @@ -22779,7 +22779,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mph", @@ -22790,7 +22790,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "skill", @@ -22801,7 +22801,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mda", @@ -22812,7 +22812,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xio", @@ -22823,7 +22823,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "zoon", @@ -22834,7 +22834,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "naft", @@ -22845,7 +22845,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lxt", @@ -22856,7 +22856,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "marsh", @@ -22867,7 +22867,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rainbow", @@ -22878,7 +22878,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "spo", @@ -22889,7 +22889,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "brd", @@ -22900,7 +22900,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "eved", @@ -22911,7 +22911,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lead", @@ -22922,7 +22922,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cns", @@ -22933,7 +22933,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sfuel", @@ -22944,7 +22944,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bunny", @@ -22955,7 +22955,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "leash", @@ -22966,7 +22966,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "flokibsc", @@ -22977,7 +22977,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "floki", @@ -22988,7 +22988,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "volt", @@ -22999,7 +22999,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "brise", @@ -23010,7 +23010,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "kishu", @@ -23021,7 +23021,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "shinja", @@ -23032,7 +23032,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ntvrk", @@ -23043,7 +23043,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "akita", @@ -23054,7 +23054,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "zinu", @@ -23065,7 +23065,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gafa", @@ -23076,7 +23076,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rbif", @@ -23087,7 +23087,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "trvl", @@ -23098,7 +23098,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "kibabsc", @@ -23109,7 +23109,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "kiba", @@ -23120,7 +23120,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "guard", @@ -23131,7 +23131,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "feg", @@ -23142,7 +23142,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fegbsc", @@ -23153,7 +23153,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "blocks", @@ -23164,7 +23164,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "copi", @@ -23175,7 +23175,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dogecoin", @@ -23186,7 +23186,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "klee", @@ -23197,7 +23197,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "lblock", @@ -23208,7 +23208,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gspi", @@ -23219,7 +23219,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gmr", @@ -23230,7 +23230,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "asia", @@ -23241,7 +23241,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "knc", @@ -23252,7 +23252,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fjb", @@ -23263,7 +23263,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "wise", @@ -23274,7 +23274,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tenfi", @@ -23285,7 +23285,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "btfa", @@ -23296,7 +23296,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "aquagoat", @@ -23307,7 +23307,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "titano", @@ -23318,7 +23318,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sanshu", @@ -23329,7 +23329,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "avn", @@ -23340,7 +23340,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tenshi", @@ -23351,7 +23351,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "poodl", @@ -23362,7 +23362,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "pika", @@ -23373,7 +23373,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "geth", @@ -23384,7 +23384,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "defc", @@ -23395,7 +23395,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "keanu", @@ -23406,7 +23406,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rxcg", @@ -23417,7 +23417,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "dgmoon", @@ -23428,7 +23428,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "koromaru", @@ -23439,7 +23439,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "nsh", @@ -23450,7 +23450,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fluf", @@ -23461,7 +23461,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hmc", @@ -23472,7 +23472,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "nyxt", @@ -23483,7 +23483,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lof", @@ -23494,7 +23494,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usd", @@ -23505,7 +23505,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "gbp", @@ -23516,7 +23516,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "cad", @@ -23527,7 +23527,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "jpy", @@ -23538,7 +23538,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "rub", @@ -23549,7 +23549,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "aud", @@ -23560,7 +23560,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "chf", @@ -23571,7 +23571,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "czk", @@ -23582,7 +23582,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "dkk", @@ -23593,7 +23593,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "nok", @@ -23604,7 +23604,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "nzd", @@ -23615,7 +23615,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "pln", @@ -23626,7 +23626,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "sek", @@ -23637,7 +23637,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "try", @@ -23648,7 +23648,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "zar", @@ -23659,7 +23659,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "huf", @@ -23670,7 +23670,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "ils", @@ -23681,7 +23681,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "brl", @@ -23692,7 +23692,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "fetbsc", @@ -23703,7 +23703,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mononoke", @@ -23715,7 +23715,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "daibsc", @@ -23726,7 +23726,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "miota", @@ -23737,7 +23737,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "luffy", @@ -23748,7 +23748,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "vgx", @@ -23759,7 +23759,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdtsol", @@ -23771,7 +23771,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "nearbsc", @@ -23782,7 +23782,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "iotxbsc", @@ -23793,7 +23793,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "metiserc20", @@ -23804,7 +23804,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "nowbep2", @@ -23815,7 +23815,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "saitamav2", @@ -23826,7 +23826,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "vlxbsc", @@ -23837,7 +23837,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dfibsc", @@ -23848,7 +23848,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdcsol", @@ -23859,7 +23859,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "clear", @@ -23870,7 +23870,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdcbsc", @@ -23881,7 +23881,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bttcbsc", @@ -23892,7 +23892,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "maticbsc", @@ -23903,7 +23903,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "avaxbsc", @@ -23914,7 +23914,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ppm", @@ -23925,7 +23925,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bttc", @@ -23936,7 +23936,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "trxbsc", @@ -23947,7 +23947,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "etcbsc", @@ -23958,7 +23958,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "atombsc", @@ -23969,7 +23969,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bchbsc", @@ -23980,7 +23980,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "vetbsc", @@ -23991,7 +23991,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "filbsc", @@ -24002,7 +24002,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "egldbsc", @@ -24013,7 +24013,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "axsbsc", @@ -24024,7 +24024,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tusdbsc", @@ -24035,7 +24035,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "eosbsc", @@ -24046,7 +24046,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mkrbsc", @@ -24057,7 +24057,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdpbsc", @@ -24068,7 +24068,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "daimatic", @@ -24079,7 +24079,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "zecbsc", @@ -24090,7 +24090,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ftmbsc", @@ -24101,7 +24101,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "manabsc", @@ -24112,7 +24112,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "batbsc", @@ -24123,7 +24123,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sxpmainnet", @@ -24135,7 +24135,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "zilbsc", @@ -24146,7 +24146,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "compbsc", @@ -24157,7 +24157,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "snxbsc", @@ -24168,7 +24168,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "solbsc", @@ -24179,7 +24179,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ceekerc20", @@ -24191,7 +24191,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "yfibsc", @@ -24202,7 +24202,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "kncbsc", @@ -24213,7 +24213,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "chrbsc", @@ -24224,7 +24224,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sushibsc", @@ -24235,7 +24235,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdtmatic", @@ -24246,7 +24246,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ankrbsc", @@ -24257,7 +24257,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "celrbsc", @@ -24268,7 +24268,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sandmatic", @@ -24280,7 +24280,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "busdbnb", @@ -24291,7 +24291,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "xcnbsc", @@ -24302,7 +24302,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "plamatic", @@ -24313,7 +24313,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fluxerc20", @@ -24324,7 +24324,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "c98erc20", @@ -24335,7 +24335,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "krw", @@ -24346,7 +24346,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "world", @@ -24357,7 +24357,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": true + "isAvailable": true, }, { "ticker": "all", @@ -24368,7 +24368,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "amd", @@ -24379,7 +24379,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "ang", @@ -24390,7 +24390,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "bam", @@ -24401,7 +24401,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "bbd", @@ -24412,7 +24412,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "bdt", @@ -24423,7 +24423,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "bmd", @@ -24434,7 +24434,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "bnd", @@ -24445,7 +24445,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "bob", @@ -24456,7 +24456,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "bwp", @@ -24467,7 +24467,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "byn", @@ -24478,7 +24478,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "cny", @@ -24489,7 +24489,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "djf", @@ -24500,7 +24500,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "egp", @@ -24511,7 +24511,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "ghs", @@ -24522,7 +24522,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "gtq", @@ -24533,7 +24533,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "hnl", @@ -24544,7 +24544,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "hrk", @@ -24555,7 +24555,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "isk", @@ -24566,7 +24566,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "jmd", @@ -24577,7 +24577,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "kes", @@ -24588,7 +24588,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "kgs", @@ -24599,7 +24599,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "khr", @@ -24610,7 +24610,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "kyd", @@ -24621,7 +24621,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "lbp", @@ -24632,7 +24632,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "lkr", @@ -24643,7 +24643,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "mkd", @@ -24654,7 +24654,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "mnt", @@ -24665,7 +24665,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "mop", @@ -24676,7 +24676,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "mur", @@ -24687,7 +24687,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "mzn", @@ -24698,7 +24698,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "pab", @@ -24709,7 +24709,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "pgk", @@ -24720,7 +24720,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "pkr", @@ -24731,7 +24731,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "pyg", @@ -24742,7 +24742,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "rsd", @@ -24753,7 +24753,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "sos", @@ -24764,7 +24764,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "thb", @@ -24775,7 +24775,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "ttd", @@ -24786,7 +24786,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "tzs", @@ -24797,7 +24797,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "ugx", @@ -24808,7 +24808,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "xaf", @@ -24819,7 +24819,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "xof", @@ -24830,7 +24830,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "zmw", @@ -24841,7 +24841,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": false, - "isAvailable": false + "isAvailable": false, }, { "ticker": "momento", @@ -24852,7 +24852,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fire", @@ -24863,7 +24863,7 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ghc", @@ -24874,8 +24874,8 @@ const List> getPairedCurrenciesJSON = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true - } + "isAvailable": true, + }, ]; const List> getPairedCurrenciesJSONFixedRate = [ @@ -24888,7 +24888,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "eth", @@ -24899,7 +24899,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ethbsc", @@ -24910,7 +24910,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdt", @@ -24921,7 +24921,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdterc20", @@ -24933,7 +24933,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": true, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdttrc20", @@ -24945,7 +24945,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdtbsc", @@ -24956,7 +24956,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdc", @@ -24967,7 +24967,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": true, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdcmatic", @@ -24979,7 +24979,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bnbmainnet", @@ -24991,7 +24991,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bnbbsc", @@ -25002,7 +25002,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "busd", @@ -25013,7 +25013,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "busdbsc", @@ -25024,7 +25024,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xrp", @@ -25035,7 +25035,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xrpbsc", @@ -25046,7 +25046,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ada", @@ -25057,7 +25057,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "adabsc", @@ -25068,7 +25068,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sol", @@ -25079,7 +25079,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "doge", @@ -25090,7 +25090,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dot", @@ -25101,7 +25101,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dotbsc", @@ -25112,7 +25112,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dai", @@ -25123,7 +25123,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "matic", @@ -25134,7 +25134,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "maticmainnet", @@ -25146,7 +25146,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "shib", @@ -25157,7 +25157,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "shibbsc", @@ -25168,7 +25168,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "trx", @@ -25179,7 +25179,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "avax", @@ -25190,7 +25190,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "avaxc", @@ -25201,7 +25201,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "leo", @@ -25212,7 +25212,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "wbtc", @@ -25223,7 +25223,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "uni", @@ -25234,7 +25234,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "etc", @@ -25245,7 +25245,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ltc", @@ -25256,7 +25256,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ltcbsc", @@ -25267,7 +25267,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ftt", @@ -25278,7 +25278,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "link", @@ -25289,7 +25289,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "atom", @@ -25300,7 +25300,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cro", @@ -25311,7 +25311,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "near", @@ -25322,7 +25322,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xlm", @@ -25333,7 +25333,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bch", @@ -25344,7 +25344,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "algo", @@ -25355,7 +25355,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "flow", @@ -25366,7 +25366,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "vet", @@ -25377,7 +25377,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "icp", @@ -25388,7 +25388,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fil", @@ -25399,7 +25399,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ape", @@ -25410,7 +25410,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "eos", @@ -25421,7 +25421,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mana", @@ -25432,7 +25432,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sand", @@ -25443,7 +25443,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hbar", @@ -25454,7 +25454,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xtz", @@ -25465,7 +25465,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xtzbsc", @@ -25476,7 +25476,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "chz", @@ -25487,7 +25487,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "qnt", @@ -25498,7 +25498,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "egld", @@ -25509,7 +25509,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "aave", @@ -25520,7 +25520,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "theta", @@ -25531,7 +25531,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "axs", @@ -25542,7 +25542,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tusd", @@ -25553,7 +25553,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bsv", @@ -25564,7 +25564,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "okb", @@ -25575,7 +25575,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "galabsc", @@ -25586,7 +25586,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "zec", @@ -25597,7 +25597,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdp", @@ -25608,7 +25608,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "snx", @@ -25619,7 +25619,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bttbsc", @@ -25630,7 +25630,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "iota", @@ -25641,7 +25641,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mkr", @@ -25652,7 +25652,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hnt", @@ -25663,7 +25663,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ht", @@ -25674,7 +25674,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "grt", @@ -25685,7 +25685,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "klay", @@ -25696,7 +25696,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ftm", @@ -25707,7 +25707,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ftmmainnet", @@ -25718,7 +25718,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "neo", @@ -25729,7 +25729,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "paxg", @@ -25740,7 +25740,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ldo", @@ -25751,7 +25751,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cake", @@ -25762,7 +25762,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "crv", @@ -25773,7 +25773,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "nexo", @@ -25784,7 +25784,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bat", @@ -25795,7 +25795,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dash", @@ -25806,7 +25806,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "waves", @@ -25817,7 +25817,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "zil", @@ -25828,7 +25828,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": true, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lrc", @@ -25839,7 +25839,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "enj", @@ -25850,7 +25850,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ksm", @@ -25861,7 +25861,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dcr", @@ -25872,7 +25872,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "btg", @@ -25883,7 +25883,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gmt", @@ -25894,7 +25894,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gno", @@ -25905,7 +25905,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xem", @@ -25916,7 +25916,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "twt", @@ -25927,7 +25927,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "1inch", @@ -25938,7 +25938,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "1inchbsc", @@ -25949,7 +25949,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "celo", @@ -25960,7 +25960,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hot", @@ -25971,7 +25971,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "galaerc20", @@ -25983,7 +25983,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ankr", @@ -25994,7 +25994,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "comp", @@ -26005,7 +26005,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cvx", @@ -26016,7 +26016,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "qtum", @@ -26027,7 +26027,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "yfi", @@ -26038,7 +26038,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xdc", @@ -26049,7 +26049,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "iotx", @@ -26060,7 +26060,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cel", @@ -26071,7 +26071,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gusd", @@ -26082,7 +26082,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": true, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tfuel", @@ -26093,7 +26093,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rvn", @@ -26104,7 +26104,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "flux", @@ -26115,7 +26115,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bal", @@ -26126,7 +26126,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "amp", @@ -26137,7 +26137,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "omg", @@ -26148,7 +26148,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "zrx", @@ -26159,7 +26159,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "one", @@ -26170,7 +26170,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rsr", @@ -26181,7 +26181,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "icx", @@ -26192,7 +26192,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ens", @@ -26203,7 +26203,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "jst", @@ -26214,7 +26214,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xym", @@ -26225,7 +26225,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "iost", @@ -26236,7 +26236,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lpt", @@ -26247,7 +26247,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "glm", @@ -26258,7 +26258,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "audio", @@ -26269,7 +26269,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "storj", @@ -26280,7 +26280,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ont", @@ -26291,7 +26291,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ontbsc", @@ -26302,7 +26302,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "waxp", @@ -26313,7 +26313,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sc", @@ -26324,7 +26324,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "srm", @@ -26335,7 +26335,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "zen", @@ -26346,7 +26346,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "imx", @@ -26357,7 +26357,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "uma", @@ -26368,7 +26368,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "scrt", @@ -26379,7 +26379,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "skl", @@ -26390,7 +26390,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "poly", @@ -26401,7 +26401,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "slp", @@ -26412,7 +26412,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "woobsc", @@ -26423,7 +26423,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "woo", @@ -26434,7 +26434,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "chsb", @@ -26445,7 +26445,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "elon", @@ -26456,7 +26456,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dgb", @@ -26467,7 +26467,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dao", @@ -26478,7 +26478,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "pla", @@ -26489,7 +26489,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cvc", @@ -26500,7 +26500,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "spell", @@ -26511,7 +26511,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sushi", @@ -26522,7 +26522,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rndr", @@ -26533,7 +26533,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lsk", @@ -26544,7 +26544,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "btcst", @@ -26555,7 +26555,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "eps", @@ -26566,7 +26566,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "pundix", @@ -26577,7 +26577,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "celr", @@ -26588,7 +26588,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ren", @@ -26599,7 +26599,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "nano", @@ -26610,7 +26610,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xyo", @@ -26621,7 +26621,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "win", @@ -26632,7 +26632,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ong", @@ -26643,7 +26643,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "people", @@ -26654,7 +26654,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "uos", @@ -26665,7 +26665,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cfx", @@ -26676,7 +26676,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "req", @@ -26687,7 +26687,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dydx", @@ -26698,7 +26698,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ardr", @@ -26709,7 +26709,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rly", @@ -26720,7 +26720,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "powr", @@ -26731,7 +26731,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "nmr", @@ -26742,7 +26742,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "coti", @@ -26753,7 +26753,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rlc", @@ -26764,7 +26764,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "snt", @@ -26775,7 +26775,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ocean", @@ -26786,7 +26786,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "chr", @@ -26797,7 +26797,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "api3", @@ -26808,7 +26808,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dent", @@ -26819,7 +26819,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bnt", @@ -26830,7 +26830,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fxs", @@ -26841,7 +26841,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hex", @@ -26852,7 +26852,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "steth", @@ -26863,7 +26863,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "btcb", @@ -26874,7 +26874,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lunc", @@ -26885,7 +26885,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dfi", @@ -26896,7 +26896,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bnx", @@ -26907,7 +26907,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rpl", @@ -26918,7 +26918,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "luna", @@ -26929,7 +26929,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "babydoge", @@ -26940,7 +26940,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "raca", @@ -26951,7 +26951,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "prom", @@ -26962,7 +26962,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sys", @@ -26973,7 +26973,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "c98", @@ -26984,7 +26984,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gal", @@ -26995,7 +26995,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bico", @@ -27006,7 +27006,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "steem", @@ -27017,7 +27017,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "susd", @@ -27028,7 +27028,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ctsi", @@ -27039,7 +27039,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hxro", @@ -27050,7 +27050,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fun", @@ -27061,7 +27061,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rep", @@ -27072,7 +27072,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "strax", @@ -27083,7 +27083,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "pyr", @@ -27094,7 +27094,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bsw", @@ -27105,7 +27105,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lyxe", @@ -27116,7 +27116,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mtl", @@ -27127,7 +27127,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "stmx", @@ -27138,7 +27138,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "stpt", @@ -27149,7 +27149,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ufo", @@ -27160,7 +27160,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "elf", @@ -27171,7 +27171,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "oxt", @@ -27182,7 +27182,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ach", @@ -27193,7 +27193,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ogn", @@ -27204,7 +27204,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sfund", @@ -27215,7 +27215,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tlm", @@ -27226,7 +27226,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "loom", @@ -27237,7 +27237,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ant", @@ -27248,7 +27248,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "alice", @@ -27259,7 +27259,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fet", @@ -27270,7 +27270,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ygg", @@ -27281,7 +27281,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ark", @@ -27292,7 +27292,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "utk", @@ -27303,7 +27303,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "super", @@ -27314,7 +27314,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dusk", @@ -27325,7 +27325,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ilv", @@ -27336,7 +27336,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mbox", @@ -27347,7 +27347,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sun", @@ -27358,7 +27358,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "aergo", @@ -27369,7 +27369,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "vra", @@ -27380,7 +27380,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xvg", @@ -27391,7 +27391,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bake", @@ -27402,7 +27402,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dpi", @@ -27413,7 +27413,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "pols", @@ -27424,7 +27424,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mln", @@ -27435,7 +27435,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xcad", @@ -27446,7 +27446,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "divi", @@ -27457,7 +27457,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tomo", @@ -27468,7 +27468,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "arpa", @@ -27479,7 +27479,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sfp", @@ -27490,7 +27490,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "band", @@ -27501,7 +27501,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bandmainnet", @@ -27513,7 +27513,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sps", @@ -27524,7 +27524,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ava", @@ -27535,7 +27535,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "avaerc20", @@ -27546,7 +27546,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "avabsc", @@ -27557,7 +27557,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "jasmy", @@ -27568,7 +27568,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cult", @@ -27579,7 +27579,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "starl", @@ -27590,7 +27590,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "kmd", @@ -27601,7 +27601,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "alpaca", @@ -27612,7 +27612,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "blz", @@ -27623,7 +27623,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "alcx", @@ -27634,7 +27634,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "yfii", @@ -27645,7 +27645,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bel", @@ -27656,7 +27656,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mc", @@ -27667,7 +27667,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dia", @@ -27678,7 +27678,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tko", @@ -27689,7 +27689,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bcd", @@ -27700,7 +27700,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "farm", @@ -27711,7 +27711,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ata", @@ -27722,7 +27722,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fio", @@ -27733,7 +27733,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ubt", @@ -27744,7 +27744,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dnt", @@ -27755,7 +27755,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "om", @@ -27766,7 +27766,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "grs", @@ -27777,7 +27777,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gas", @@ -27788,7 +27788,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fox", @@ -27799,7 +27799,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "firo", @@ -27810,7 +27810,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "aion", @@ -27821,7 +27821,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "adx", @@ -27832,7 +27832,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cudos", @@ -27843,7 +27843,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "nwc", @@ -27855,7 +27855,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "rook", @@ -27866,7 +27866,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "solve", @@ -27877,7 +27877,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "klv", @@ -27888,7 +27888,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "front", @@ -27899,7 +27899,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "wtc", @@ -27910,7 +27910,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "beam", @@ -27921,7 +27921,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gto", @@ -27932,7 +27932,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "akro", @@ -27943,7 +27943,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hez", @@ -27954,7 +27954,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mdt", @@ -27965,7 +27965,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "pnk", @@ -27976,7 +27976,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ast", @@ -27987,7 +27987,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "snm", @@ -27998,7 +27998,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xdb", @@ -28009,7 +28009,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "pivx", @@ -28020,7 +28020,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mir", @@ -28031,7 +28031,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "perl", @@ -28042,7 +28042,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "go", @@ -28053,7 +28053,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "urus", @@ -28064,7 +28064,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "arv", @@ -28075,7 +28075,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cell", @@ -28086,7 +28086,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "caps", @@ -28097,7 +28097,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "wabi", @@ -28108,7 +28108,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "shr", @@ -28119,7 +28119,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "san", @@ -28130,7 +28130,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fuse", @@ -28141,7 +28141,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "poolz", @@ -28152,7 +28152,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "vib", @@ -28163,7 +28163,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "now", @@ -28174,7 +28174,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "muse", @@ -28185,7 +28185,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mint", @@ -28196,7 +28196,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xor", @@ -28207,7 +28207,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mtv", @@ -28218,7 +28218,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ppt", @@ -28229,7 +28229,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "spi", @@ -28240,7 +28240,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "belt", @@ -28251,7 +28251,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "awc", @@ -28262,7 +28262,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "defit", @@ -28273,7 +28273,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "srk", @@ -28284,7 +28284,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lgcy", @@ -28295,7 +28295,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "nftb", @@ -28306,7 +28306,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "hotcross", @@ -28317,7 +28317,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bin", @@ -28328,7 +28328,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tking", @@ -28339,7 +28339,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mph", @@ -28350,7 +28350,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "skill", @@ -28361,7 +28361,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xio", @@ -28372,7 +28372,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "zoon", @@ -28383,7 +28383,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "naft", @@ -28394,7 +28394,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "marsh", @@ -28405,7 +28405,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "spo", @@ -28416,7 +28416,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "eved", @@ -28427,7 +28427,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lead", @@ -28438,7 +28438,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "cns", @@ -28449,7 +28449,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sfuel", @@ -28460,7 +28460,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "leash", @@ -28471,7 +28471,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "flokibsc", @@ -28482,7 +28482,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "floki", @@ -28493,7 +28493,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "volt", @@ -28504,7 +28504,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "brise", @@ -28515,7 +28515,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "kishu", @@ -28526,7 +28526,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "shinja", @@ -28537,7 +28537,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ntvrk", @@ -28548,7 +28548,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "akita", @@ -28559,7 +28559,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "zinu", @@ -28570,7 +28570,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gafa", @@ -28581,7 +28581,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "trvl", @@ -28592,7 +28592,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "kibabsc", @@ -28603,7 +28603,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "kiba", @@ -28614,7 +28614,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "guard", @@ -28625,7 +28625,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "blocks", @@ -28636,7 +28636,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "copi", @@ -28647,7 +28647,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "dogecoin", @@ -28658,7 +28658,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lblock", @@ -28669,7 +28669,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "asia", @@ -28680,7 +28680,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gspi", @@ -28691,7 +28691,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "gmr", @@ -28702,7 +28702,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "knc", @@ -28713,7 +28713,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "btfa", @@ -28724,7 +28724,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fjb", @@ -28735,7 +28735,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "wise", @@ -28746,7 +28746,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tenfi", @@ -28757,7 +28757,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "aquagoat", @@ -28768,7 +28768,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "avn", @@ -28779,7 +28779,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "geth", @@ -28790,7 +28790,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tenshi", @@ -28801,7 +28801,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "poodl", @@ -28812,7 +28812,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fluf", @@ -28823,7 +28823,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "nyxt", @@ -28834,7 +28834,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "lof", @@ -28845,7 +28845,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fetbsc", @@ -28856,7 +28856,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mononoke", @@ -28868,7 +28868,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "luffy", @@ -28879,7 +28879,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "vgx", @@ -28890,7 +28890,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdtsol", @@ -28902,7 +28902,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "nearbsc", @@ -28913,7 +28913,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "iotxbsc", @@ -28924,7 +28924,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "metiserc20", @@ -28935,7 +28935,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdcsol", @@ -28946,7 +28946,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "clear", @@ -28957,7 +28957,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdcbsc", @@ -28968,7 +28968,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bttcbsc", @@ -28979,7 +28979,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "maticbsc", @@ -28990,7 +28990,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "avaxbsc", @@ -29001,7 +29001,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ppm", @@ -29012,7 +29012,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bttc", @@ -29023,7 +29023,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "trxbsc", @@ -29034,7 +29034,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "etcbsc", @@ -29045,7 +29045,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "atombsc", @@ -29056,7 +29056,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "bchbsc", @@ -29067,7 +29067,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "vetbsc", @@ -29078,7 +29078,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "filbsc", @@ -29089,7 +29089,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "egldbsc", @@ -29100,7 +29100,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "axsbsc", @@ -29111,7 +29111,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "tusdbsc", @@ -29122,7 +29122,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "eosbsc", @@ -29133,7 +29133,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "mkrbsc", @@ -29144,7 +29144,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "usdpbsc", @@ -29155,7 +29155,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "zecbsc", @@ -29166,7 +29166,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ftmbsc", @@ -29177,7 +29177,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "manabsc", @@ -29188,7 +29188,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "batbsc", @@ -29199,7 +29199,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "zilbsc", @@ -29210,7 +29210,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "compbsc", @@ -29221,7 +29221,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "snxbsc", @@ -29232,7 +29232,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "solbsc", @@ -29243,7 +29243,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ceekerc20", @@ -29255,7 +29255,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "yfibsc", @@ -29266,7 +29266,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "kncbsc", @@ -29277,7 +29277,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "chrbsc", @@ -29288,7 +29288,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sushibsc", @@ -29299,7 +29299,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ankrbsc", @@ -29310,7 +29310,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "celrbsc", @@ -29321,7 +29321,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "sandmatic", @@ -29333,7 +29333,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "xcnbsc", @@ -29344,7 +29344,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "plamatic", @@ -29355,7 +29355,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "c98erc20", @@ -29366,7 +29366,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "momento", @@ -29377,7 +29377,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "fire", @@ -29388,7 +29388,7 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true + "isAvailable": true, }, { "ticker": "ghc", @@ -29399,8 +29399,8 @@ const List> getPairedCurrenciesJSONFixedRate = [ "featured": false, "isStable": false, "supportsFixedRate": true, - "isAvailable": true - } + "isAvailable": true, + }, ]; const Map estFixedRateExchangeAmountJSON = { @@ -29409,7 +29409,7 @@ const Map estFixedRateExchangeAmountJSON = { "transactionSpeedForecast": "10-60", "warningMessage": null, "rateId": "1t2W5KBPqhycSJVYpaNZzYWLfMr0kSFe", - "validUntil": "2022-08-29T18:42:12.940Z" + "validUntil": "2022-08-29T18:42:12.940Z", }; const List> fixedRateMarketsJSON = [ @@ -29419,7 +29419,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.978, "minerFee": 0.00032, "min": 0.0880393, - "max": 83.33363733 + "max": 83.33363733, }, { "from": "btg", @@ -29427,7 +29427,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.14941302599839185, "minerFee": 0.0010244438488340927, "min": 0.09442316, - "max": 83.339702 + "max": 83.339702, }, { "from": "btg", @@ -29435,7 +29435,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.00111492, "minerFee": 0.0000339324, "min": 0.09972141, - "max": 83.34848533 + "max": 83.34848533, }, { "from": "btg", @@ -29443,7 +29443,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.35757536882617064, "minerFee": 0.0001584990378447723, "min": 0.08815272, - "max": 83.33374508 + "max": 83.33374508, }, { "from": "btg", @@ -29451,7 +29451,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.6819082568807339, "minerFee": 0.0009035596330275229, "min": 0.08901381, - "max": 83.33456311 + "max": 83.33456311, }, { "from": "btg", @@ -29459,7 +29459,7 @@ const List> fixedRateMarketsJSON = [ "rate": 7587.544889696968, "minerFee": 1.5413161373737372, "min": 0.08791797, - "max": 83.33352206 + "max": 83.33352206, }, { "from": "btg", @@ -29467,7 +29467,7 @@ const List> fixedRateMarketsJSON = [ "rate": 50.35772357723577, "minerFee": 0.40823848238482385, "min": 0.09564422, - "max": 83.340862 + "max": 83.340862, }, { "from": "btg", @@ -29475,7 +29475,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.4083956043956044, "minerFee": 0.0008668131868131869, "min": 0.08979579, - "max": 83.335306 + "max": 83.335306, }, { "from": "btg", @@ -29483,7 +29483,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.1899352640545145, "minerFee": 0.0001314752538330494, "min": 0.08839629, - "max": 83.33397646 + "max": 83.33397646, }, { "from": "btg", @@ -29491,7 +29491,7 @@ const List> fixedRateMarketsJSON = [ "rate": 7.1099066615678765, "minerFee": 0.011163174913957935, "min": 0.08925485, - "max": 83.3347921 + "max": 83.3347921, }, { "from": "btg", @@ -29499,7 +29499,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.4807761966364812, "minerFee": 0.00017865459249676583, "min": 0.08808272, - "max": 83.33367858 + "max": 83.33367858, }, { "from": "btg", @@ -29507,7 +29507,7 @@ const List> fixedRateMarketsJSON = [ "rate": 215.27520369124952, "minerFee": 0.05521884722965227, "min": 0.08797014, - "max": 83.33357162 + "max": 83.33357162, }, { "from": "btg", @@ -29515,7 +29515,7 @@ const List> fixedRateMarketsJSON = [ "rate": 68.61046153846155, "minerFee": 0.3112246153846154, "min": 0.09215562, - "max": 83.33754783 + "max": 83.33754783, }, { "from": "btg", @@ -29523,7 +29523,7 @@ const List> fixedRateMarketsJSON = [ "rate": 506.7818181818181, "minerFee": 0.33290909090909093, "min": 0.08807229, - "max": 83.33394366 + "max": 83.33394366, }, { "from": "btg", @@ -29531,7 +29531,7 @@ const List> fixedRateMarketsJSON = [ "rate": 12.238419319429198, "minerFee": 1.5140897553896817, "min": 0.19528785, - "max": 83.43552345 + "max": 83.43552345, }, { "from": "btg", @@ -29539,7 +29539,7 @@ const List> fixedRateMarketsJSON = [ "rate": 2144.076923076923, "minerFee": 0.35776923076923073, "min": 0.0878825, - "max": 83.33348836 + "max": 83.33348836, }, { "from": "btg", @@ -29547,7 +29547,7 @@ const List> fixedRateMarketsJSON = [ "rate": 217.71801333333332, "minerFee": 2.035618488888889, "min": 0.0968634, - "max": 83.34202022 + "max": 83.34202022, }, { "from": "btg", @@ -29555,7 +29555,7 @@ const List> fixedRateMarketsJSON = [ "rate": 107.9303000968054, "minerFee": 12.200577818809293, "min": 0.18600747, - "max": 83.42670709 + "max": 83.42670709, }, { "from": "btg", @@ -29563,7 +29563,7 @@ const List> fixedRateMarketsJSON = [ "rate": 153.35900962861072, "minerFee": 18.1227649785282, "min": 0.19046837, - "max": 83.43094494 + "max": 83.43094494, }, { "from": "btg", @@ -29571,7 +29571,7 @@ const List> fixedRateMarketsJSON = [ "rate": 166.4059701492537, "minerFee": 1.0272238805970149, "min": 0.0937565, - "max": 83.33906866 + "max": 83.33906866, }, { "from": "btg", @@ -29579,7 +29579,7 @@ const List> fixedRateMarketsJSON = [ "rate": 41.2170055452865, "minerFee": 4.792601348391867, "min": 0.18882058, - "max": 83.42937954 + "max": 83.42937954, }, { "from": "btg", @@ -29587,7 +29587,7 @@ const List> fixedRateMarketsJSON = [ "rate": 774.2499999999999, "minerFee": 282.42956266666664, "min": 0.40485075, - "max": 83.63460821 + "max": 83.63460821, }, { "from": "btg", @@ -29595,7 +29595,7 @@ const List> fixedRateMarketsJSON = [ "rate": 74.59896160535116, "minerFee": 8.56401184909699, "min": 0.18755538, - "max": 83.4281776 + "max": 83.4281776, }, { "from": "btg", @@ -29603,7 +29603,7 @@ const List> fixedRateMarketsJSON = [ "rate": 21.37588053215926, "minerFee": 0.10349707656967841, "min": 0.09245448, - "max": 83.33783174 + "max": 83.33783174, }, { "from": "btg", @@ -29611,7 +29611,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.7817253376313944, "minerFee": 0.0002278896257883672, "min": 0.08800441, - "max": 83.33360418 + "max": 83.33360418, }, { "from": "btg", @@ -29619,7 +29619,7 @@ const List> fixedRateMarketsJSON = [ "rate": 356.2044728434505, "minerFee": 14.058274760383387, "min": 0.1263179, - "max": 83.370002 + "max": 83.370002, }, { "from": "btg", @@ -29627,7 +29627,7 @@ const List> fixedRateMarketsJSON = [ "rate": 357.3461538461538, "minerFee": 0.15846153846153846, "min": 0.08815299, - "max": 83.33374533 + "max": 83.33374533, }, { "from": "btg", @@ -29635,7 +29635,7 @@ const List> fixedRateMarketsJSON = [ "rate": 81.91917707567963, "minerFee": 0.015901910360029387, "min": 0.08790941, - "max": 83.33351393 + "max": 83.33351393, }, { "from": "btg", @@ -29643,7 +29643,7 @@ const List> fixedRateMarketsJSON = [ "rate": 62.286033519553065, "minerFee": 7.322690224134078, "min": 0.18994093, - "max": 83.43044388 + "max": 83.43044388, }, { "from": "btg", @@ -29651,7 +29651,7 @@ const List> fixedRateMarketsJSON = [ "rate": 511.4311926605504, "minerFee": 181.11263586477062, "min": 0.39559318, - "max": 83.62581351 + "max": 83.62581351, }, { "from": "btg", @@ -29659,7 +29659,7 @@ const List> fixedRateMarketsJSON = [ "rate": 141.1291139240506, "minerFee": 23.490017377594935, "min": 0.23316384, - "max": 83.47150564 + "max": 83.47150564, }, { "from": "btg", @@ -29667,7 +29667,7 @@ const List> fixedRateMarketsJSON = [ "rate": 2.913641091298667, "minerFee": 0.43348143929919, "min": 0.2171297, - "max": 8.4562732 + "max": 8.4562732, }, { "from": "btg", @@ -29675,7 +29675,7 @@ const List> fixedRateMarketsJSON = [ "rate": 49.07218309859154, "minerFee": 4.7917473090140845, "min": 0.1726251, - "max": 83.41399383 + "max": 83.41399383, }, { "from": "btg", @@ -29683,7 +29683,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.14445518155384615, "minerFee": 0.017155832749538462, "min": 0.19090617, - "max": 8.43136085 + "max": 8.43136085, }, { "from": "btg", @@ -29691,7 +29691,7 @@ const List> fixedRateMarketsJSON = [ "rate": 65.47258041103933, "minerFee": 7.542106490598943, "min": 0.18776946, - "max": 83.42838098 + "max": 83.42838098, }, { "from": "btg", @@ -29699,7 +29699,7 @@ const List> fixedRateMarketsJSON = [ "rate": 18.0982949469242, "minerFee": 2.2297705262489855, "min": 0.19484193, - "max": 83.43509983 + "max": 83.43509983, }, { "from": "btg", @@ -29707,7 +29707,7 @@ const List> fixedRateMarketsJSON = [ "rate": 15.314835164835165, "minerFee": 0.0025054945054945057, "min": 0.0878793, - "max": 83.33348533 + "max": 83.33348533, }, { "from": "btg", @@ -29715,7 +29715,7 @@ const List> fixedRateMarketsJSON = [ "rate": 78.34996486296556, "minerFee": 0.5128179901616303, "min": 0.9276065, - "max": 83.3394145 + "max": 83.3394145, }, { "from": "btg", @@ -29723,7 +29723,7 @@ const List> fixedRateMarketsJSON = [ "rate": 28.6464542651593, "minerFee": 3.5423108464850976, "min": 0.19520763, - "max": 83.43544724 + "max": 83.43544724, }, { "from": "btg", @@ -29731,7 +29731,7 @@ const List> fixedRateMarketsJSON = [ "rate": 5591.813479503722, "minerFee": 1.0148161111662533, "min": 0.08789679, - "max": 83.33350194 + "max": 83.33350194, }, { "from": "btg", @@ -29739,7 +29739,7 @@ const List> fixedRateMarketsJSON = [ "rate": 54.12233009708737, "minerFee": 0.10885436893203884, "min": 0.08968632, - "max": 83.335202 + "max": 83.335202, }, { "from": "btg", @@ -29747,7 +29747,7 @@ const List> fixedRateMarketsJSON = [ "rate": 64.0758620689655, "minerFee": 6.101253498620689, "min": 0.17046022, - "max": 83.4119372 + "max": 83.4119372, }, { "from": "btg", @@ -29755,7 +29755,7 @@ const List> fixedRateMarketsJSON = [ "rate": 4.952998667258995, "minerFee": 0.0018103065304309195, "min": 0.08807676, - "max": 83.33367291 + "max": 83.33367291, }, { "from": "btg", @@ -29763,7 +29763,7 @@ const List> fixedRateMarketsJSON = [ "rate": 122.65346534653465, "minerFee": 0.22006600660066009, "min": 0.08947404, - "max": 83.33500033 + "max": 83.33500033, }, { "from": "btg", @@ -29771,7 +29771,7 @@ const List> fixedRateMarketsJSON = [ "rate": 25.127789046653138, "minerFee": 0.004110885733603786, "min": 0.0878793, - "max": 83.33348533 + "max": 83.33348533, }, { "from": "btg", @@ -29779,7 +29779,7 @@ const List> fixedRateMarketsJSON = [ "rate": 22.531585517999996, "minerFee": 3.32270418896, "min": 0.21594896, - "max": 83.4551515 + "max": 83.4551515, }, { "from": "btg", @@ -29787,7 +29787,7 @@ const List> fixedRateMarketsJSON = [ "rate": 913.8688524590164, "minerFee": 3.1495081967213117, "min": 0.09108983, - "max": 83.33653533 + "max": 83.33653533, }, { "from": "btg", @@ -29795,7 +29795,7 @@ const List> fixedRateMarketsJSON = [ "rate": 1.4220918367346935, "minerFee": 0.0003326530612244898, "min": 0.08794808, - "max": 83.33355066 + "max": 83.33355066, }, { "from": "btg", @@ -29803,7 +29803,7 @@ const List> fixedRateMarketsJSON = [ "rate": 66.9421016826923, "minerFee": 0.011451673076923076, "min": 0.08788656, - "max": 83.33349223 + "max": 83.33349223, }, { "from": "btg", @@ -29811,7 +29811,7 @@ const List> fixedRateMarketsJSON = [ "rate": 2831.0311962814067, "minerFee": 512.7883001779396, "min": 0.24360594, - "max": 83.48142563 + "max": 83.48142563, }, { "from": "btg", @@ -29819,7 +29819,7 @@ const List> fixedRateMarketsJSON = [ "rate": 2.4237391304347824, "minerFee": 0.0003965217391304348, "min": 0.0878793, - "max": 83.33348533 + "max": 83.33348533, }, { "from": "btg", @@ -29827,7 +29827,7 @@ const List> fixedRateMarketsJSON = [ "rate": 478.5064377682403, "minerFee": 0.17828326180257512, "min": 0.08808369, - "max": 83.33367949 + "max": 83.33367949, }, { "from": "btg", @@ -29835,7 +29835,7 @@ const List> fixedRateMarketsJSON = [ "rate": 9.138688524590162, "minerFee": 0.022855081967213114, "min": 0.11150935, - "max": 83.33565693 + "max": 83.33565693, }, { "from": "btg", @@ -29843,7 +29843,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.4225770628636363, "minerFee": 0.00026913326181818184, "min": 0.08834211, - "max": 83.333925 + "max": 83.333925, }, { "from": "btg", @@ -29851,7 +29851,7 @@ const List> fixedRateMarketsJSON = [ "rate": 22.533179853599997, "minerFee": 2.281977409792, "min": 0.17577619, - "max": 83.41698737 + "max": 83.41698737, }, { "from": "btg", @@ -29859,7 +29859,7 @@ const List> fixedRateMarketsJSON = [ "rate": 1664.059701492537, "minerFee": 344.29837234597017, "min": 0.2900701, - "max": 83.52556659 + "max": 83.52556659, }, { "from": "btg", @@ -29867,7 +29867,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.9460540857430729, "minerFee": 0.22812232367455917, "min": 0.29734157, - "max": 8.53247448 + "max": 8.53247448, }, { "from": "btg", @@ -29875,7 +29875,7 @@ const List> fixedRateMarketsJSON = [ "rate": 13.894815553339976, "minerFee": 1.6320535804586243, "min": 0.18986001, - "max": 83.430367 + "max": 83.430367, }, { "from": "btg", @@ -29883,7 +29883,7 @@ const List> fixedRateMarketsJSON = [ "rate": 1.3132155477031802, "minerFee": 0.1431611909893993, "min": 0.18250796, - "max": 41.75671589 + "max": 41.75671589, }, { "from": "btg", @@ -29891,7 +29891,7 @@ const List> fixedRateMarketsJSON = [ "rate": 92.44776119402984, "minerFee": 0.015124378109452736, "min": 0.0878793, - "max": 83.33348533 + "max": 83.33348533, }, { "from": "btg", @@ -29899,7 +29899,7 @@ const List> fixedRateMarketsJSON = [ "rate": 14.913322632423755, "minerFee": 0.0074398073836276085, "min": 0.08820715, - "max": 83.33379679 + "max": 83.33379679, }, { "from": "btg", @@ -29907,7 +29907,7 @@ const List> fixedRateMarketsJSON = [ "rate": 23400.83937943925, "minerFee": 2345.177409170685, "min": 0.17503259, - "max": 8.41628095 + "max": 8.41628095, }, { "from": "btg", @@ -29915,7 +29915,7 @@ const List> fixedRateMarketsJSON = [ "rate": 157.2524682651622, "minerFee": 18.869392025176303, "min": 0.19205238, - "max": 83.43244975 + "max": 83.43244975, }, { "from": "btg", @@ -29923,7 +29923,7 @@ const List> fixedRateMarketsJSON = [ "rate": 2658.445121688583, "minerFee": 0.44491944731101574, "min": 0.08788298, - "max": 83.33348882 + "max": 83.33348882, }, { "from": "btg", @@ -29931,7 +29931,7 @@ const List> fixedRateMarketsJSON = [ "rate": 1161.375, "minerFee": 280.20967087, "min": 0.88734432, - "max": 8.53261043 + "max": 8.53261043, }, { "from": "btg", @@ -29939,7 +29939,7 @@ const List> fixedRateMarketsJSON = [ "rate": 10729.367205683944, "minerFee": 1270.1233723482715, "min": 0.1906056, - "max": 83.43107531 + "max": 83.43107531, }, { "from": "btg", @@ -29947,7 +29947,7 @@ const List> fixedRateMarketsJSON = [ "rate": 81.91917707567966, "minerFee": 0.014651910360029392, "min": 0.08789423, - "max": 83.33349951 + "max": 83.33349951, }, { "from": "btg", @@ -29955,7 +29955,7 @@ const List> fixedRateMarketsJSON = [ "rate": 667.6167664670658, "minerFee": 0.11922155688622754, "min": 0.08789395, - "max": 83.33349924 + "max": 83.33349924, }, { "from": "btg", @@ -29963,7 +29963,7 @@ const List> fixedRateMarketsJSON = [ "rate": 13.291845493562231, "minerFee": 1.8319124050500715, "min": 0.20750763, - "max": 83.44713224 + "max": 83.44713224, }, { "from": "btg", @@ -29971,7 +29971,7 @@ const List> fixedRateMarketsJSON = [ "rate": 3.4347504621072082, "minerFee": 0.4073198423659889, "min": 0.19079756, - "max": 83.43125767 + "max": 83.43125767, }, { "from": "btg", @@ -29979,7 +29979,7 @@ const List> fixedRateMarketsJSON = [ "rate": 453.2195121951219, "minerFee": 39.28528055146341, "min": 2.90652983, - "max": 83.40493666 + "max": 83.40493666, }, { "from": "btg", @@ -29987,7 +29987,7 @@ const List> fixedRateMarketsJSON = [ "rate": 774.2499999999999, "minerFee": 0.13666666666666666, "min": 0.08789193, - "max": 83.33349733 + "max": 83.33349733, }, { "from": "btg", @@ -29995,7 +29995,7 @@ const List> fixedRateMarketsJSON = [ "rate": 271.2700729927007, "minerFee": 27.484418842043794, "min": 0.17581551, - "max": 83.41702472 + "max": 83.41702472, }, { "from": "btg", @@ -30003,7 +30003,7 @@ const List> fixedRateMarketsJSON = [ "rate": 62.460504201680656, "minerFee": 0.010218487394957981, "min": 0.0878793, - "max": 83.33348533 + "max": 83.33348533, }, { "from": "btg", @@ -30011,7 +30011,7 @@ const List> fixedRateMarketsJSON = [ "rate": 43.36522753792299, "minerFee": 5.322881865752626, "min": 0.19444359, - "max": 83.4347214 + "max": 83.4347214, }, { "from": "btg", @@ -30019,7 +30019,7 @@ const List> fixedRateMarketsJSON = [ "rate": 118.48246546227416, "minerFee": 13.629062924431457, "min": 0.18784309, - "max": 83.42845093 + "max": 83.42845093, }, { "from": "btg", @@ -30027,7 +30027,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.08000287026406429, "minerFee": 0.00041308840413318025, "min": 0.09277544, - "max": 83.33813666 + "max": 83.33813666, }, { "from": "btg", @@ -30035,7 +30035,7 @@ const List> fixedRateMarketsJSON = [ "rate": 19.067057233715165, "minerFee": 1.784705334966661, "min": 0.1691243, - "max": 18.41066808 + "max": 18.41066808, }, { "from": "btg", @@ -30043,7 +30043,7 @@ const List> fixedRateMarketsJSON = [ "rate": 952.9230769230768, "minerFee": 79.44165195589743, "min": 0.16021002, - "max": 83.40219951 + "max": 83.40219951, }, { "from": "btg", @@ -30051,7 +30051,7 @@ const List> fixedRateMarketsJSON = [ "rate": 100.62454873646209, "minerFee": 0.026462093862815887, "min": 0.0879765, - "max": 83.33357766 + "max": 83.33357766, }, { "from": "btg", @@ -30059,7 +30059,7 @@ const List> fixedRateMarketsJSON = [ "rate": 3454.707273038916, "minerFee": 1092.0153549739327, "min": 0.36241721, - "max": 8.59429634 + "max": 8.59429634, }, { "from": "btg", @@ -30067,7 +30067,7 @@ const List> fixedRateMarketsJSON = [ "rate": 211.1590909090909, "minerFee": 24.591947434545457, "min": 0.1889809, - "max": 83.42953184 + "max": 83.42953184, }, { "from": "btg", @@ -30075,7 +30075,7 @@ const List> fixedRateMarketsJSON = [ "rate": 138.47908622587357, "minerFee": 0.023055065231226766, "min": 0.08788213, - "max": 8.33348801 + "max": 8.33348801, }, { "from": "btg", @@ -30083,7 +30083,7 @@ const List> fixedRateMarketsJSON = [ "rate": 282.56551392615404, "minerFee": 37.998834916940886, "min": 0.20448872, - "max": 83.44426427 + "max": 83.44426427, }, { "from": "btg", @@ -30091,7 +30091,7 @@ const List> fixedRateMarketsJSON = [ "rate": 83.82857142857142, "minerFee": 4.098232985714286, "min": 0.13546036, - "max": 83.37868734 + "max": 83.37868734, }, { "from": "btg", @@ -30099,7 +30099,7 @@ const List> fixedRateMarketsJSON = [ "rate": 22.535008322399996, "minerFee": 0.0037365089279999997, "min": 10.1861435, - "max": 83.33348738 + "max": 83.33348738, }, { "from": "btg", @@ -30107,7 +30107,7 @@ const List> fixedRateMarketsJSON = [ "rate": 2.0135813617482388, "minerFee": 0.0028294202636806936, "min": 0.08909487, - "max": 83.33464012 + "max": 83.33464012, }, { "from": "btg", @@ -30115,7 +30115,7 @@ const List> fixedRateMarketsJSON = [ "rate": 22.535543483999994, "minerFee": 2.2802294764799997, "min": 0.17570083, - "max": 83.41691578 + "max": 83.41691578, }, { "from": "btg", @@ -30123,7 +30123,7 @@ const List> fixedRateMarketsJSON = [ "rate": 184.10954511764703, "minerFee": 23.178051790980394, "min": 0.19717028, - "max": 83.43731176 + "max": 83.43731176, }, { "from": "btg", @@ -30131,7 +30131,7 @@ const List> fixedRateMarketsJSON = [ "rate": 22.534116386399997, "minerFee": 2.616215373008, "min": 0.18866975, - "max": 83.42923625 + "max": 83.42923625, }, { "from": "btg", @@ -30139,7 +30139,7 @@ const List> fixedRateMarketsJSON = [ "rate": 344.1111111111111, "minerFee": 0.0762962962962963, "min": 0.08793615, - "max": 83.33353933 + "max": 83.33353933, }, { "from": "btg", @@ -30147,7 +30147,7 @@ const List> fixedRateMarketsJSON = [ "rate": 4.621101729931549, "minerFee": 0.4417639784629745, "min": 0.17093561, - "max": 83.41238882 + "max": 83.41238882, }, { "from": "btg", @@ -30155,7 +30155,7 @@ const List> fixedRateMarketsJSON = [ "rate": 300.7639445822102, "minerFee": 32.60671772530997, "min": 0.18198388, - "max": 83.42288468 + "max": 83.42288468, }, { "from": "btg", @@ -30163,7 +30163,7 @@ const List> fixedRateMarketsJSON = [ "rate": 1041.981308411215, "minerFee": 77.64060070971962, "min": 0.15251296, - "max": 83.3948873 + "max": 83.3948873, }, { "from": "btg", @@ -30171,7 +30171,7 @@ const List> fixedRateMarketsJSON = [ "rate": 27.866033491627093, "minerFee": 3.484827340284929, "min": 0.19637156, - "max": 83.43655298 + "max": 83.43655298, }, { "from": "btg", @@ -30179,7 +30179,7 @@ const List> fixedRateMarketsJSON = [ "rate": 23.179209979209976, "minerFee": 2.7973248197920997, "min": 0.19260661, - "max": 83.43297627 + "max": 83.43297627, }, { "from": "btg", @@ -30187,7 +30187,7 @@ const List> fixedRateMarketsJSON = [ "rate": 4.283407778445163, "minerFee": 1.0327226920087436, "min": 0.29731381, - "max": 8.53244811 + "max": 8.53244811, }, { "from": "btg", @@ -30195,7 +30195,7 @@ const List> fixedRateMarketsJSON = [ "rate": 17.982528521739127, "minerFee": 4.801996926956522, "min": 0.31988055, - "max": 83.55388651 + "max": 83.55388651, }, { "from": "btg", @@ -30203,7 +30203,7 @@ const List> fixedRateMarketsJSON = [ "rate": 138.6744152238806, "minerFee": 0.12268702089552239, "min": 0.08858457, - "max": 83.33415533 + "max": 83.33415533, }, { "from": "btg", @@ -30211,7 +30211,7 @@ const List> fixedRateMarketsJSON = [ "rate": 22.534172132400002, "minerFee": 3.728100272128, "min": 0.23156118, - "max": 83.46998311 + "max": 83.46998311, }, { "from": "btg", @@ -30219,7 +30219,7 @@ const List> fixedRateMarketsJSON = [ "rate": 1252.7191011235952, "minerFee": 161.49473712022473, "min": 0.1998075, - "max": 83.43981712 + "max": 83.43981712, }, { "from": "btg", @@ -30227,7 +30227,7 @@ const List> fixedRateMarketsJSON = [ "rate": 187.38151260504202, "minerFee": 25.017766722184874, "min": 0.20380376, - "max": 83.44361357 + "max": 83.44361357, }, { "from": "btg", @@ -30235,7 +30235,7 @@ const List> fixedRateMarketsJSON = [ "rate": 208.39626168224297, "minerFee": 19.992346607943926, "min": 0.1711361, - "max": 83.41257929 + "max": 83.41257929, }, { "from": "btg", @@ -30243,7 +30243,7 @@ const List> fixedRateMarketsJSON = [ "rate": 6277.161092590528, "minerFee": 1513.8717569700557, "min": 0.29737728, - "max": 83.53250841 + "max": 83.53250841, }, { "from": "btg", @@ -30251,7 +30251,7 @@ const List> fixedRateMarketsJSON = [ "rate": 193.610890625, "minerFee": 17.569255633333334, "min": 1.60631329, - "max": 83.40829371 + "max": 83.40829371, }, { "from": "btg", @@ -30259,7 +30259,7 @@ const List> fixedRateMarketsJSON = [ "rate": 2933.9999999999995, "minerFee": 368.58142651000003, "min": 0.1969461, - "max": 83.43709878 + "max": 83.43709878, }, { "from": "btg", @@ -30267,7 +30267,7 @@ const List> fixedRateMarketsJSON = [ "rate": 76.46913580246914, "minerFee": 0.013510288065843624, "min": 0.0878921, - "max": 83.33349749 + "max": 83.33349749, }, { "from": "btg", @@ -30275,7 +30275,7 @@ const List> fixedRateMarketsJSON = [ "rate": 229.24728710478126, "minerFee": 5.037504668646999, "min": 0.10920813, - "max": 41.68708105 + "max": 41.68708105, }, { "from": "btg", @@ -30283,7 +30283,7 @@ const List> fixedRateMarketsJSON = [ "rate": 22.535008322399996, "minerFee": 0.453686708928, "min": 0.10740723, - "max": 83.35203685 + "max": 83.35203685, }, { "from": "btg", @@ -30291,7 +30291,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.46281444582814446, "minerFee": 0.06113932606475716, "min": 0.20267429, - "max": 83.44254056 + "max": 83.44254056, }, { "from": "btg", @@ -30299,7 +30299,7 @@ const List> fixedRateMarketsJSON = [ "rate": 884.8571428571428, "minerFee": 93.37321282476191, "min": 0.17947232, - "max": 83.42049869 + "max": 83.42049869, }, { "from": "btg", @@ -30307,7 +30307,7 @@ const List> fixedRateMarketsJSON = [ "rate": 506.7818181818181, "minerFee": 52.515960910909094, "min": 0.17782294, - "max": 83.41893178 + "max": 83.41893178, }, { "from": "btg", @@ -30315,7 +30315,7 @@ const List> fixedRateMarketsJSON = [ "rate": 6.367332952598514, "minerFee": 1.9260347304625927, "min": 0.35174955, - "max": 83.58416206 + "max": 83.58416206, }, { "from": "btg", @@ -30323,7 +30323,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.8354698343649306, "minerFee": 0.0885791221814912, "min": 0.1799284, - "max": 83.42093197 + "max": 83.42093197, }, { "from": "btg", @@ -30331,7 +30331,7 @@ const List> fixedRateMarketsJSON = [ "rate": 22.8335616, "minerFee": 3.9672583620000004, "min": 0.23870754, - "max": 16.81010549 + "max": 16.81010549, }, { "from": "btg", @@ -30339,7 +30339,7 @@ const List> fixedRateMarketsJSON = [ "rate": 3.138851351351351, "minerFee": 0.016413513513513514, "min": 0.09283341, - "max": 83.33819173 + "max": 83.33819173, }, { "from": "btg", @@ -30347,7 +30347,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.4608931852561984, "minerFee": 0.010075401748099174, "min": 2.21766809, - "max": 83.35363967 + "max": 83.35363967, }, { "from": "btg", @@ -30355,7 +30355,7 @@ const List> fixedRateMarketsJSON = [ "rate": 247.21064301552101, "minerFee": 26.831382298980042, "min": 0.18209172, - "max": 83.42298712 + "max": 83.42298712, }, { "from": "btg", @@ -30363,7 +30363,7 @@ const List> fixedRateMarketsJSON = [ "rate": 19.18967297762478, "minerFee": 0.021139414802065402, "min": 0.08879667, - "max": 83.33435683 + "max": 83.33435683, }, { "from": "btg", @@ -30371,7 +30371,7 @@ const List> fixedRateMarketsJSON = [ "rate": 130.5526932084309, "minerFee": 12.39344039381733, "min": 0.17026368, - "max": 83.41175049 + "max": 83.41175049, }, { "from": "btg", @@ -30379,7 +30379,7 @@ const List> fixedRateMarketsJSON = [ "rate": 27.597029702970296, "minerFee": 0.018714851485148516, "min": 0.08838241, - "max": 83.33396328 + "max": 83.33396328, }, { "from": "btg", @@ -30387,7 +30387,7 @@ const List> fixedRateMarketsJSON = [ "rate": 17.982528521739127, "minerFee": 0.005441926956521739, "min": 0.08801527, - "max": 83.3336145 + "max": 83.3336145, }, { "from": "btg", @@ -30395,7 +30395,7 @@ const List> fixedRateMarketsJSON = [ "rate": 3.509348441926345, "minerFee": 0.3667472365344665, "min": 0.17861615, - "max": 83.41968533 + "max": 83.41968533, }, { "from": "btg", @@ -30403,7 +30403,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.0025110810810810807, "minerFee": 0.0002959808108108108, "min": 0.19011354, - "max": 83.43060785 + "max": 83.43060785, }, { "from": "btg", @@ -30411,7 +30411,7 @@ const List> fixedRateMarketsJSON = [ "rate": 496.19390719999996, "minerFee": 5.081176917333333, "min": 0.09773432, - "max": 83.3428476 + "max": 83.3428476, }, { "from": "btg", @@ -30419,7 +30419,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.02820440172021249, "minerFee": 0.0035042742170503416, "min": 0.19577529, - "max": 83.43598652 + "max": 83.43598652, }, { "from": "btg", @@ -30427,7 +30427,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.024726546906187623, "minerFee": 0.002459945242847638, "min": 0.17412814, - "max": 83.41542172 + "max": 83.41542172, }, { "from": "btg", @@ -30435,7 +30435,7 @@ const List> fixedRateMarketsJSON = [ "rate": 20.878651685393255, "minerFee": 2.4370465303370787, "min": 0.18920965, - "max": 83.42974916 + "max": 83.42974916, }, { "from": "btg", @@ -30443,7 +30443,7 @@ const List> fixedRateMarketsJSON = [ "rate": 59.39904102290889, "minerFee": 6.174845094523175, "min": 0.17830277, - "max": 83.41938762 + "max": 83.41938762, }, { "from": "btg", @@ -30451,7 +30451,7 @@ const List> fixedRateMarketsJSON = [ "rate": 28.36946564885496, "minerFee": 4.275159711374045, "min": 0.21907466, - "max": 83.45812092 + "max": 83.45812092, }, { "from": "btg", @@ -30459,7 +30459,7 @@ const List> fixedRateMarketsJSON = [ "rate": 13.750920382230898, "minerFee": 0.022249639326336345, "min": 0.08930041, - "max": 8.33483538 + "max": 8.33483538, }, { "from": "btg", @@ -30467,7 +30467,7 @@ const List> fixedRateMarketsJSON = [ "rate": 796.3714285714286, "minerFee": 50.13028571428571, "min": 0.14928281, - "max": 16.725152 + "max": 16.725152, }, { "from": "btg", @@ -30475,7 +30475,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.0011144742103158737, "minerFee": 0.00014126232706917233, "min": 0.1979233, - "max": 83.43802713 + "max": 83.43802713, }, { "from": "btg", @@ -30483,7 +30483,7 @@ const List> fixedRateMarketsJSON = [ "rate": 3.612832145171743, "minerFee": 0.4919526463836682, "min": 0.20603589, - "max": 83.44573408 + "max": 83.44573408, }, { "from": "btg", @@ -30491,7 +30491,7 @@ const List> fixedRateMarketsJSON = [ "rate": 19.266102637415788, "minerFee": 4.643757508631888, "min": 0.2972747, - "max": 83.53241095 + "max": 83.53241095, }, { "from": "btg", @@ -30499,7 +30499,7 @@ const List> fixedRateMarketsJSON = [ "rate": 3898.7903671972317, "minerFee": 3.637838914878893, "min": 0.08863178, - "max": 8.33420018 + "max": 8.33420018, }, { "from": "btg", @@ -30507,7 +30507,7 @@ const List> fixedRateMarketsJSON = [ "rate": 381.8219178082191, "minerFee": 0.36246575342465753, "min": 0.08864772, - "max": 83.33421533 + "max": 83.33421533, }, { "from": "btg", @@ -30515,7 +30515,7 @@ const List> fixedRateMarketsJSON = [ "rate": 3.818219178082191, "minerFee": 0.0031246575342465752, "min": 0.08851965, - "max": 83.33409366 + "max": 83.33409366, }, { "from": "btg", @@ -30523,7 +30523,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.26596374045801524, "minerFee": 0.11600931145038167, "min": 0.46683669, - "max": 83.69349485 + "max": 83.69349485, }, { "from": "btg", @@ -30531,7 +30531,7 @@ const List> fixedRateMarketsJSON = [ "rate": 1093.0588235294117, "minerFee": 0.2288235294117647, "min": 0.08792404, - "max": 83.33352783 + "max": 83.33352783, }, { "from": "btg", @@ -30539,7 +30539,7 @@ const List> fixedRateMarketsJSON = [ "rate": 404.870747019408, "minerFee": 43.251741243029755, "min": 0.18056873, - "max": 83.42154028 + "max": 83.42154028, }, { "from": "btg", @@ -30547,7 +30547,7 @@ const List> fixedRateMarketsJSON = [ "rate": 1.3869350444572706, "minerFee": 0.16101662143876602, "min": 0.18862802, - "max": 83.42919661 + "max": 83.42919661, }, { "from": "btg", @@ -30555,7 +30555,7 @@ const List> fixedRateMarketsJSON = [ "rate": 95.08442330126582, "minerFee": 0.02180573387341772, "min": 0.08794361, - "max": 83.33354642 + "max": 83.33354642, }, { "from": "btg", @@ -30563,7 +30563,7 @@ const List> fixedRateMarketsJSON = [ "rate": 3737.1489755223874, "minerFee": 413.0672189554229, "min": 0.18397532, - "max": 83.42477654 + "max": 83.42477654, }, { "from": "btg", @@ -30571,7 +30571,7 @@ const List> fixedRateMarketsJSON = [ "rate": 140.24150943396225, "minerFee": 13.650713756226414, "min": 0.17224921, - "max": 83.41363674 + "max": 83.41363674, }, { "from": "btg", @@ -30579,7 +30579,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.4314705882352941, "minerFee": 0.00012058823529411766, "min": 0.0879925, - "max": 83.33359287 + "max": 83.33359287, }, { "from": "btg", @@ -30587,7 +30587,7 @@ const List> fixedRateMarketsJSON = [ "rate": 1.6517333333333333, "minerFee": 0.18282373222222223, "min": 0.18381776, - "max": 83.42462686 + "max": 83.42462686, }, { "from": "btg", @@ -30595,7 +30595,7 @@ const List> fixedRateMarketsJSON = [ "rate": 38.20713660801095, "minerFee": 0.007250656295789113, "min": 0.08790493, - "max": 83.33350968 + "max": 83.33350968, }, { "from": "btg", @@ -30603,7 +30603,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.2544187300242379, "minerFee": 0.026577112696118482, "min": 0.17851233, - "max": 41.75292004 + "max": 41.75292004, }, { "from": "btg", @@ -30611,7 +30611,7 @@ const List> fixedRateMarketsJSON = [ "rate": 16.868962584, "minerFee": 2.03397511848, "min": 0.19251378, - "max": 41.76622141 + "max": 41.76622141, }, { "from": "btg", @@ -30619,7 +30619,7 @@ const List> fixedRateMarketsJSON = [ "rate": 6.083403995128442, "minerFee": 1.4697898999174033, "min": 0.29768826, - "max": 41.86613717 + "max": 41.86613717, }, { "from": "btg", @@ -30627,7 +30627,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.5307810974973711, "minerFee": 0.038952535353373806, "min": 0.15150883, - "max": 41.72726671 + "max": 41.72726671, }, { "from": "btg", @@ -30635,7 +30635,7 @@ const List> fixedRateMarketsJSON = [ "rate": 21.923439825429888, "minerFee": 5.241758686822156, "min": 0.2955048, - "max": 41.86406288 + "max": 41.86406288, }, { "from": "btg", @@ -30643,7 +30643,7 @@ const List> fixedRateMarketsJSON = [ "rate": 7.784757829577479, "minerFee": 0.7385709600130188, "min": 0.17018079, - "max": 41.74500507 + "max": 41.74500507, }, { "from": "btg", @@ -30651,7 +30651,7 @@ const List> fixedRateMarketsJSON = [ "rate": 532.2547783127123, "minerFee": 60.112871576349725, "min": 0.18592609, - "max": 41.75996311 + "max": 41.75996311, }, { "from": "btg", @@ -30659,7 +30659,7 @@ const List> fixedRateMarketsJSON = [ "rate": 41665.08538469991, "minerFee": 4084.9677050170676, "min": 0.17293476, - "max": 41.74762135 + "max": 41.74762135, }, { "from": "btg", @@ -30667,7 +30667,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.6118044872481201, "minerFee": 0.14782887071366022, "min": 0.29770663, - "max": 8.53282129 + "max": 8.53282129, }, { "from": "btg", @@ -30675,7 +30675,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.996192336510844, "minerFee": 0.07963596625137191, "min": 0.15720383, - "max": 41.73267696 + "max": 41.73267696, }, { "from": "btg", @@ -30683,7 +30683,7 @@ const List> fixedRateMarketsJSON = [ "rate": 45.30923006034302, "minerFee": 4.192996272975107, "min": 0.16815405, - "max": 41.74307967 + "max": 41.74307967, }, { "from": "btg", @@ -30691,7 +30691,7 @@ const List> fixedRateMarketsJSON = [ "rate": 7.805272065534172, "minerFee": 0.8042204261252407, "min": 0.1772731, - "max": 41.75174277 + "max": 41.75174277, }, { "from": "btg", @@ -30699,7 +30699,7 @@ const List> fixedRateMarketsJSON = [ "rate": 60316.30294623652, "minerFee": 5047.749709763515, "min": 0.16046023, - "max": 41.73577054 + "max": 41.73577054, }, { "from": "btg", @@ -30707,7 +30707,7 @@ const List> fixedRateMarketsJSON = [ "rate": 70.009326310896, "minerFee": 7.92499520851712, "min": 0.18610492, - "max": 12.59346633 + "max": 12.59346633, }, { "from": "btg", @@ -30715,7 +30715,7 @@ const List> fixedRateMarketsJSON = [ "rate": 131.2824883268124, "minerFee": 33.47929173770173, "min": 0.30934212, - "max": 41.87720834 + "max": 41.87720834, }, { "from": "btg", @@ -30723,7 +30723,7 @@ const List> fixedRateMarketsJSON = [ "rate": 123.11994828693237, "minerFee": 25.595069842828128, "min": 0.26838725, - "max": 41.83830121 + "max": 41.83830121, }, { "from": "btg", @@ -30731,7 +30731,7 @@ const List> fixedRateMarketsJSON = [ "rate": 7.742499999999999, "minerFee": 3.1125799666666665, "min": 0.43721974, - "max": 83.66535875 + "max": 83.66535875, }, { "from": "btg", @@ -30739,7 +30739,7 @@ const List> fixedRateMarketsJSON = [ "rate": 219.46838781925342, "minerFee": 17.069634938722984, "min": 0.15535109, - "max": 83.39758353 + "max": 83.39758353, }, { "from": "btg", @@ -30747,7 +30747,7 @@ const List> fixedRateMarketsJSON = [ "rate": 21.176068376068375, "minerFee": 2.0092964074643875, "min": 0.17025534, - "max": 8.41174256 + "max": 8.41174256, }, { "from": "btg", @@ -30755,7 +30755,7 @@ const List> fixedRateMarketsJSON = [ "rate": 612.5934065934065, "minerFee": 59.01474506021978, "min": 0.1714848, - "max": 12.57957722 + "max": 12.57957722, }, { "from": "btg", @@ -30763,7 +30763,7 @@ const List> fixedRateMarketsJSON = [ "rate": 80.90856313497822, "minerFee": 10.475728664746008, "min": 0.20037674, - "max": 83.44035789 + "max": 83.44035789, }, { "from": "btg", @@ -30771,7 +30771,7 @@ const List> fixedRateMarketsJSON = [ "rate": 45.39679365500407, "minerFee": 10.967275788307568, "min": 0.29768343, - "max": 8.53279925 + "max": 8.53279925, }, { "from": "btg", @@ -30779,7 +30779,7 @@ const List> fixedRateMarketsJSON = [ "rate": 1023.9303787475625, "minerFee": 108.08245980239225, "min": 0.17946355, - "max": 41.7538237 + "max": 41.7538237, }, { "from": "btg", @@ -30787,7 +30787,7 @@ const List> fixedRateMarketsJSON = [ "rate": 845.2681353944778, "minerFee": 204.27068066552465, "min": 0.29773803, - "max": 41.86618445 + "max": 41.86618445, }, { "from": "btg", @@ -30795,7 +30795,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.22476236914651365, "minerFee": 0.02690367093973767, "min": 0.19175252, - "max": 41.76549822 + "max": 41.76549822, }, { "from": "btg", @@ -30803,7 +30803,7 @@ const List> fixedRateMarketsJSON = [ "rate": 91.6875, "minerFee": 22.121816120000002, "min": 0.29748467, - "max": 8.53261043 + "max": 8.53261043, }, { "from": "btg", @@ -30811,7 +30811,7 @@ const List> fixedRateMarketsJSON = [ "rate": 10.699808061420343, "minerFee": 0.0019504798464491364, "min": 0.08789758, - "max": 83.3335027 + "max": 83.3335027, }, { "from": "btg", @@ -30819,7 +30819,7 @@ const List> fixedRateMarketsJSON = [ "rate": 107437.37372137688, "minerFee": 12888.999709777485, "min": 0.19198681, - "max": 41.76572079 + "max": 41.76572079, }, { "from": "btg", @@ -30827,7 +30827,7 @@ const List> fixedRateMarketsJSON = [ "rate": 175.85488958990535, "minerFee": 42.38823344608833, "min": 0.29728203, - "max": 83.53241792 + "max": 83.53241792, }, { "from": "btg", @@ -30835,7 +30835,7 @@ const List> fixedRateMarketsJSON = [ "rate": 12.645703012657657, "minerFee": 1.2157302866687374, "min": 0.17127865, - "max": 41.74604804 + "max": 41.74604804, }, { "from": "btg", @@ -30843,7 +30843,7 @@ const List> fixedRateMarketsJSON = [ "rate": 99.72450805008945, "minerFee": 24.051843297942757, "min": 0.29740539, - "max": 83.53253511 + "max": 83.53253511, }, { "from": "btg", @@ -30851,7 +30851,7 @@ const List> fixedRateMarketsJSON = [ "rate": 972.5725973521215, "minerFee": 231.4808729713664, "min": 0.29456204, - "max": 41.86316726 + "max": 41.86316726, }, { "from": "btg", @@ -30859,7 +30859,7 @@ const List> fixedRateMarketsJSON = [ "rate": 8205.7777524, "minerFee": 1376.043255978, "min": 0.23345847, - "max": 41.80511887 + "max": 41.80511887, }, { "from": "btg", @@ -30867,7 +30867,7 @@ const List> fixedRateMarketsJSON = [ "rate": 23.0498242712425, "minerFee": 2.6009864423961133, "min": 0.18581415, - "max": 83.42652343 + "max": 83.42652343, }, { "from": "btg", @@ -30875,7 +30875,7 @@ const List> fixedRateMarketsJSON = [ "rate": 106.08182683158896, "minerFee": 14.361561920095149, "min": 0.20554102, - "max": 83.44526396 + "max": 83.44526396, }, { "from": "btg", @@ -30883,7 +30883,7 @@ const List> fixedRateMarketsJSON = [ "rate": 63.351092304, "minerFee": 6.15465636688, "min": 0.17216011, - "max": 8.41355209 + "max": 8.41355209, }, { "from": "btg", @@ -30891,7 +30891,7 @@ const List> fixedRateMarketsJSON = [ "rate": 1.1763832478840275, "minerFee": 0.11332311533707713, "min": 0.17144766, - "max": 41.74620861 + "max": 41.74620861, }, { "from": "btg", @@ -30899,7 +30899,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.015022024360411204, "minerFee": 0.003612937590897409, "min": 0.29673517, - "max": 8.53189841 + "max": 8.53189841, }, { "from": "btg", @@ -30907,7 +30907,7 @@ const List> fixedRateMarketsJSON = [ "rate": 5.682568807339449, "minerFee": 0.04301919360856269, "min": 0.09512682, - "max": 83.34037047 + "max": 83.34037047, }, { "from": "btg", @@ -30915,7 +30915,7 @@ const List> fixedRateMarketsJSON = [ "rate": 136.46511627906975, "minerFee": 29.59897541139535, "min": 0.27629352, - "max": 83.51247883 + "max": 83.51247883, }, { "from": "btg", @@ -30923,7 +30923,7 @@ const List> fixedRateMarketsJSON = [ "rate": 464.2112197964683, "minerFee": 112.00700295583582, "min": 0.29740852, - "max": 8.53253808 + "max": 8.53253808, }, { "from": "btg", @@ -30931,7 +30931,7 @@ const List> fixedRateMarketsJSON = [ "rate": 3471.9626303199416, "minerFee": 0.5680102462691111, "min": 0.0878793, - "max": 83.33348533 + "max": 83.33348533, }, { "from": "btg", @@ -30939,7 +30939,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.017095917447858364, "minerFee": 0.002113616878110079, "min": 0.19517191, - "max": 8.4354133 + "max": 8.4354133, }, { "from": "btg", @@ -30947,7 +30947,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.7097640416503936, "minerFee": 0.0011161168166299214, "min": 0.08925667, - "max": 83.33479383 + "max": 83.33479383, }, { "from": "btg", @@ -30955,7 +30955,7 @@ const List> fixedRateMarketsJSON = [ "rate": 158.1446808510638, "minerFee": 12.124159390425532, "min": 0.15428995, - "max": 83.39657544 + "max": 83.39657544, }, { "from": "btg", @@ -30963,7 +30963,7 @@ const List> fixedRateMarketsJSON = [ "rate": 227.55283266759034, "minerFee": 25.64287931728713, "min": 0.18566249, - "max": 53.42637936 + "max": 53.42637936, }, { "from": "btg", @@ -30971,7 +30971,7 @@ const List> fixedRateMarketsJSON = [ "rate": 5633.752080599999, "minerFee": 1361.8708393220002, "min": 0.29786654, - "max": 8.53297321 + "max": 8.53297321, }, { "from": "btg", @@ -30979,7 +30979,7 @@ const List> fixedRateMarketsJSON = [ "rate": 1827193.2965347187, "minerFee": 218035.50092333645, "min": 0.19141369, - "max": 83.43184299 + "max": 83.43184299, }, { "from": "btg", @@ -30987,7 +30987,7 @@ const List> fixedRateMarketsJSON = [ "rate": 3.4336926393594087, "minerFee": 0.001561749307052664, "min": 0.08816395, - "max": 83.33375575 + "max": 83.33375575, }, { "from": "btg", @@ -30995,7 +30995,7 @@ const List> fixedRateMarketsJSON = [ "rate": 47704658766.10275, "minerFee": 101208911269.9956, "min": 0.37427568, - "max": 85.27056189 + "max": 85.27056189, }, { "from": "btg", @@ -31003,7 +31003,7 @@ const List> fixedRateMarketsJSON = [ "rate": 612.5934065934065, "minerFee": 0.2002197802197802, "min": 0.08803895, - "max": 83.333637 + "max": 83.333637, }, { "from": "btg", @@ -31011,7 +31011,7 @@ const List> fixedRateMarketsJSON = [ "rate": 184.89552238805967, "minerFee": 44.6104948562189, "min": 0.29748467, - "max": 83.53261043 + "max": 83.53261043, }, { "from": "btg", @@ -31019,7 +31019,7 @@ const List> fixedRateMarketsJSON = [ "rate": 160.65129682997116, "minerFee": 13.33209866074928, "min": 0.1597774, - "max": 83.40178852 + "max": 83.40178852, }, { "from": "btg", @@ -31027,7 +31027,7 @@ const List> fixedRateMarketsJSON = [ "rate": 80.90856313497822, "minerFee": 0.048236584746008705, "min": 0.08797608, - "max": 83.33357726 + "max": 83.33357726, }, { "from": "btg", @@ -31035,7 +31035,7 @@ const List> fixedRateMarketsJSON = [ "rate": 164.42060350642885, "minerFee": 39.73910784623827, "min": 0.29776237, - "max": 83.53287424 + "max": 83.53287424, }, { "from": "btg", @@ -31043,7 +31043,7 @@ const List> fixedRateMarketsJSON = [ "rate": 131.78723404255317, "minerFee": 12.519967913687944, "min": 0.17032487, - "max": 83.41180861 + "max": 83.41180861, }, { "from": "btg", @@ -31051,7 +31051,7 @@ const List> fixedRateMarketsJSON = [ "rate": 498.7152768934262, "minerFee": 2.0815894113527076, "min": 0.09180146, - "max": 83.33721138 + "max": 83.33721138, }, { "from": "btg", @@ -31059,7 +31059,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.08000287026406429, "minerFee": 0.0003481184041331802, "min": 0.09198019, - "max": 83.33738117 + "max": 83.33738117, }, { "from": "btg", @@ -31067,7 +31067,7 @@ const List> fixedRateMarketsJSON = [ "rate": 89.11854948873689, "minerFee": 0.6111676217977483, "min": 0.0944301, - "max": 3.33970858 + "max": 3.33970858, }, { "from": "btg", @@ -31075,7 +31075,7 @@ const List> fixedRateMarketsJSON = [ "rate": 92.44776119402984, "minerFee": 0.6723596881094528, "min": 0.09483216, - "max": 83.34009054 + "max": 83.34009054, }, { "from": "btg", @@ -31083,7 +31083,7 @@ const List> fixedRateMarketsJSON = [ "rate": 22.534406265599998, "minerFee": 0.244898820432, "min": 0.09834693, - "max": 83.34342957 + "max": 83.34342957, }, { "from": "btg", @@ -31091,7 +31091,7 @@ const List> fixedRateMarketsJSON = [ "rate": 153.07227367150617, "minerFee": 36.342140058760165, "min": 0.2957292, - "max": 16.86427607 + "max": 16.86427607, }, { "from": "btg", @@ -31099,7 +31099,7 @@ const List> fixedRateMarketsJSON = [ "rate": 1.1826880237615356, "minerFee": 0.010193486793253421, "min": 0.09615562, - "max": 83.34134783 + "max": 83.34134783, }, { "from": "btg", @@ -31107,7 +31107,7 @@ const List> fixedRateMarketsJSON = [ "rate": 215.23552123552122, "minerFee": 24.542612145212356, "min": 0.1868645, - "max": 83.42752127 + "max": 83.42752127, }, { "from": "btg", @@ -31115,7 +31115,7 @@ const List> fixedRateMarketsJSON = [ "rate": 0.014632648240018899, "minerFee": 0.0013523938892826208, "min": 0.16468619, - "max": 83.40962433 + "max": 83.40962433, }, { "from": "btg", @@ -31123,7 +31123,7 @@ const List> fixedRateMarketsJSON = [ "rate": 11.819357574472594, "minerFee": 0.011933637230997562, "min": 0.23192767, - "max": 83.33427225 + "max": 83.33427225, }, { "from": "btg", @@ -31131,7 +31131,7 @@ const List> fixedRateMarketsJSON = [ "rate": 23126390.835414965, "minerFee": 1655052.6969984109, "min": 0.15775047, - "max": 12.56652961 + "max": 12.56652961, }, { "from": "btg", @@ -31139,7 +31139,7 @@ const List> fixedRateMarketsJSON = [ "rate": 2839.115528846205, "minerFee": 287.82718733813437, "min": 0.17583358, - "max": 8.41704189 + "max": 8.41704189, }, { "from": "btg", @@ -31147,7 +31147,7 @@ const List> fixedRateMarketsJSON = [ "rate": 22.535543483999994, "minerFee": 0.24498456648, "min": 0.09835134, - "max": 83.34343376 + "max": 83.34343376, }, { "from": "btg", @@ -31155,7 +31155,7 @@ const List> fixedRateMarketsJSON = [ "rate": 26679.282835897662, "minerFee": 195.69592369840862, "min": 0.09489708, - "max": 8.34015221 + "max": 8.34015221, }, { "from": "btg", @@ -31163,7 +31163,7 @@ const List> fixedRateMarketsJSON = [ "rate": 27.86603349162709, "minerFee": 0.010798860284928768, "min": 0.08809814, - "max": 83.33369322 + "max": 83.33369322, }, { "from": "btg", @@ -31171,7 +31171,7 @@ const List> fixedRateMarketsJSON = [ "rate": 3.6662939822426828, "minerFee": 0.05059980269648142, "min": 0.83376746, - "max": 41.67948949 + "max": 41.67948949, }, { "from": "btg", @@ -31179,7 +31179,7 @@ const List> fixedRateMarketsJSON = [ "rate": 5.67331569305923, "minerFee": 0.04632814980663546, "min": 0.09570443, - "max": 8.3409192 + "max": 8.3409192, }, { "from": "btg", @@ -31187,7 +31187,7 @@ const List> fixedRateMarketsJSON = [ "rate": 51299.297206563795, "minerFee": 6560.880224620378, "min": 0.19887458, - "max": 8.43893084 + "max": 8.43893084, }, { "from": "btg", @@ -31195,7 +31195,7 @@ const List> fixedRateMarketsJSON = [ "rate": 2234.966119881007, "minerFee": 19.921217139019387, "min": 0.09644154, - "max": 8.34161945 + "max": 8.34161945, }, { "from": "btg", @@ -31203,7 +31203,7 @@ const List> fixedRateMarketsJSON = [ "rate": 5458.751231330335, "minerFee": 54.51949997620946, "min": 0.09749263, - "max": 8.34261799 + "max": 8.34261799, }, { "from": "btg", @@ -31211,7 +31211,7 @@ const List> fixedRateMarketsJSON = [ "rate": 59.383655060680816, "minerFee": 0.6014325873923404, "min": 0.09763, - "max": 8.34274849 + "max": 8.34274849, }, { "from": "btg", @@ -31219,7 +31219,7 @@ const List> fixedRateMarketsJSON = [ "rate": 20009.571409609012, "minerFee": 199.91039097486447, "min": 0.09749576, - "max": 8.34262096 + "max": 8.34262096, }, { "from": "btg", @@ -31227,7 +31227,7 @@ const List> fixedRateMarketsJSON = [ "rate": 18.444033614780405, "minerFee": 0.182008368812234, "min": 0.09737579, - "max": 8.34250699 + "max": 8.34250699, }, { "from": "btg", @@ -31235,7 +31235,7 @@ const List> fixedRateMarketsJSON = [ "rate": 1806.094478793519, "minerFee": 16.983146009352723, "min": 0.09692086, - "max": 8.34207481 + "max": 8.34207481, }, { "from": "btg", @@ -31243,7 +31243,7 @@ const List> fixedRateMarketsJSON = [ "rate": 54.38559116685567, "minerFee": 0.5737179582277065, "min": 0.09804213, - "max": 83.34314002 + "max": 83.34314002, }, { "from": "btg", @@ -31251,7 +31251,7 @@ const List> fixedRateMarketsJSON = [ "rate": 26453.452494334324, "minerFee": 272.19687938925716, "min": 0.09778827, - "max": 8.34289884 + "max": 8.34289884, }, { "from": "btg", @@ -31259,7 +31259,7 @@ const List> fixedRateMarketsJSON = [ "rate": 84.36554911793039, "minerFee": 0.9320547548250192, "min": 0.09853017, - "max": 8.34360365 + "max": 8.34360365, }, { "from": "btg", @@ -31267,7 +31267,7 @@ const List> fixedRateMarketsJSON = [ "rate": 23.430113978958218, "minerFee": 0.24127752748122017, "min": 0.09779619, - "max": 41.67623971 + "max": 41.67623971, }, { "from": "btg", @@ -31275,7 +31275,7 @@ const List> fixedRateMarketsJSON = [ "rate": 35.98522107076894, "minerFee": 0.26028537273141417, "min": 0.09479726, - "max": 66.67339072 + "max": 66.67339072, }, { "from": "btg", @@ -31283,7 +31283,7 @@ const List> fixedRateMarketsJSON = [ "rate": 57997.80585040937, "minerFee": 453.837920195159, "min": 0.09537655, - "max": 8.34060771 + "max": 8.34060771, }, { "from": "btg", @@ -31291,7 +31291,7 @@ const List> fixedRateMarketsJSON = [ "rate": 271.50612436626506, "minerFee": 2.6064851398554216, "min": 0.09711852, - "max": 8.34226258 + "max": 8.34226258, }, { "from": "btg", @@ -31299,7 +31299,7 @@ const List> fixedRateMarketsJSON = [ "rate": 14.240899220845574, "minerFee": 5.822829629463533, "min": 0.44314537, - "max": 83.67098809 + "max": 83.67098809, }, { "from": "btg", @@ -31307,11 +31307,15 @@ const List> fixedRateMarketsJSON = [ "rate": 1639.5882352941173, "minerFee": 163.23764397411765, "min": 0.17428818, - "max": 83.41557376 + "max": 83.41557376, }, ]; const Map createStandardTransactionResponse = { + "fromAmount": "0.3", + "toAmount": "0.0021936", + "flow": "standard", + "type": "direct", "payinAddress": "85uTiLU3DPHDw8JuinfrLAJPsPw64BnCB8UU95mHhqXsVQrG1XKz3umMwnh468nRn54WWxNzZ79d5RGcESjKPSBGPDtrTRd", "payoutAddress": "bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5", @@ -31321,6 +31325,8 @@ const Map createStandardTransactionResponse = { "refundAddress": "888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H", "refundExtraId": "", + "fromNetwork": "xmr", + "toNetwork": "", + "validUntil": "2019-09-09T14:01:04.921Z", "id": "6d2f9280dacab3", - "amount": 0.0021936 }; diff --git a/test/services/change_now/change_now_test.dart b/test/services/change_now/change_now_test.dart index f922bc3ed7..5cc526d6d3 100644 --- a/test/services/change_now/change_now_test.dart +++ b/test/services/change_now/change_now_test.dart @@ -5,8 +5,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:stackwallet/exceptions/exchange/exchange_exception.dart'; -import 'package:stackwallet/models/exchange/change_now/exchange_transaction.dart'; -import 'package:stackwallet/models/exchange/change_now/exchange_transaction_status.dart'; +import 'package:stackwallet/models/exchange/change_now/cn_exchange_transaction.dart'; +import 'package:stackwallet/models/exchange/change_now/cn_exchange_transaction_status.dart'; import 'package:stackwallet/models/exchange/response_objects/estimate.dart'; import 'package:stackwallet/networking/http.dart'; import 'package:stackwallet/services/exchange/change_now/change_now_api.dart'; @@ -16,68 +16,119 @@ import 'change_now_test.mocks.dart'; @GenerateMocks([HTTP]) void main() { - group("getAvailableCurrencies", () { - test("getAvailableCurrencies succeeds without options", () async { - final client = MockHTTP(); + const testApiKey = 'testAPIKEY'; + + Uri buildV2Uri(String path, [Map? params]) { + return Uri.https('api.changenow.io', '/v2$path', params); + } + + Map changeNowHeaders([String apiKey = '']) { + return {'Content-Type': 'application/json', 'x-changenow-api-key': apiKey}; + } + + String buildCreateExchangeBody({ + required String fromCurrency, + required String fromNetwork, + required String toCurrency, + required String toNetwork, + required String fromAmount, + required String toAmount, + required String flow, + required String type, + required String address, + String extraId = '', + String refundAddress = '', + String refundExtraId = '', + String userId = '', + String payload = '', + String contactEmail = '', + String rateId = '', + }) { + return jsonEncode({ + 'fromCurrency': fromCurrency, + 'fromNetwork': fromNetwork, + 'toCurrency': toCurrency, + 'toNetwork': toNetwork, + 'fromAmount': fromAmount, + 'toAmount': toAmount, + 'flow': flow, + 'type': type, + 'address': address, + 'extraId': extraId, + 'refundAddress': refundAddress, + 'refundExtraId': refundExtraId, + 'userId': userId, + 'payload': payload, + 'contactEmail': contactEmail, + 'rateId': rateId, + }); + } + group('getAvailableCurrencies', () { + test('getAvailableCurrencies succeeds without options', () async { + final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.get( - url: Uri.parse("https://api.ChangeNow.io/v1/currencies"), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange/currencies', {'flow': 'standard'}), + headers: changeNowHeaders(testApiKey), proxyInfo: null, ), ).thenAnswer( - (realInvocation) async => + (_) async => Response(utf8.encode(jsonEncode(availableCurrenciesJSON)), 200), ); - final result = await instance.getAvailableCurrencies(); + final result = await instance.getAvailableCurrencies(apiKey: testApiKey); expect(result.exception, null); expect(result.value == null, false); expect(result.value!.length, 538); }); - test("getAvailableCurrencies succeeds with active option", () async { + test('getAvailableCurrencies succeeds with active option', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.get( - url: Uri.parse("https://api.ChangeNow.io/v1/currencies?active=true"), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange/currencies', { + 'flow': 'standard', + 'active': 'true', + }), + headers: changeNowHeaders(testApiKey), proxyInfo: null, ), ).thenAnswer( - (realInvocation) async => Response( + (_) async => Response( utf8.encode(jsonEncode(availableCurrenciesJSONActive)), 200, ), ); - final result = await instance.getAvailableCurrencies(active: true); + final result = await instance.getAvailableCurrencies( + active: true, + apiKey: testApiKey, + ); expect(result.exception, null); expect(result.value == null, false); expect(result.value!.length, 531); }); - test("getAvailableCurrencies succeeds with fixedRate option", () async { + test('getAvailableCurrencies succeeds with fixedRate option', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.get( - url: Uri.parse( - "https://api.ChangeNow.io/v1/currencies?fixedRate=true", - ), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange/currencies', {'flow': 'fixed-rate'}), + headers: changeNowHeaders(testApiKey), proxyInfo: null, ), ).thenAnswer( - (realInvocation) async => Response( + (_) async => Response( utf8.encode(jsonEncode(availableCurrenciesJSONFixedRate)), 200, ), @@ -85,6 +136,7 @@ void main() { final result = await instance.getAvailableCurrencies( flow: CNFlow.fixedRate, + apiKey: testApiKey, ); expect(result.exception, null); @@ -93,21 +145,22 @@ void main() { }); test( - "getAvailableCurrencies succeeds with fixedRate and active options", + 'getAvailableCurrencies succeeds with fixedRate and active options', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.get( - url: Uri.parse( - "https://api.ChangeNow.io/v1/currencies?fixedRate=true&active=true", - ), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange/currencies', { + 'flow': 'fixed-rate', + 'active': 'true', + }), + headers: changeNowHeaders(testApiKey), proxyInfo: null, ), ).thenAnswer( - (realInvocation) async => Response( + (_) async => Response( utf8.encode(jsonEncode(availableCurrenciesJSONActiveFixedRate)), 200, ), @@ -116,6 +169,7 @@ void main() { final result = await instance.getAvailableCurrencies( active: true, flow: CNFlow.fixedRate, + apiKey: testApiKey, ); expect(result.exception, null); @@ -125,25 +179,27 @@ void main() { ); test( - "getAvailableCurrencies fails with ChangeNowExceptionType.serializeResponseError", + 'getAvailableCurrencies fails with ChangeNowExceptionType.serializeResponseError', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.get( - url: Uri.parse("https://api.ChangeNow.io/v1/currencies"), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange/currencies', {'flow': 'standard'}), + headers: changeNowHeaders(testApiKey), proxyInfo: null, ), ).thenAnswer( - (realInvocation) async => Response( + (_) async => Response( utf8.encode('{"some unexpected": "but valid json data"}'), 200, ), ); - final result = await instance.getAvailableCurrencies(); + final result = await instance.getAvailableCurrencies( + apiKey: testApiKey, + ); expect( result.exception!.type, @@ -153,50 +209,48 @@ void main() { }, ); - test("getAvailableCurrencies fails for any other reason", () async { + test('getAvailableCurrencies fails for any other reason', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.get( - url: Uri.parse("https://api.ChangeNow.io/v1/currencies"), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange/currencies', {'flow': 'standard'}), + headers: changeNowHeaders(testApiKey), proxyInfo: null, ), - ).thenAnswer((realInvocation) async => Response(utf8.encode(""), 400)); + ).thenAnswer((_) async => Response(utf8.encode(''), 400)); - final result = await instance.getAvailableCurrencies(); + final result = await instance.getAvailableCurrencies(apiKey: testApiKey); - expect( - result.exception!.type, - ExchangeExceptionType.serializeResponseError, - ); + expect(result.exception!.type, ExchangeExceptionType.generic); expect(result.value == null, true); }); }); - group("getMinimalExchangeAmount", () { - test("getMinimalExchangeAmount succeeds", () async { + group('getMinimalExchangeAmount', () { + test('getMinimalExchangeAmount succeeds', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.get( - url: Uri.parse( - "https://api.ChangeNow.io/v1/min-amount/xmr_btc?api_key=testAPIKEY", - ), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange/min-amount', { + 'fromCurrency': 'xmr', + 'toCurrency': 'btc', + 'flow': 'standard', + }), + headers: changeNowHeaders('testAPIKEY'), proxyInfo: null, ), ).thenAnswer( - (realInvocation) async => - Response(utf8.encode('{"minAmount": 42}'), 200), + (_) async => Response(utf8.encode('{"minAmount": 42}'), 200), ); final result = await instance.getMinimalExchangeAmount( - fromCurrency: "xmr", - toCurrency: "btc", - apiKey: "testAPIKEY", + fromCurrency: 'xmr', + toCurrency: 'btc', + apiKey: 'testAPIKEY', ); expect(result.exception, null); @@ -205,27 +259,27 @@ void main() { }); test( - "getMinimalExchangeAmount fails with ChangeNowExceptionType.serializeResponseError", + 'getMinimalExchangeAmount fails with ChangeNowExceptionType.serializeResponseError', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.get( - url: Uri.parse( - "https://api.ChangeNow.io/v1/min-amount/xmr_btc?api_key=testAPIKEY", - ), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange/min-amount', { + 'fromCurrency': 'xmr', + 'toCurrency': 'btc', + 'flow': 'standard', + }), + headers: changeNowHeaders('testAPIKEY'), proxyInfo: null, ), - ).thenAnswer( - (realInvocation) async => Response(utf8.encode('{"error": 42}'), 200), - ); + ).thenAnswer((_) async => Response(utf8.encode('{"error": 42}'), 200)); final result = await instance.getMinimalExchangeAmount( - fromCurrency: "xmr", - toCurrency: "btc", - apiKey: "testAPIKEY", + fromCurrency: 'xmr', + toCurrency: 'btc', + apiKey: 'testAPIKEY', ); expect( @@ -236,61 +290,78 @@ void main() { }, ); - test("getMinimalExchangeAmount fails for any other reason", () async { + test('getMinimalExchangeAmount fails for any other reason', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.get( - url: Uri.parse( - "https://api.ChangeNow.io/v1/min-amount/xmr_btc?api_key=testAPIKEY", - ), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange/min-amount', { + 'fromCurrency': 'xmr', + 'toCurrency': 'btc', + 'flow': 'standard', + }), + headers: changeNowHeaders('testAPIKEY'), proxyInfo: null, ), - ).thenAnswer((realInvocation) async => Response(utf8.encode(''), 400)); + ).thenAnswer((_) async => Response(utf8.encode(''), 400)); final result = await instance.getMinimalExchangeAmount( - fromCurrency: "xmr", - toCurrency: "btc", - apiKey: "testAPIKEY", + fromCurrency: 'xmr', + toCurrency: 'btc', + apiKey: 'testAPIKEY', ); - expect( - result.exception!.type, - ExchangeExceptionType.serializeResponseError, - ); + expect(result.exception!.type, ExchangeExceptionType.generic); expect(result.value == null, true); }); }); - group("getEstimatedExchangeAmount", () { - test("getEstimatedExchangeAmount succeeds", () async { + group('getEstimatedExchangeAmount', () { + test('getEstimatedExchangeAmount succeeds', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.get( - url: Uri.parse( - "https://api.ChangeNow.io/v1/exchange-amount/42/xmr_btc?api_key=testAPIKEY", - ), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange/estimated-amount', { + 'fromCurrency': 'xmr', + 'toCurrency': 'btc', + 'fromAmount': '42', + 'flow': 'standard', + 'type': 'direct', + }), + headers: changeNowHeaders('testAPIKEY'), proxyInfo: null, ), ).thenAnswer( - (realInvocation) async => Response( + (_) async => Response( utf8.encode( - '{"estimatedAmount": 58.4142873, "transactionSpeedForecast": "10-60", "warningMessage": null}', + jsonEncode({ + 'fromCurrency': 'xmr', + 'fromNetwork': 'xmr', + 'toCurrency': 'btc', + 'toNetwork': 'btc', + 'flow': 'standard', + 'type': 'direct', + 'validUntil': '2019-09-09T14:01:04.921Z', + 'transactionSpeedForecast': '10-60', + 'warningMessage': 'Rates may shift while the order is pending.', + 'depositFee': '0', + 'withdrawalFee': '0.0001', + 'fromAmount': '42', + 'toAmount': '58.4142873', + }), ), 200, ), ); final result = await instance.getEstimatedExchangeAmount( - fromCurrency: "xmr", - toCurrency: "btc", + fromCurrency: 'xmr', + toCurrency: 'btc', fromAmount: Decimal.fromInt(42), - apiKey: "testAPIKEY", + apiKey: 'testAPIKEY', ); expect(result.exception, null); @@ -299,28 +370,30 @@ void main() { }); test( - "getEstimatedExchangeAmount fails with ChangeNowExceptionType.serializeResponseError", + 'getEstimatedExchangeAmount fails with ChangeNowExceptionType.serializeResponseError', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.get( - url: Uri.parse( - "https://api.ChangeNow.io/v1/exchange-amount/42/xmr_btc?api_key=testAPIKEY", - ), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange/estimated-amount', { + 'fromCurrency': 'xmr', + 'toCurrency': 'btc', + 'fromAmount': '42', + 'flow': 'standard', + 'type': 'direct', + }), + headers: changeNowHeaders('testAPIKEY'), proxyInfo: null, ), - ).thenAnswer( - (realInvocation) async => Response(utf8.encode('{"error": 42}'), 200), - ); + ).thenAnswer((_) async => Response(utf8.encode('{"error": 42}'), 200)); final result = await instance.getEstimatedExchangeAmount( - fromCurrency: "xmr", - toCurrency: "btc", + fromCurrency: 'xmr', + toCurrency: 'btc', fromAmount: Decimal.fromInt(42), - apiKey: "testAPIKEY", + apiKey: 'testAPIKEY', ); expect( @@ -331,25 +404,29 @@ void main() { }, ); - test("getEstimatedExchangeAmount fails for any other reason", () async { + test('getEstimatedExchangeAmount fails for any other reason', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.get( - url: Uri.parse( - "https://api.ChangeNow.io/v1/exchange-amount/42/xmr_btc?api_key=testAPIKEY", - ), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange/estimated-amount', { + 'fromCurrency': 'xmr', + 'toCurrency': 'btc', + 'fromAmount': '42', + 'flow': 'standard', + 'type': 'direct', + }), + headers: changeNowHeaders('testAPIKEY'), proxyInfo: null, ), - ).thenAnswer((realInvocation) async => Response(utf8.encode(''), 400)); + ).thenAnswer((_) async => Response(utf8.encode(''), 400)); final result = await instance.getEstimatedExchangeAmount( - fromCurrency: "xmr", - toCurrency: "btc", + fromCurrency: 'xmr', + toCurrency: 'btc', fromAmount: Decimal.fromInt(42), - apiKey: "testAPIKEY", + apiKey: 'testAPIKEY', ); expect(result.exception!.type, ExchangeExceptionType.generic); @@ -357,113 +434,46 @@ void main() { }); }); - // group("getEstimatedFixedRateExchangeAmount", () { - // test("getEstimatedFixedRateExchangeAmount succeeds", () async { - // final client = MockHTTP(); - // ChangeNow.instance.client = client; - // - // when(client.get(url: - // Uri.parse( - // "https://api.ChangeNow.io/v1/exchange-amount/fixed-rate/10/xmr_btc?api_key=testAPIKEY&useRateId=true"), - // headers: {'Content-Type': 'application/json'}, - // proxyInfo: null, - // )).thenAnswer((realInvocation) async => - // Response(utf8.encode(jsonEncode(estFixedRateExchangeAmountJSON )), 200)); - // - // final result = - // await ChangeNow.instance.getEstimatedFixedRateExchangeAmount( - // fromCurrency: "xmr", - // toCurrency: "btc", - // fromAmount: Decimal.fromInt(10), - // apiKey: "testAPIKEY", - // ); - // - // expect(result.exception, null); - // expect(result.value == null, false); - // expect(result.value.toString(), - // 'EstimatedExchangeAmount: {estimatedAmount: 0.07271053, transactionSpeedForecast: 10-60, warningMessage: null, rateId: 1t2W5KBPqhycSJVYpaNZzYWLfMr0kSFe, networkFee: 0.00002408}'); - // }); - // - // test( - // "getEstimatedFixedRateExchangeAmount fails with ChangeNowExceptionType.serializeResponseError", - // () async { - // final client = MockHTTP(); - // ChangeNow.instance.client = client; - // - // when(client.get(url: - // Uri.parse( - // "https://api.ChangeNow.io/v1/exchange-amount/fixed-rate/10/xmr_btc?api_key=testAPIKEY&useRateId=true"), - // headers: {'Content-Type': 'application/json'}, - // proxyInfo: null, - // )).thenAnswer((realInvocation) async => Response('{"error": 42}', 200)); - // - // final result = - // await ChangeNow.instance.getEstimatedFixedRateExchangeAmount( - // fromCurrency: "xmr", - // toCurrency: "btc", - // fromAmount: Decimal.fromInt(10), - // apiKey: "testAPIKEY", - // ); - // - // expect(result.exception!.type, - // ChangeNowExceptionType.serializeResponseError); - // expect(result.value == null, true); - // }); - // - // test("getEstimatedFixedRateExchangeAmount fails for any other reason", - // () async { - // final client = MockHTTP(); - // ChangeNow.instance.client = client; - // - // when(client.get(url: - // Uri.parse( - // "https://api.ChangeNow.io/v1/exchange-amount/fixed-rate/10/xmr_btc?api_key=testAPIKEY&useRateId=true"), - // headers: {'Content-Type': 'application/json'}, - // proxyInfo: null, - // )).thenAnswer((realInvocation) async => Response('', 400)); - // - // final result = - // await ChangeNow.instance.getEstimatedFixedRateExchangeAmount( - // fromCurrency: "xmr", - // toCurrency: "btc", - // fromAmount: Decimal.fromInt(10), - // apiKey: "testAPIKEY", - // ); - // - // expect(result.exception!.type, ChangeNowExceptionType.generic); - // expect(result.value == null, true); - // }); - // }); - - group("createExchangeTransaction", () { - test("createExchangeTransaction succeeds", () async { + group('createExchangeTransaction standard flow', () { + test('createExchangeTransaction succeeds', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.post( - url: Uri.parse("https://api.ChangeNow.io/v1/transactions/testAPIKEY"), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange'), + headers: changeNowHeaders('testAPIKEY'), proxyInfo: null, - body: - '{"from":"xmr","to":"btc","address":"bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5","amount":"0.3","flow":"standard","extraId":"","userId":"","contactEmail":"","refundAddress":"888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H","refundExtraId":""}', + body: buildCreateExchangeBody( + fromCurrency: 'xmr', + fromNetwork: 'xmr', + toCurrency: 'btc', + toNetwork: '', + fromAmount: '0.3', + toAmount: '', + flow: 'standard', + type: 'direct', + address: 'bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5', + refundAddress: + '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H', + ), encoding: null, ), ).thenAnswer( - (realInvocation) async => Response( + (_) async => Response( utf8.encode(jsonEncode(createStandardTransactionResponse)), 200, ), ); final result = await instance.createExchangeTransaction( - fromCurrency: "xmr", - toCurrency: "btc", - address: "bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5", - fromAmount: Decimal.parse("0.3"), + fromCurrency: 'xmr', + toCurrency: 'btc', + address: 'bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5', + fromAmount: Decimal.parse('0.3'), refundAddress: - "888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H", - apiKey: "testAPIKEY", + '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H', + apiKey: 'testAPIKEY', fromNetwork: 'xmr', toNetwork: '', rateId: '', @@ -471,38 +481,45 @@ void main() { expect(result.exception, null); expect(result.value == null, false); - expect(result.value, isA()); + expect(result.value, isA()); }); test( - "createExchangeTransaction fails with ChangeNowExceptionType.serializeResponseError", + 'createExchangeTransaction fails with ChangeNowExceptionType.serializeResponseError', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.post( - url: Uri.parse( - "https://api.ChangeNow.io/v1/transactions/testAPIKEY", - ), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange'), + headers: changeNowHeaders('testAPIKEY'), proxyInfo: null, - body: - '{"from":"xmr","to":"btc","address":"bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5","amount":"0.3","flow":"standard","extraId":"","userId":"","contactEmail":"","refundAddress":"888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H","refundExtraId":""}', + body: buildCreateExchangeBody( + fromCurrency: 'xmr', + fromNetwork: 'xmr', + toCurrency: 'btc', + toNetwork: '', + fromAmount: '0.3', + toAmount: '', + flow: 'standard', + type: 'direct', + address: 'bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5', + refundAddress: + '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H', + ), encoding: null, ), - ).thenAnswer( - (realInvocation) async => Response(utf8.encode('{"error": 42}'), 200), - ); + ).thenAnswer((_) async => Response(utf8.encode('{"error": 42}'), 200)); final result = await instance.createExchangeTransaction( - fromCurrency: "xmr", - toCurrency: "btc", - address: "bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5", - fromAmount: Decimal.parse("0.3"), + fromCurrency: 'xmr', + toCurrency: 'btc', + address: 'bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5', + fromAmount: Decimal.parse('0.3'), refundAddress: - "888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H", - apiKey: "testAPIKEY", + '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H', + apiKey: 'testAPIKEY', fromNetwork: 'xmr', toNetwork: '', rateId: '', @@ -516,29 +533,40 @@ void main() { }, ); - test("createExchangeTransaction fails for any other reason", () async { + test('createExchangeTransaction fails for any other reason', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.post( - url: Uri.parse("https://api.ChangeNow.io/v1/transactions/testAPIKEY"), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange'), + headers: changeNowHeaders('testAPIKEY'), proxyInfo: null, - body: - '{"from":"xmr","to":"btc","address":"bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5","amount":"0.3","flow":"standard","extraId":"","userId":"","contactEmail":"","refundAddress":"888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H","refundExtraId":""}', + body: buildCreateExchangeBody( + fromCurrency: 'xmr', + fromNetwork: 'xmr', + toCurrency: 'btc', + toNetwork: '', + fromAmount: '0.3', + toAmount: '', + flow: 'standard', + type: 'direct', + address: 'bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5', + refundAddress: + '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H', + ), encoding: null, ), - ).thenAnswer((realInvocation) async => Response(utf8.encode(''), 400)); + ).thenAnswer((_) async => Response(utf8.encode(''), 400)); final result = await instance.createExchangeTransaction( - fromCurrency: "xmr", - toCurrency: "btc", - address: "bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5", - fromAmount: Decimal.parse("0.3"), + fromCurrency: 'xmr', + toCurrency: 'btc', + address: 'bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5', + fromAmount: Decimal.parse('0.3'), refundAddress: - "888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H", - apiKey: "testAPIKEY", + '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H', + apiKey: 'testAPIKEY', fromNetwork: 'xmr', toNetwork: '', rateId: '', @@ -549,43 +577,63 @@ void main() { }); }); - group("createExchangeTransaction", () { - test("createExchangeTransaction succeeds", () async { + group('createExchangeTransaction fixed-rate flow', () { + test('createExchangeTransaction succeeds', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.post( - url: Uri.parse( - "https://api.ChangeNow.io/v1/transactions/fixed-rate/testAPIKEY", - ), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange'), + headers: changeNowHeaders('testAPIKEY'), proxyInfo: null, - body: - '{"from":"btc","to":"eth","address":"0x57f31ad4b64095347F87eDB1675566DAfF5EC886","flow":"fixed-rate","extraId":"","userId":"","contactEmail":"","refundAddress":"","refundExtraId":"","rateId":"","amount":"0.3"}', + body: buildCreateExchangeBody( + fromCurrency: 'btc', + fromNetwork: 'xmr', + toCurrency: 'eth', + toNetwork: '', + fromAmount: '0.3', + toAmount: '', + flow: 'fixed-rate', + type: 'direct', + address: '0x57f31ad4b64095347F87eDB1675566DAfF5EC886', + ), encoding: null, ), ).thenAnswer( - (realInvocation) async => Response( + (_) async => Response( utf8.encode( - '{"payinAddress": "33eFX2jfeWbXMSmRe9ewUUTrmSVSxZi5cj", "payoutAddress":' - ' "0x57f31ad4b64095347F87eDB1675566DAfF5EC886","payoutExtraId": "",' - ' "fromCurrency": "btc", "toCurrency": "eth", "refundAddress": "",' - '"refundExtraId": "","validUntil": "2019-09-09T14:01:04.921Z","id":' - ' "a5c73e2603f40d","amount": 62.9737711}', + jsonEncode({ + 'fromAmount': '0.3', + 'toAmount': '62.9737711', + 'flow': 'fixed-rate', + 'type': 'direct', + 'payinAddress': '33eFX2jfeWbXMSmRe9ewUUTrmSVSxZi5cj', + 'payoutAddress': '0x57f31ad4b64095347F87eDB1675566DAfF5EC886', + 'payoutExtraId': '', + 'fromCurrency': 'btc', + 'toCurrency': 'eth', + 'refundAddress': '', + 'refundExtraId': '', + 'fromNetwork': 'xmr', + 'toNetwork': '', + 'validUntil': '2019-09-09T14:01:04.921Z', + 'id': 'a5c73e2603f40d', + }), ), 200, ), ); final result = await instance.createExchangeTransaction( - fromCurrency: "btc", - toCurrency: "eth", - address: "0x57f31ad4b64095347F87eDB1675566DAfF5EC886", - fromAmount: Decimal.parse("0.3"), - refundAddress: "", - apiKey: "testAPIKEY", + fromCurrency: 'btc', + toCurrency: 'eth', + address: '0x57f31ad4b64095347F87eDB1675566DAfF5EC886', + fromAmount: Decimal.parse('0.3'), + refundAddress: '', + apiKey: 'testAPIKEY', rateId: '', + flow: CNFlow.fixedRate, type: CNExchangeType.direct, fromNetwork: 'xmr', toNetwork: '', @@ -593,77 +641,98 @@ void main() { expect(result.exception, null); expect(result.value == null, false); - expect(result.value, isA()); + expect(result.value, isA()); }); test( - "createExchangeTransaction fails with ChangeNowExceptionType.serializeResponseError", + 'createExchangeTransaction fails with ChangeNowExceptionType.serializeResponseError', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.post( - url: Uri.parse( - "https://api.ChangeNow.io/v1/transactions/fixed-rate/testAPIKEY", - ), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange'), + headers: changeNowHeaders('testAPIKEY'), proxyInfo: null, - body: - '{"from":"btc","to":"eth","address":"0x57f31ad4b64095347F87eDB1675566DAfF5EC886","amount":"0.3","flow":"fixed-rate","extraId":"","userId":"","contactEmail":"","refundAddress":"","refundExtraId":"","rateId":""}', + body: buildCreateExchangeBody( + fromCurrency: 'btc', + fromNetwork: 'xmr', + toCurrency: 'eth', + toNetwork: '', + fromAmount: '0.3', + toAmount: '', + flow: 'fixed-rate', + type: 'direct', + address: '0x57f31ad4b64095347F87eDB1675566DAfF5EC886', + ), encoding: null, ), ).thenAnswer( - (realInvocation) async => Response( - utf8.encode('{"id": "a5c73e2603f40d","amount": 62.9737711}'), + (_) async => Response( + utf8.encode('{"id": "a5c73e2603f40d", "amount": 62.9737711}'), 200, ), ); final result = await instance.createExchangeTransaction( - fromCurrency: "btc", - toCurrency: "eth", - address: "0x57f31ad4b64095347F87eDB1675566DAfF5EC886", - fromAmount: Decimal.parse("0.3"), - refundAddress: "", - apiKey: "testAPIKEY", + fromCurrency: 'btc', + toCurrency: 'eth', + address: '0x57f31ad4b64095347F87eDB1675566DAfF5EC886', + fromAmount: Decimal.parse('0.3'), + refundAddress: '', + apiKey: 'testAPIKEY', rateId: '', + flow: CNFlow.fixedRate, type: CNExchangeType.direct, fromNetwork: 'xmr', toNetwork: '', ); - expect(result.exception!.type, ExchangeExceptionType.generic); + expect( + result.exception!.type, + ExchangeExceptionType.serializeResponseError, + ); expect(result.value == null, true); }, ); - test("createExchangeTransaction fails for any other reason", () async { + test('createExchangeTransaction fails for any other reason', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.post( - url: Uri.parse( - "https://api.ChangeNow.io/v1/transactions/fixed-rate/testAPIKEY", - ), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange'), + headers: changeNowHeaders('testAPIKEY'), proxyInfo: null, - body: - '{"from": "btc","to": "eth","address": "0x57f31ad4b64095347F87eDB1675566DAfF5EC886", "amount": "1.12345","extraId": "", "userId": "","contactEmail": "","refundAddress": "", "refundExtraId": "", "rateId": "" }', + body: buildCreateExchangeBody( + fromCurrency: 'xmr', + fromNetwork: 'xmr', + toCurrency: 'btc', + toNetwork: '', + fromAmount: '0.3', + toAmount: '', + flow: 'fixed-rate', + type: 'direct', + address: 'bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5', + refundAddress: + '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H', + ), encoding: null, ), - ).thenAnswer((realInvocation) async => Response(utf8.encode(''), 400)); + ).thenAnswer((_) async => Response(utf8.encode(''), 400)); final result = await instance.createExchangeTransaction( - fromCurrency: "xmr", - toCurrency: "btc", - address: "bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5", - fromAmount: Decimal.parse("0.3"), + fromCurrency: 'xmr', + toCurrency: 'btc', + address: 'bc1qu58svs9983e2vuyqh7gq7ratf8k5qehz5k0cn5', + fromAmount: Decimal.parse('0.3'), refundAddress: - "888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H", - apiKey: "testAPIKEY", + '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H', + apiKey: 'testAPIKEY', rateId: '', + flow: CNFlow.fixedRate, type: CNExchangeType.direct, fromNetwork: 'xmr', toNetwork: '', @@ -674,64 +743,73 @@ void main() { }); }); - group("getTransactionStatus", () { - test("getTransactionStatus succeeds", () async { + group('getTransactionStatus', () { + test('getTransactionStatus succeeds', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.get( - url: Uri.parse( - "https://api.ChangeNow.io/v1/transactions/47F87eDB1675566DAfF5EC886/testAPIKEY", - ), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange/by-id', { + 'id': '47F87eDB1675566DAfF5EC886', + }), + headers: changeNowHeaders('testAPIKEY'), proxyInfo: null, ), ).thenAnswer( - (realInvocation) async => Response( + (_) async => Response( utf8.encode( - '{"status": "waiting", "payinAddress": "32Ge2ci26rj1sRGw2NjiQa9L7Xvxtgzhrj", ' - '"payoutAddress": "0x57f31ad4b64095347F87eDB1675566DAfF5EC886", ' - '"fromCurrency": "btc", "toCurrency": "eth", "id": "50727663e5d9a4", ' - '"updatedAt": "2019-08-22T14:47:49.943Z", "expectedSendAmount": 1, ' - '"expectedReceiveAmount": 52.31667, "createdAt": "2019-08-22T14:47:49.943Z",' - ' "isPartner": false}', + jsonEncode({ + 'status': 'waiting', + 'id': '50727663e5d9a4', + 'actionsAvailable': false, + 'fromCurrency': 'btc', + 'fromNetwork': 'btc', + 'toCurrency': 'eth', + 'toNetwork': 'eth', + 'expectedAmountFrom': '1', + 'expectedAmountTo': '52.31667', + 'payinAddress': '32Ge2ci26rj1sRGw2NjiQa9L7Xvxtgzhrj', + 'payoutAddress': '0x57f31ad4b64095347F87eDB1675566DAfF5EC886', + 'createdAt': '2019-08-22T14:47:49.943Z', + 'updatedAt': '2019-08-22T14:47:49.943Z', + 'fromLegacyTicker': 'btc', + 'toLegacyTicker': 'eth', + }), ), 200, ), ); final result = await instance.getTransactionStatus( - id: "47F87eDB1675566DAfF5EC886", - apiKey: "testAPIKEY", + id: '47F87eDB1675566DAfF5EC886', + apiKey: 'testAPIKEY', ); expect(result.exception, null); expect(result.value == null, false); - expect(result.value, isA()); + expect(result.value, isA()); }); test( - "getTransactionStatus fails with ChangeNowExceptionType.serializeResponseError", + 'getTransactionStatus fails with ChangeNowExceptionType.serializeResponseError', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.get( - url: Uri.parse( - "https://api.ChangeNow.io/v1/transactions/47F87eDB1675566DAfF5EC886/testAPIKEY", - ), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange/by-id', { + 'id': '47F87eDB1675566DAfF5EC886', + }), + headers: changeNowHeaders('testAPIKEY'), proxyInfo: null, ), - ).thenAnswer( - (realInvocation) async => Response(utf8.encode('{"error": 42}'), 200), - ); + ).thenAnswer((_) async => Response(utf8.encode('{"error": 42}'), 200)); final result = await instance.getTransactionStatus( - id: "47F87eDB1675566DAfF5EC886", - apiKey: "testAPIKEY", + id: '47F87eDB1675566DAfF5EC886', + apiKey: 'testAPIKEY', ); expect( @@ -742,29 +820,26 @@ void main() { }, ); - test("getTransactionStatus fails for any other reason", () async { + test('getTransactionStatus fails for any other reason', () async { final client = MockHTTP(); final instance = ChangeNowAPI(http: client); when( client.get( - url: Uri.parse( - "https://api.ChangeNow.io/v1/transactions/47F87eDB1675566DAfF5EC886/testAPIKEY", - ), - headers: {'Content-Type': 'application/json'}, + url: buildV2Uri('/exchange/by-id', { + 'id': '47F87eDB1675566DAfF5EC886', + }), + headers: changeNowHeaders('testAPIKEY'), proxyInfo: null, ), - ).thenAnswer((realInvocation) async => Response(utf8.encode(''), 400)); + ).thenAnswer((_) async => Response(utf8.encode(''), 400)); final result = await instance.getTransactionStatus( - id: "47F87eDB1675566DAfF5EC886", - apiKey: "testAPIKEY", + id: '47F87eDB1675566DAfF5EC886', + apiKey: 'testAPIKEY', ); - expect( - result.exception!.type, - ExchangeExceptionType.serializeResponseError, - ); + expect(result.exception!.type, ExchangeExceptionType.generic); expect(result.value == null, true); }); }); diff --git a/test/services/change_now/change_now_test.mocks.dart b/test/services/change_now/change_now_test.mocks.dart index 4b92e7841a..018cc35bc1 100644 --- a/test/services/change_now/change_now_test.mocks.dart +++ b/test/services/change_now/change_now_test.mocks.dart @@ -43,12 +43,14 @@ class MockHTTP extends _i1.Mock implements _i2.HTTP { required Uri? url, Map? headers, required ({_i4.InternetAddress host, int port})? proxyInfo, + Duration? connectionTimeout, }) => (super.noSuchMethod( Invocation.method(#get, [], { #url: url, #headers: headers, #proxyInfo: proxyInfo, + #connectionTimeout: connectionTimeout, }), returnValue: _i3.Future<_i2.Response>.value( _FakeResponse_0( @@ -57,6 +59,7 @@ class MockHTTP extends _i1.Mock implements _i2.HTTP { #url: url, #headers: headers, #proxyInfo: proxyInfo, + #connectionTimeout: connectionTimeout, }), ), ), @@ -93,4 +96,113 @@ class MockHTTP extends _i1.Mock implements _i2.HTTP { ), ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> postBytes({ + required Uri? url, + Map? headers, + required List? bodyBytes, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#postBytes, [], { + #url: url, + #headers: headers, + #bodyBytes: bodyBytes, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#postBytes, [], { + #url: url, + #headers: headers, + #bodyBytes: bodyBytes, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> put({ + required Uri? url, + Map? headers, + Object? body, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#put, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#put, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> patch({ + required Uri? url, + Map? headers, + Object? body, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#patch, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#patch, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> delete({ + required Uri? url, + Map? headers, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#delete, [], { + #url: url, + #headers: headers, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#delete, [], { + #url: url, + #headers: headers, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); } diff --git a/test/services/coins/bitcoin/bitcoin_wallet_test.mocks.dart b/test/services/coins/bitcoin/bitcoin_wallet_test.mocks.dart index 7d1f9507c8..b9d265e2f8 100644 --- a/test/services/coins/bitcoin/bitcoin_wallet_test.mocks.dart +++ b/test/services/coins/bitcoin/bitcoin_wallet_test.mocks.dart @@ -328,6 +328,22 @@ class MockElectrumXClient extends _i1.Mock implements _i5.ElectrumXClient { ) as _i8.Future>); + @override + _i8.Future>> getBatchTransactions({ + required List? txHashes, + String? requestID, + }) => + (super.noSuchMethod( + Invocation.method(#getBatchTransactions, [], { + #txHashes: txHashes, + #requestID: requestID, + }), + returnValue: _i8.Future>>.value( + >[], + ), + ) + as _i8.Future>>); + @override _i8.Future> getLelantusAnonymitySet({ String? groupId = '1', @@ -661,6 +677,22 @@ class MockCachedElectrumXClient extends _i1.Mock ) as _i8.Future>); + @override + _i8.Future>> getBatchTransactions({ + required List? txHashes, + required _i2.CryptoCurrency? cryptoCurrency, + }) => + (super.noSuchMethod( + Invocation.method(#getBatchTransactions, [], { + #txHashes: txHashes, + #cryptoCurrency: cryptoCurrency, + }), + returnValue: _i8.Future>>.value( + >[], + ), + ) + as _i8.Future>>); + @override _i8.Future clearSharedTransactionCache({ required _i2.CryptoCurrency? cryptoCurrency, diff --git a/test/services/coins/bitcoincash/bitcoincash_wallet_test.mocks.dart b/test/services/coins/bitcoincash/bitcoincash_wallet_test.mocks.dart index a4e641ec33..a91186a54f 100644 --- a/test/services/coins/bitcoincash/bitcoincash_wallet_test.mocks.dart +++ b/test/services/coins/bitcoincash/bitcoincash_wallet_test.mocks.dart @@ -328,6 +328,22 @@ class MockElectrumXClient extends _i1.Mock implements _i5.ElectrumXClient { ) as _i8.Future>); + @override + _i8.Future>> getBatchTransactions({ + required List? txHashes, + String? requestID, + }) => + (super.noSuchMethod( + Invocation.method(#getBatchTransactions, [], { + #txHashes: txHashes, + #requestID: requestID, + }), + returnValue: _i8.Future>>.value( + >[], + ), + ) + as _i8.Future>>); + @override _i8.Future> getLelantusAnonymitySet({ String? groupId = '1', @@ -661,6 +677,22 @@ class MockCachedElectrumXClient extends _i1.Mock ) as _i8.Future>); + @override + _i8.Future>> getBatchTransactions({ + required List? txHashes, + required _i2.CryptoCurrency? cryptoCurrency, + }) => + (super.noSuchMethod( + Invocation.method(#getBatchTransactions, [], { + #txHashes: txHashes, + #cryptoCurrency: cryptoCurrency, + }), + returnValue: _i8.Future>>.value( + >[], + ), + ) + as _i8.Future>>); + @override _i8.Future clearSharedTransactionCache({ required _i2.CryptoCurrency? cryptoCurrency, diff --git a/test/services/coins/dogecoin/dogecoin_wallet_test.mocks.dart b/test/services/coins/dogecoin/dogecoin_wallet_test.mocks.dart index cf2da0eb7f..8fde902450 100644 --- a/test/services/coins/dogecoin/dogecoin_wallet_test.mocks.dart +++ b/test/services/coins/dogecoin/dogecoin_wallet_test.mocks.dart @@ -328,6 +328,22 @@ class MockElectrumXClient extends _i1.Mock implements _i5.ElectrumXClient { ) as _i8.Future>); + @override + _i8.Future>> getBatchTransactions({ + required List? txHashes, + String? requestID, + }) => + (super.noSuchMethod( + Invocation.method(#getBatchTransactions, [], { + #txHashes: txHashes, + #requestID: requestID, + }), + returnValue: _i8.Future>>.value( + >[], + ), + ) + as _i8.Future>>); + @override _i8.Future> getLelantusAnonymitySet({ String? groupId = '1', @@ -661,6 +677,22 @@ class MockCachedElectrumXClient extends _i1.Mock ) as _i8.Future>); + @override + _i8.Future>> getBatchTransactions({ + required List? txHashes, + required _i2.CryptoCurrency? cryptoCurrency, + }) => + (super.noSuchMethod( + Invocation.method(#getBatchTransactions, [], { + #txHashes: txHashes, + #cryptoCurrency: cryptoCurrency, + }), + returnValue: _i8.Future>>.value( + >[], + ), + ) + as _i8.Future>>); + @override _i8.Future clearSharedTransactionCache({ required _i2.CryptoCurrency? cryptoCurrency, diff --git a/test/services/coins/firo/firo_wallet_test.dart b/test/services/coins/firo/firo_wallet_test.dart index 3edbd52f11..22ef37197f 100644 --- a/test/services/coins/firo/firo_wallet_test.dart +++ b/test/services/coins/firo/firo_wallet_test.dart @@ -1,8 +1,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hive_ce/hive.dart'; -import 'package:hive_test/hive_test.dart'; import 'package:mockito/annotations.dart'; +import '../../../hive/hive_ce_test_utils.dart'; + @GenerateMocks([ // ElectrumXClient, // CachedElectrumXClient, @@ -329,7 +330,7 @@ void main() { const testWalletName = "Test Wallet"; setUp(() async { - await setUpTestHive(); + await setUpHiveCeTest(); final wallets = await Hive.openBox('wallets'); await wallets.put('currentWalletName', testWalletName); @@ -3015,7 +3016,7 @@ void main() { // }); // tearDown(() async { - await tearDownTestHive(); + await tearDownHiveCeTest(); }); }); diff --git a/test/services/coins/namecoin/namecoin_wallet_test.mocks.dart b/test/services/coins/namecoin/namecoin_wallet_test.mocks.dart index 103ca6b1d4..9ecb591912 100644 --- a/test/services/coins/namecoin/namecoin_wallet_test.mocks.dart +++ b/test/services/coins/namecoin/namecoin_wallet_test.mocks.dart @@ -328,6 +328,22 @@ class MockElectrumXClient extends _i1.Mock implements _i5.ElectrumXClient { ) as _i8.Future>); + @override + _i8.Future>> getBatchTransactions({ + required List? txHashes, + String? requestID, + }) => + (super.noSuchMethod( + Invocation.method(#getBatchTransactions, [], { + #txHashes: txHashes, + #requestID: requestID, + }), + returnValue: _i8.Future>>.value( + >[], + ), + ) + as _i8.Future>>); + @override _i8.Future> getLelantusAnonymitySet({ String? groupId = '1', @@ -661,6 +677,22 @@ class MockCachedElectrumXClient extends _i1.Mock ) as _i8.Future>); + @override + _i8.Future>> getBatchTransactions({ + required List? txHashes, + required _i2.CryptoCurrency? cryptoCurrency, + }) => + (super.noSuchMethod( + Invocation.method(#getBatchTransactions, [], { + #txHashes: txHashes, + #cryptoCurrency: cryptoCurrency, + }), + returnValue: _i8.Future>>.value( + >[], + ), + ) + as _i8.Future>>); + @override _i8.Future clearSharedTransactionCache({ required _i2.CryptoCurrency? cryptoCurrency, diff --git a/test/services/coins/particl/particl_wallet_test.mocks.dart b/test/services/coins/particl/particl_wallet_test.mocks.dart index 8c10019e4a..6929d60a42 100644 --- a/test/services/coins/particl/particl_wallet_test.mocks.dart +++ b/test/services/coins/particl/particl_wallet_test.mocks.dart @@ -328,6 +328,22 @@ class MockElectrumXClient extends _i1.Mock implements _i5.ElectrumXClient { ) as _i8.Future>); + @override + _i8.Future>> getBatchTransactions({ + required List? txHashes, + String? requestID, + }) => + (super.noSuchMethod( + Invocation.method(#getBatchTransactions, [], { + #txHashes: txHashes, + #requestID: requestID, + }), + returnValue: _i8.Future>>.value( + >[], + ), + ) + as _i8.Future>>); + @override _i8.Future> getLelantusAnonymitySet({ String? groupId = '1', @@ -661,6 +677,22 @@ class MockCachedElectrumXClient extends _i1.Mock ) as _i8.Future>); + @override + _i8.Future>> getBatchTransactions({ + required List? txHashes, + required _i2.CryptoCurrency? cryptoCurrency, + }) => + (super.noSuchMethod( + Invocation.method(#getBatchTransactions, [], { + #txHashes: txHashes, + #cryptoCurrency: cryptoCurrency, + }), + returnValue: _i8.Future>>.value( + >[], + ), + ) + as _i8.Future>>); + @override _i8.Future clearSharedTransactionCache({ required _i2.CryptoCurrency? cryptoCurrency, diff --git a/test/services/node_service_test.dart b/test/services/node_service_test.dart index efae567278..447cffb69f 100644 --- a/test/services/node_service_test.dart +++ b/test/services/node_service_test.dart @@ -1,8 +1,6 @@ // TODO MWC import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; -import 'package:hive_test/hive_test.dart'; import 'package:stackwallet/app_config.dart'; import 'package:stackwallet/db/hive/db.dart'; import 'package:stackwallet/models/node_model.dart'; @@ -10,16 +8,26 @@ import 'package:stackwallet/services/node_service.dart'; import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import '../hive/hive_ce_test_utils.dart'; + void main() { bool wasRegistered = false; + final expectedPrimaryDefaults = AppConfig.coins + .where((coin) => coin.identifier != 'firo') + .map((e) => e.defaultNode(isPrimary: true)) + .toList(growable: false); + final expectedDefaultNodeCount = + expectedPrimaryDefaults.length + + (AppConfig.coins.any((e) => e.identifier == 'firo') ? 4 : 0); + setUp(() async { - await setUpTestHive(); + await setUpHiveCeTest(); if (!wasRegistered) { wasRegistered = true; - Hive.registerAdapter(NodeModelAdapter()); + DB.instance.hive.registerAdapter(NodeModelAdapter()); } - await Hive.openBox(DB.boxNameNodeModels); - // await Hive.openBox(DB.boxNamePrimaryNodes); + await DB.instance.hive.openBox(DB.boxNameNodeModels); + // await DB.instance.hive.openBox(DB.boxNamePrimaryNodes); }); group("Empty nodes DB tests", () { @@ -115,10 +123,7 @@ void main() { final fakeStore = FakeSecureStorage(); final service = NodeService(secureStorageInterface: fakeStore); await service.updateDefaults(); - expect( - service.nodes.length, - AppConfig.coins.map((e) => e.defaultNode).length, - ); + expect(service.nodes.length, expectedDefaultNodeCount); expect(fakeStore.interactions, 0); }); }); @@ -177,10 +182,12 @@ void main() { final fakeStore = FakeSecureStorage(); final service = NodeService(secureStorageInterface: fakeStore); expect( - service.getPrimaryNodeFor( - currency: Bitcoin(CryptoCurrencyNetwork.main), - ), - null, + service + .getPrimaryNodeFor(currency: Bitcoin(CryptoCurrencyNetwork.main)) + ?.toString(), + Bitcoin( + CryptoCurrencyNetwork.main, + ).defaultNode(isPrimary: true).toString(), ); await service.setPrimaryNodeFor( coin: Bitcoin(CryptoCurrencyNetwork.main), @@ -190,7 +197,9 @@ void main() { service .getPrimaryNodeFor(currency: Bitcoin(CryptoCurrencyNetwork.main)) .toString(), - Bitcoin(CryptoCurrencyNetwork.main).defaultNode.toString(), + Bitcoin( + CryptoCurrencyNetwork.main, + ).defaultNode(isPrimary: true).toString(), ); expect(fakeStore.interactions, 0); }); @@ -206,13 +215,13 @@ void main() { coin: Monero(CryptoCurrencyNetwork.main), node: Monero(CryptoCurrencyNetwork.main).defaultNode(isPrimary: true), ); - expect( - service.primaryNodes.toString(), - [ - Bitcoin(CryptoCurrencyNetwork.main).defaultNode(isPrimary: true), - Monero(CryptoCurrencyNetwork.main).defaultNode(isPrimary: true), - ].toString(), - ); + final primaryNodes = service.primaryNodes; + final expectedPrimaryNodes = [...expectedPrimaryDefaults] + ..sort((a, b) => a.id.compareTo(b.id)); + primaryNodes.sort((a, b) => a.id.compareTo(b.id)); + + expect(primaryNodes.length, expectedPrimaryNodes.length); + expect(primaryNodes.toString(), expectedPrimaryNodes.toString()); expect(fakeStore.interactions, 0); }); @@ -220,15 +229,18 @@ void main() { final fakeStore = FakeSecureStorage(); final service = NodeService(secureStorageInterface: fakeStore); final nodes = service.nodes; - final defaults = AppConfig.coins - .map((e) => e.defaultNode(isPrimary: true)) - .toList(); - - nodes.sort((a, b) => a.id.compareTo(b.id)); - defaults.sort((a, b) => a.id.compareTo(b.id)); - - expect(nodes.length, defaults.length); - expect(nodes.toString(), defaults.toString()); + final defaultIds = expectedPrimaryDefaults.map((e) => e.id).toSet(); + final extraFiroIds = service.nodes + .where((node) => node.id.startsWith('not_a_real_default_but_temp_')) + .map((node) => node.id) + .toSet(); + + expect(nodes.length, expectedDefaultNodeCount); + expect(nodes.map((node) => node.id).toSet(), containsAll(defaultIds)); + expect( + extraFiroIds.length, + AppConfig.coins.any((e) => e.identifier == 'firo') ? 4 : 0, + ); expect(fakeStore.interactions, 0); }); @@ -236,21 +248,15 @@ void main() { final fakeStore = FakeSecureStorage(); final service = NodeService(secureStorageInterface: fakeStore); await service.save(nodeA, null, true); - expect( - service.nodes.length, - AppConfig.coins.map((e) => e.defaultNode).length + 1, - ); - expect(fakeStore.interactions, 0); + expect(service.nodes.length, expectedDefaultNodeCount + 1); + expect(fakeStore.interactions, 1); }); test("add a node with a password", () async { final fakeStore = FakeSecureStorage(); final service = NodeService(secureStorageInterface: fakeStore); await service.save(nodeA, "some password", true); - expect( - service.nodes.length, - AppConfig.coins.map((e) => e.defaultNode).length + 1, - ); + expect(service.nodes.length, expectedDefaultNodeCount + 1); expect(fakeStore.interactions, 1); expect(fakeStore.writes, 1); }); @@ -309,10 +315,7 @@ void main() { await service.delete(nodeB.id, true); - expect( - service.nodes.length, - AppConfig.coins.map((e) => e.defaultNode).length + 2, - ); + expect(service.nodes.length, expectedDefaultNodeCount + 2); expect( service.nodes.where((element) => element.id == nodeB.id).length, 0, @@ -341,6 +344,6 @@ void main() { }); tearDown(() async { - await tearDownTestHive(); + await tearDownHiveCeTest(); }); } diff --git a/test/services/paynym/paynym_is_api_test.dart b/test/services/paynym/paynym_is_api_test.dart new file mode 100644 index 0000000000..fa2e651a2e --- /dev/null +++ b/test/services/paynym/paynym_is_api_test.dart @@ -0,0 +1,245 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/networking/http.dart'; +import 'package:stackwallet/utilities/paynym_is_api.dart'; + +import 'paynym_is_api_test.mocks.dart'; + +@GenerateMocks([HTTP]) +void main() { + late PaynymIsApi api; + late MockHTTP client; + + setUp(() { + client = MockHTTP(); + api = PaynymIsApi(); + api.client = client; + }); + + void stubPost( + String endpoint, + String responseBody, + int statusCode, { + Map? extraHeaders, + }) { + when( + client.post( + url: Uri.parse('https://paynym.rs/api/v1$endpoint'), + headers: anyNamed('headers'), + proxyInfo: anyNamed('proxyInfo'), + body: anyNamed('body'), + encoding: anyNamed('encoding'), + ), + ).thenAnswer((_) async => Response(utf8.encode(responseBody), statusCode)); + } + + group('create', () { + test('400 with empty body returns typed error', () async { + stubPost('/create', '', 400); + final r = await api.create('PM8Ttest'); + expect(r.statusCode, 400); + expect(r.message, 'Bad request'); + expect(r.value, isNull); + }); + + test('201 with valid JSON returns CreatedPaynym', () async { + stubPost( + '/create', + '{"claimed":false,"nymID":"abc","nymName":"foo","segwit":true,"token":"tok"}', + 201, + ); + final r = await api.create('PM8Ttest'); + expect(r.statusCode, 201); + expect(r.message, 'PayNym created successfully'); + expect(r.value, isNotNull); + expect(r.value!.nymId, 'abc'); + }); + + test('200 returns existing PayNym', () async { + stubPost( + '/create', + '{"claimed":true,"nymID":"abc","nymName":"foo","segwit":true,"token":"tok"}', + 200, + ); + final r = await api.create('PM8Ttest'); + expect(r.statusCode, 200); + expect(r.message, 'PayNym already exists'); + expect(r.value, isNotNull); + }); + }); + + group('token', () { + test('404 with empty body returns typed error', () async { + stubPost('/token', '', 404); + final r = await api.token('PM8Ttest'); + expect(r.statusCode, 404); + expect(r.message, 'Payment code was not found'); + expect(r.value, isNull); + }); + + test('400 with empty body returns typed error', () async { + stubPost('/token', '', 400); + final r = await api.token('PM8Ttest'); + expect(r.statusCode, 400); + expect(r.message, 'Bad request'); + expect(r.value, isNull); + }); + + test('200 with valid JSON returns token string', () async { + stubPost('/token', '{"token":"testToken123"}', 200); + final r = await api.token('PM8Ttest'); + expect(r.statusCode, 200); + expect(r.message, 'Token was successfully updated'); + expect(r.value, 'testToken123'); + }); + }); + + group('nym', () { + test('404 with empty body returns typed error', () async { + stubPost('/nym', '', 404); + final r = await api.nym('PM8Ttest'); + expect(r.statusCode, 404); + expect(r.message, 'Nym not found'); + expect(r.value, isNull); + }); + + test('400 with empty body returns typed error', () async { + stubPost('/nym', '', 400); + final r = await api.nym('PM8Ttest'); + expect(r.statusCode, 400); + expect(r.message, 'Bad request'); + expect(r.value, isNull); + }); + + test('200 with valid JSON returns PaynymAccount', () async { + stubPost( + '/nym', + jsonEncode({ + 'nymID': 'testId', + 'nymName': 'testName', + 'segwit': true, + 'codes': [ + {'claimed': true, 'segwit': true, 'code': 'PM8Ttest'}, + ], + 'followers': >[], + 'following': >[], + }), + 200, + ); + final r = await api.nym('PM8Ttest'); + expect(r.statusCode, 200); + expect(r.message, 'Nym found and returned'); + expect(r.value, isNotNull); + expect(r.value!.nymID, 'testId'); + }); + }); + + group('claim', () { + test('400 with empty body returns typed error', () async { + stubPost('/claim', '', 400); + final r = await api.claim('tok', 'sig'); + expect(r.statusCode, 400); + expect(r.message, 'Bad request'); + expect(r.value, isNull); + }); + + test('200 with valid JSON returns PaynymClaim', () async { + stubPost('/claim', '{"claimed":"PM8Ttest","token":"newTok"}', 200); + final r = await api.claim('tok', 'sig'); + expect(r.statusCode, 200); + expect(r.message, 'Payment code successfully claimed'); + expect(r.value, isNotNull); + expect(r.value!.claimed, 'PM8Ttest'); + }); + }); + + group('follow', () { + test('404 with empty body returns typed error', () async { + stubPost('/follow', '', 404); + final r = await api.follow('tok', 'sig', 'target'); + expect(r.statusCode, 404); + expect(r.message, 'Payment code not found'); + expect(r.value, isNull); + }); + + test('401 with empty body returns typed error', () async { + stubPost('/follow', '', 401); + final r = await api.follow('tok', 'sig', 'target'); + expect(r.statusCode, 401); + expect( + r.message, + 'Unauthorized token or signature or Unclaimed payment code', + ); + expect(r.value, isNull); + }); + + test('400 with empty body returns typed error', () async { + stubPost('/follow', '', 400); + final r = await api.follow('tok', 'sig', 'target'); + expect(r.statusCode, 400); + expect(r.message, 'Bad request'); + expect(r.value, isNull); + }); + }); + + group('unfollow', () { + test('404 with empty body returns typed error', () async { + stubPost('/unfollow', '', 404); + final r = await api.unfollow('tok', 'sig', 'target'); + expect(r.statusCode, 404); + expect(r.message, 'Payment code not found'); + expect(r.value, isNull); + }); + + test('401 with empty body returns typed error', () async { + stubPost('/unfollow', '', 401); + final r = await api.unfollow('tok', 'sig', 'target'); + expect(r.statusCode, 401); + expect( + r.message, + 'Unauthorized token or signature or Unclaimed payment code', + ); + expect(r.value, isNull); + }); + + test('400 with empty body returns typed error', () async { + stubPost('/unfollow', '', 400); + final r = await api.unfollow('tok', 'sig', 'target'); + expect(r.statusCode, 400); + expect(r.message, 'Bad request'); + expect(r.value, isNull); + }); + }); + + group('add', () { + test('400 with empty body returns typed error', () async { + stubPost('/nym/add', '', 400); + final r = await api.add('tok', 'sig', 'nym', 'code'); + expect(r.statusCode, 400); + expect(r.message, 'Bad request'); + expect(r.value, false); + }); + + test('401 with empty body returns typed error', () async { + stubPost('/nym/add', '', 401); + final r = await api.add('tok', 'sig', 'nym', 'code'); + expect(r.statusCode, 401); + expect( + r.message, + 'Unauthorized token or signature or Unclaimed payment code', + ); + expect(r.value, false); + }); + + test('404 with empty body returns typed error', () async { + stubPost('/nym/add', '', 404); + final r = await api.add('tok', 'sig', 'nym', 'code'); + expect(r.statusCode, 404); + expect(r.message, 'Nym not found'); + expect(r.value, false); + }); + }); +} diff --git a/test/services/paynym/paynym_is_api_test.mocks.dart b/test/services/paynym/paynym_is_api_test.mocks.dart new file mode 100644 index 0000000000..80e427fb3b --- /dev/null +++ b/test/services/paynym/paynym_is_api_test.mocks.dart @@ -0,0 +1,208 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in stackwallet/test/services/paynym/paynym_is_api_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; +import 'dart:convert' as _i5; +import 'dart:io' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:stackwallet/networking/http.dart' as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { + _FakeResponse_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [HTTP]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockHTTP extends _i1.Mock implements _i2.HTTP { + MockHTTP() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future<_i2.Response> get({ + required Uri? url, + Map? headers, + required ({_i4.InternetAddress host, int port})? proxyInfo, + Duration? connectionTimeout, + }) => + (super.noSuchMethod( + Invocation.method(#get, [], { + #url: url, + #headers: headers, + #proxyInfo: proxyInfo, + #connectionTimeout: connectionTimeout, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#get, [], { + #url: url, + #headers: headers, + #proxyInfo: proxyInfo, + #connectionTimeout: connectionTimeout, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> post({ + required Uri? url, + Map? headers, + Object? body, + _i5.Encoding? encoding, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#post, [], { + #url: url, + #headers: headers, + #body: body, + #encoding: encoding, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#post, [], { + #url: url, + #headers: headers, + #body: body, + #encoding: encoding, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> postBytes({ + required Uri? url, + Map? headers, + required List? bodyBytes, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#postBytes, [], { + #url: url, + #headers: headers, + #bodyBytes: bodyBytes, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#postBytes, [], { + #url: url, + #headers: headers, + #bodyBytes: bodyBytes, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> put({ + required Uri? url, + Map? headers, + Object? body, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#put, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#put, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> patch({ + required Uri? url, + Map? headers, + Object? body, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#patch, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#patch, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> delete({ + required Uri? url, + Map? headers, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#delete, [], { + #url: url, + #headers: headers, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#delete, [], { + #url: url, + #headers: headers, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); +} diff --git a/test/utilities/dynamic_object_test.dart b/test/utilities/dynamic_object_test.dart new file mode 100644 index 0000000000..023e71e8e7 --- /dev/null +++ b/test/utilities/dynamic_object_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/dynamic_object.dart'; + +void main() { + test("DynamicObject get success", () { + final object = DynamicObject(1); + expect(object.get(), isA()); + }); + + test("DynamicObject get failure", () { + final object = DynamicObject(1); + expect( + () => object.get(), + throwsA(isA()), + ); + }); + test("DynamicObject get if match success", () { + final object = DynamicObject(1); + expect(object.getIfMatch(), isA()); + }); + + test("DynamicObject get if match failure", () { + final object = DynamicObject(1); + expect(object.getIfMatch(), isNull); + }); +} diff --git a/test/utilities/electrum_seed_utils_test.dart b/test/utilities/electrum_seed_utils_test.dart new file mode 100644 index 0000000000..e9d06a4e6b --- /dev/null +++ b/test/utilities/electrum_seed_utils_test.dart @@ -0,0 +1,307 @@ +import 'package:coinlib_flutter/coinlib_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/electrum_seed_utils.dart'; +import 'package:stackwallet/utilities/extensions/extensions.dart'; + +class _TestCase { + final String words, bip32Seed, seedVersion; + final String? lang, wordsHex, passphrase, passphraseHex; + + const _TestCase({ + required this.words, + required this.bip32Seed, + this.seedVersion = ElectrumSeedUtils.kSeedPrefix, + this.lang, + this.wordsHex, + this.passphrase, + this.passphraseHex, + }); +} + +// horror data sourced from https://github.com/spesmilo/electrum/blob/master/tests/test_wallet_vertical.py#L31-L33 +const kUnicodeHorror = + "₿ 😀 😈 う けたま わる w͢͢͝h͡o͢͡ ̸͢k̵͟n̴͘ǫw̸̛s͘ ̀́w͘͢ḩ̵a҉̡͢t ̧̕h́o̵r͏̵rors̡ ̶͡͠lį̶e͟͟ ̶͝in͢ ͏t̕h̷̡͟e ͟͟d̛a͜r̕͡k̢̨ ͡h̴e͏a̷̢̡rt́͏ ̴̷͠ò̵̶f̸ u̧͘ní̛͜c͢͏o̷͏d̸͢e̡͝?͞"; +const kUnicodeHorrorHex = + "e282bf20f09f988020f09f98882020202020e3818620e38191e3819fe381be20e3828fe382" + "8b2077cda2cda2cd9d68cda16fcda2cda120ccb8cda26bccb5cd9f6eccb4cd98c7ab77ccb8" + "cc9b73cd9820cc80cc8177cd98cda2e1b8a9ccb561d289cca1cda27420cca7cc9568cc816f" + "ccb572cd8fccb5726f7273cca120ccb6cda1cda06cc4afccb665cd9fcd9f20ccb6cd9d696e" + "cda220cd8f74cc9568ccb7cca1cd9f6520cd9fcd9f64cc9b61cd9c72cc95cda16bcca2cca8" + "20cda168ccb465cd8f61ccb7cca2cca17274cc81cd8f20ccb4ccb7cda0c3b2ccb5ccb666cc" + "b82075cca7cd986ec3adcc9bcd9c63cda2cd8f6fccb7cd8f64ccb8cda265cca1cd9d3fcd9e"; + +// test cases sourced from https://github.com/spesmilo/electrum/blob/master/tests/test_mnemonic.py +const kTestCases = { + "english": _TestCase( + words: + "wild father tree among universe such" + " mobile favorite target dynamic credit identify", + seedVersion: ElectrumSeedUtils.kSeedPrefixSegwit, + bip32Seed: + "aac2a6302e48577ab4b46f23dbae0774e2e62c796f797d0a1b5faeb528301e3064342d" + "afb79069e7c4c6b8c38ae11d7a973bec0d4f70626f8cc5184a8d0b0756", + ), + "english_with_passphrase": _TestCase( + words: + "wild father tree among universe such" + " mobile favorite target dynamic credit identify", + seedVersion: ElectrumSeedUtils.kSeedPrefixSegwit, + passphrase: "Did you ever hear the tragedy of Darth Plagueis the Wise?", + bip32Seed: + "4aa29f2aeb0127efb55138ab9e7be83b36750358751906f86c662b21a1ea1370f949e6" + "d1a12fa56d3d93cadda93038c76ac8118597364e46f5156fde6183c82f", + ), + "japanese": _TestCase( + lang: "ja", + words: "なのか ひろい しなん まなぶ つぶす さがす おしゃれ かわく おいかける けさき かいとう さたん", + wordsHex: + "e381aae381aee3818b20e381b2e3828de3818420e38197e381aae3829320e381bee381" + "aae381b5e3829920e381a4e381b5e38299e3819920e38195e3818be38299e38199" + "20e3818ae38197e38283e3828c20e3818be3828fe3818f20e3818ae38184e3818b" + "e38191e3828b20e38191e38195e3818d20e3818be38184e381a8e3818620e38195" + "e3819fe38293", + bip32Seed: + "d3eaf0e44ddae3a5769cb08a26918e8b308258bcb057bb704c6f69713245c0b35cb92c" + "03df9c9ece5eff826091b4e74041e010b701d44d610976ce8bfb66a8ad", + ), + "japanese_with_passphrase": _TestCase( + lang: "ja", + words: "なのか ひろい しなん まなぶ つぶす さがす おしゃれ かわく おいかける けさき かいとう さたん", + wordsHex: + "e381aae381aee3818b20e381b2e3828de3818420e38197e381aae3829320e381bee381" + "aae381b5e3829920e381a4e381b5e38299e3819920e38195e3818be38299e38199" + "20e3818ae38197e38283e3828c20e3818be3828fe3818f20e3818ae38184e3818b" + "e38191e3828b20e38191e38195e3818d20e3818be38184e381a8e3818620e38195" + "e3819fe38293", + passphrase: kUnicodeHorror, + passphraseHex: kUnicodeHorrorHex, + bip32Seed: + "251ee6b45b38ba0849e8f40794540f7e2c6d9d604c31d68d3ac50c034f8b64e4bc037c" + "5e1e985a2fed8aad23560e690b03b120daf2e84dceb1d7857dda042457", + ), + "chinese": _TestCase( + lang: "zh", + words: "眼 悲 叛 改 节 跃 衡 响 疆 股 遂 冬", + wordsHex: + "e79cbc20e682b220e58f9b20e694b920e88a8220e8b78320e8a1a120e5938d20e79686" + "20e882a120e9818220e586ac", + seedVersion: ElectrumSeedUtils.kSeedPrefixSegwit, + bip32Seed: + "0b9077db7b5a50dbb6f61821e2d35e255068a5847e221138048a20e12d80b673ce306b" + "6fe7ac174ebc6751e11b7037be6ee9f17db8040bb44f8466d519ce2abf", + ), + "chinese_with_passphrase": _TestCase( + lang: "zh", + words: "眼 悲 叛 改 节 跃 衡 响 疆 股 遂 冬", + wordsHex: + "e79cbc20e682b220e58f9b20e694b920e88a8220e8b78320e8a1a120e5938d20e79686" + "20e882a120e9818220e586ac", + seedVersion: ElectrumSeedUtils.kSeedPrefixSegwit, + passphrase: "给我一些测试向量谷歌", + passphraseHex: + "e7bb99e68891e4b880e4ba9be6b58be8af95e59091e9878fe8b0b7e6ad8c", + bip32Seed: + "6c03dd0615cf59963620c0af6840b52e867468cc64f20a1f4c8155705738e87b8edb0f" + "c8a6cee4085776cb3a629ff88bb1a38f37085efdbf11ce9ec5a7fa5f71", + ), + "spanish": _TestCase( + lang: "es", + words: + "almíbar tibio superar vencer hacha peatón" + " príncipe matar consejo polen vehículo odisea", + wordsHex: + "616c6d69cc8162617220746962696f20737570657261722076656e6365722068616368" + "6120706561746fcc816e20707269cc816e63697065206d6174617220636f6e7365" + "6a6f20706f6c656e2076656869cc8163756c6f206f6469736561", + bip32Seed: + "18bffd573a960cc775bbd80ed60b7dc00bc8796a186edebe7fc7cf1f316da0fe937852" + "a969c5c79ded8255cdf54409537a16339fbe33fb9161af793ea47faa7a", + ), + "spanish_with_passphrase": _TestCase( + lang: "es", + words: + "almíbar tibio superar vencer hacha peatón " + "príncipe matar consejo polen vehículo odisea", + wordsHex: + "616c6d69cc8162617220746962696f20737570657261722076656e6365722068616368" + "6120706561746fcc816e20707269cc816e63697065206d6174617220636f6e73656a6f" + "20706f6c656e2076656869cc8163756c6f206f6469736561", + passphrase: "araña difícil solución término cárcel", + passphraseHex: + "6172616ecc83612064696669cc8163696c20736f6c7563696fcc816e207465cc81726d" + "696e6f206361cc817263656c", + bip32Seed: + "363dec0e575b887cfccebee4c84fca5a3a6bed9d0e099c061fa6b85020b031f8fe3636" + "d9af187bf432d451273c625e20f24f651ada41aae2c4ea62d87e9fa44c", + ), + "spanish2": _TestCase( + lang: "es", + words: + "equipo fiar auge langosta hacha calor " + "trance cubrir carro pulmón oro áspero", + wordsHex: + "65717569706f20666961722061756765206c616e676f7374612068616368612063616c" + "6f72207472616e63652063756272697220636172726f2070756c6d6fcc816e206f" + "726f2061cc81737065726f", + seedVersion: ElectrumSeedUtils.kSeedPrefixSegwit, + bip32Seed: + "001ebce6bfde5851f28a0d44aae5ae0c762b600daf3b33fc8fc630aee0d207646b6f98" + "b18e17dfe3be0a5efe2753c7cdad95860adbbb62cecad4dedb88e02a64", + ), + "spanish3": _TestCase( + lang: "es", + words: + "vidrio jabón muestra pájaro capucha" + " eludir feliz rotar fogata pez rezar oír", + wordsHex: + "76696472696f206a61626fcc816e206d756573747261207061cc816a61726f20636170" + "7563686120656c756469722066656c697a20726f74617220666f67617461207065" + "7a2072657a6172206f69cc8172", + seedVersion: ElectrumSeedUtils.kSeedPrefixSegwit, + passphrase: + "¡Viva España! repiten veinte pueblos y al hablar dan fe " + "del ánimo español... ¡Marquen arado martillo y clarín", + passphraseHex: + "c2a1566976612045737061c3b16121207265706974656e207665696e74652070756562" + "6c6f73207920616c206861626c61722064616e2066652064656c20c3a16e696d6f" + "2065737061c3b16f6c2e2e2e20c2a14d61727175656e20617261646f206d617274" + "696c6c6f207920636c6172c3ad6e", + bip32Seed: + "c274665e5453c72f82b8444e293e048d700c59bf000cacfba597629d202dcf3aab1cf9" + "c00ba8d3456b7943428541fed714d01d8a0a4028fc3a9bb33d981cb49f", + ), +}; + +void main() { + const kElectrumMnemonic = + "party reward jealous build maze tunnel eternal candy recipe february kid animal"; + + test( + "standard seed prefix", + () => expect(ElectrumSeedUtils.kSeedPrefix, "01"), + ); + test( + "segwit seed prefix", + () => expect(ElectrumSeedUtils.kSeedPrefixSegwit, "100"), + ); + test( + "2fa standard seed prefix", + () => expect(ElectrumSeedUtils.kSeedPrefix2fa, "101"), + ); + test( + "2fa segwit seed prefix", + () => expect(ElectrumSeedUtils.kSeedPrefix2faSegwit, "102"), + ); + + group("electrum mnemonic to seed tests", () { + for (final entry in kTestCases.entries) { + final name = entry.key; + final testCase = entry.value; + + if (testCase.wordsHex != null) { + test("$name: mnemonic to bytes to hex", () { + expect(testCase.wordsHex, testCase.words.toUint8ListFromUtf8.toHex); + }); + } + + if (testCase.passphraseHex != null && testCase.passphrase != null) { + test("$name: passphrase to bytes to hex", () { + expect( + testCase.passphraseHex, + testCase.passphrase!.toUint8ListFromUtf8.toHex, + ); + }); + } + + test("$name: isNewSeed", () { + expect( + ElectrumSeedUtils.isNewSeed( + testCase.words, + prefix: testCase.seedVersion, + ), + true, + ); + }); + test("$name: electrumMnemonicToSeedBytes", () { + expect( + ElectrumSeedUtils.electrumMnemonicToSeedBytes( + testCase.words, + passphrase: testCase.passphrase ?? "", + ).toHex, + testCase.bip32Seed, + ); + }); + } + }); + + test("test segwit version", () async { + expect( + ElectrumSeedUtils.electrumMnemonicVersion(kElectrumMnemonic), + ElectrumSeedUtils.kSeedPrefixSegwit, + ); + }); + + group( + "test group requires coinlib", + () { + setUpAll(() => loadCoinlib()); + + test("test master electrum fingerprint", () async { + final bytes = ElectrumSeedUtils.electrumMnemonicToSeedBytes( + kElectrumMnemonic, + ); + final hd = HDPrivateKey.fromSeed(bytes); + expect(BigInt.from(hd.fingerprint).toHex, "ec8d82aa"); + }); + + test("test root zpub", () async { + final bytes = ElectrumSeedUtils.electrumMnemonicToSeedBytes( + kElectrumMnemonic, + ); + final hd = HDPrivateKey.fromSeed(bytes); + final master = hd.derivePath("m/0'"); + + const zpubHDVersion = + 0x04b24746; // https://github.com/satoshilabs/slips/blob/master/slip-0132.md + expect( + master.hdPublicKey.encode(zpubHDVersion), + "zpub6oHsSqJH7vSzDJTFB8NR4YpzFU13XRmkJaVW9jQTePrnf5BPHHAQXxBMiBot12Z7DqfuTykmyPxGowrQfNa7M8xiAdEvQG47V5jhx5Tk158", + ); + }); + + test("test first receiving address", () async { + final bytes = ElectrumSeedUtils.electrumMnemonicToSeedBytes( + kElectrumMnemonic, + ); + final hd = HDPrivateKey.fromSeed(bytes); + final master = hd.derivePath("m/0'"); + + expect( + P2WPKHAddress.fromHash( + hash160(master.derivePath("0/0").publicKey.data), + hrp: "bc", + ).toString(), + "bc1qgfjuzurxzhl9vdalmjgw68s680lj5q933k37h5", + ); + }); + + test("test 9th change address", () async { + final bytes = ElectrumSeedUtils.electrumMnemonicToSeedBytes( + kElectrumMnemonic, + ); + final hd = HDPrivateKey.fromSeed(bytes); + final master = hd.derivePath("m/0'"); + + expect( + P2WPKHAddress.fromHash( + hash160(master.derivePath("1/8").publicKey.data), + hrp: "bc", + ).toString(), + "bc1qzz0mvhza5sdd2fy77klh3w8h5z238avztvqjdx", + ); + }); + }, + skip: + "Requires build/libsecp256k1.so for coinlib-backed derivation checks on Ubuntu; pure-Dart Electrum seed coverage remains active.", + ); +} diff --git a/test/utilities/mock_electrum_server.dart b/test/utilities/mock_electrum_server.dart new file mode 100644 index 0000000000..6802c0cec6 --- /dev/null +++ b/test/utilities/mock_electrum_server.dart @@ -0,0 +1,197 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:electrum_adapter/electrum_adapter.dart'; +import 'package:event_bus/event_bus.dart'; +import 'package:json_rpc_2/json_rpc_2.dart' as rpc; +import 'package:stackwallet/app_config.dart'; +import 'package:stackwallet/electrumx_rpc/client_manager.dart'; +import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; +import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; +import 'package:stackwallet/services/tor_service.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; +import 'package:stackwallet/wallets/crypto_currency/coins/firo.dart'; +import 'package:stream_channel/stream_channel.dart'; + +typedef MockElectrumHandler = FutureOr Function(List params); +typedef MockElectrumRequest = ({String method, List params}); + +class MockElectrumServer { + MockElectrumServer({ + Map handlers = const {}, + BlockHeader? initialHeader, + this.host = 'mock.electrum', + this.port = 50002, + this.useSSL = true, + }) : _handlers = Map.from(handlers), + _latestHeader = initialHeader ?? BlockHeader('00', 1) { + _handlers.putIfAbsent( + 'blockchain.headers.subscribe', + () => + (_) => {'hex': _latestHeader.hex, 'height': _latestHeader.height}, + ); + } + + final String host; + final int port; + final bool useSSL; + final Map _handlers; + final List requests = []; + final List _peers = []; + BlockHeader _latestHeader; + + Future createElectrumClient({ + ({InternetAddress host, int port})? proxyInfo, + }) async { + final channel = StreamChannelController(); + final peer = rpc.Peer.withoutJson( + channel.foreign, + onUnhandledError: (_, __) {}, + ); + _registerHandlers(peer); + unawaited(peer.listen()); + _peers.add(peer); + + return ElectrumClient(channel.local, host, port, useSSL, proxyInfo); + } + + Future createFiroElectrumClient({ + ({InternetAddress host, int port})? proxyInfo, + }) async { + final channel = StreamChannelController(); + final peer = rpc.Peer.withoutJson( + channel.foreign, + onUnhandledError: (_, __) {}, + ); + _registerHandlers(peer); + unawaited(peer.listen()); + _peers.add(peer); + + return FiroElectrumClient(channel.local, host, port, useSSL, proxyInfo); + } + + void _registerHandlers(rpc.Peer peer) { + for (final entry in _handlers.entries) { + peer.registerMethod(entry.key, (rpc.Parameters params) async { + final args = _paramsAsList(params); + requests.add((method: entry.key, params: args)); + return await entry.value(args); + }); + } + } + + List _paramsAsList(rpc.Parameters params) { + try { + return List.from(params.asList); + } catch (_) { + return const []; + } + } + + int requestCount(String method) => + requests.where((request) => request.method == method).length; + + Future emitHeader(BlockHeader header) async { + _latestHeader = header; + for (final peer in _peers) { + peer.sendNotification('blockchain.headers.subscribe', [ + {'hex': header.hex, 'height': header.height}, + ]); + } + } + + Future close() async { + for (final peer in _peers) { + await peer.close(); + } + _peers.clear(); + } +} + +class ManagedElectrumXClient extends ElectrumXClient { + ManagedElectrumXClient({ + required super.host, + required super.port, + required super.useSSL, + required Prefs prefs, + required TorService torService, + required super.failovers, + required super.cryptoCurrency, + required super.netType, + required this.clearServer, + this.torServer, + EventBus? globalEventBusForTesting, + }) : _prefsForTest = prefs, + _torServiceForTest = torService, + super( + prefs: prefs, + torService: torService, + globalEventBusForTesting: globalEventBusForTesting, + ); + + final Prefs _prefsForTest; + final TorService _torServiceForTest; + final MockElectrumServer clearServer; + final MockElectrumServer? torServer; + + @override + Future checkElectrumAdapter() async { + ({InternetAddress host, int port})? proxyInfo; + + if (AppConfig.hasFeature(AppFeature.tor)) { + if (_prefsForTest.useTor) { + if (_torServiceForTest.status != TorConnectionStatus.connected) { + if (_prefsForTest.torKillSwitch) { + throw Exception( + 'Tor preference and killswitch set but Tor is not enabled, ' + 'not connecting to Electrum adapter', + ); + } + } else { + proxyInfo = _torServiceForTest.getProxyInfo(); + } + + if (netType == TorPlainNetworkOption.clear) { + await (await ClientManager.sharedInstance.remove( + cryptoCurrency: cryptoCurrency, + )).$1?.close(); + } + } else if (netType == TorPlainNetworkOption.tor) { + await (await ClientManager.sharedInstance.remove( + cryptoCurrency: cryptoCurrency, + )).$1?.close(); + } + } + + final existing = getElectrumAdapter(); + if (existing != null && !existing.peer.isClosed) { + return; + } + if (existing != null) { + await (await ClientManager.sharedInstance.remove( + cryptoCurrency: cryptoCurrency, + )).$1?.close(); + } + + final server = proxyInfo != null ? (torServer ?? clearServer) : clearServer; + final adapter = cryptoCurrency is Firo + ? await server.createFiroElectrumClient(proxyInfo: proxyInfo) + : await server.createElectrumClient(proxyInfo: proxyInfo); + + await ClientManager.sharedInstance.addClient( + adapter, + cryptoCurrency: cryptoCurrency, + netType: netType, + ); + } +} + +Future tearDownManagedElectrum({ + Iterable servers = const [], +}) async { + await ClientManager.sharedInstance.closeAll(); + for (final server in servers) { + await server.close(); + } +} diff --git a/test/wallets/firo_transaction_type_test.dart b/test/wallets/firo_transaction_type_test.dart new file mode 100644 index 0000000000..89abfa3f03 --- /dev/null +++ b/test/wallets/firo_transaction_type_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:paymint/wallets/wallet/impl/firo_transaction_type.dart'; + +void main() { + test('recognizes Spark spend transaction types', () { + expect(isSparkSpendTransaction({'version': 3, 'type': 9}), isTrue); + expect(isSparkSpendTransaction({'version': 3, 'type': 11}), isTrue); + expect(isSparkSpendTransaction({'version': 3, 'type': 10}), isFalse); + expect(isSparkSpendTransaction({'version': 2, 'type': 11}), isFalse); + }); +} diff --git a/test/wallets/spark_name_fee_test.dart b/test/wallets/spark_name_fee_test.dart new file mode 100644 index 0000000000..331cc6a884 --- /dev/null +++ b/test/wallets/spark_name_fee_test.dart @@ -0,0 +1,102 @@ +import 'dart:typed_data'; + +import 'package:flutter_libsparkmobile/flutter_libsparkmobile.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; +import 'package:stackwallet/wl_gen/interfaces/lib_spark_interface.dart'; + +void main() { + test('Spark Name validation rejects underscores before construction', () { + final pattern = RegExp(kNameRegexString); + expect(pattern.hasMatch('NAME-FOR.TESTING'), isTrue); + expect(pattern.hasMatch('NAME_FOR_TESTING'), isFalse); + }); + + test('Spark Name fee output includes the name and address tag', () { + final baseScript = Uint8List(25); + final feeScript = sparkNameFeeScript( + baseScript: baseScript, + name: 'alice', + sparkAddress: List.filled(144, 'a').join(), + ); + + expect(feeScript.length - baseScript.length, 155); + expect(feeScript.length, 180); + expect(feeScript[25], OP_SPARKNAMEID); + expect(feeScript[32], OP_DROP); + expect(feeScript.last, OP_DROP); + }); + + test('Spark Name payments never have the miner fee subtracted', () { + expect( + shouldSubtractSparkFeeFromAmount( + isSparkNameRegistration: true, + spendsAll: true, + ), + isFalse, + ); + expect( + shouldSubtractSparkFeeFromAmount( + isSparkNameRegistration: false, + spendsAll: true, + ), + isTrue, + ); + }); + + group('Spark H2 activation', () { + test('mainnet uses V1 before the activation block', () { + final version = sparkSpendVersionForNextBlock( + network: CryptoCurrencyNetwork.main, + nextBlockHeight: 1370999, + ); + + expect(version, LibSparkSpendVersion.chaumV1); + expect(version.allowsMultipleInputs, isFalse); + expect(version.transactionVersion, 3 | (9 << 16)); + }); + + test('mainnet uses V2 at activation and later', () { + for (final nextBlockHeight in [1371000, 1371001]) { + final version = sparkSpendVersionForNextBlock( + network: CryptoCurrencyNetwork.main, + nextBlockHeight: nextBlockHeight, + ); + + expect(version, LibSparkSpendVersion.chaumV2); + expect(version.allowsMultipleInputs, isTrue); + expect(version.transactionVersion, 3 | (11 << 16)); + } + }); + + test('non-mainnet networks remain on V1', () { + for (final network in CryptoCurrencyNetwork.values.where( + (network) => network != CryptoCurrencyNetwork.main, + )) { + expect( + sparkSpendVersionForNextBlock( + network: network, + nextBlockHeight: 1371000, + ), + LibSparkSpendVersion.chaumV1, + ); + } + }); + + test('only the Chaum V2 transaction version permits multiple inputs', () { + expect( + isChaumV2SparkTransactionVersion( + LibSparkSpendVersion.chaumV1.transactionVersion, + ), + isFalse, + ); + expect( + isChaumV2SparkTransactionVersion( + LibSparkSpendVersion.chaumV2.transactionVersion, + ), + isTrue, + ); + }); + }); +} diff --git a/test/wallets/spark_spend_planner_test.dart b/test/wallets/spark_spend_planner_test.dart new file mode 100644 index 0000000000..83a09d7f0a --- /dev/null +++ b/test/wallets/spark_spend_planner_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/spark_spend_planner.dart'; + +void main() { + test('splits a payment into one plan per Spark coin', () async { + final plans = await planSingleInputSparkSpends( + coinValues: [BigInt.from(7000), BigInt.from(5000)], + recipients: [ + SparkSpendRecipientRequest( + type: SparkSpendRecipientType.private, + index: 0, + amount: BigInt.from(9000), + ), + ], + estimateFee: + ({ + required privateRecipientCount, + required transparentRecipientCount, + }) async => BigInt.from(1000), + maxTransparentAmount: BigInt.from(50000), + maxPrivateRecipients: 14, + maxTransactions: 50, + maxTransactionWeight: 1000000, + ); + + expect(plans.map((e) => e.coinIndex), [0, 1]); + expect(plans.map((e) => e.recipients.single.amount), [ + BigInt.from(6000), + BigInt.from(3000), + ]); + expect(plans.map((e) => e.fee), [BigInt.from(1000), BigInt.from(1000)]); + }); + + test('applies the transparent limit to each transaction', () async { + final plans = await planSingleInputSparkSpends( + coinValues: [BigInt.from(7000), BigInt.from(7000)], + recipients: [ + SparkSpendRecipientRequest( + type: SparkSpendRecipientType.transparent, + index: 0, + amount: BigInt.from(6000), + ), + ], + estimateFee: + ({ + required privateRecipientCount, + required transparentRecipientCount, + }) async => BigInt.from(1000), + maxTransparentAmount: BigInt.from(4000), + maxPrivateRecipients: 14, + maxTransactions: 50, + maxTransactionWeight: 1000000, + ); + + expect(plans.map((e) => e.recipients.single.amount), [ + BigInt.from(4000), + BigInt.from(2000), + ]); + }); +} diff --git a/test/widget_tests/managed_favorite_test.mocks.dart b/test/widget_tests/managed_favorite_test.mocks.dart index 4cbe4bf179..f354ad9329 100644 --- a/test/widget_tests/managed_favorite_test.mocks.dart +++ b/test/widget_tests/managed_favorite_test.mocks.dart @@ -11,6 +11,7 @@ import 'package:logger/logger.dart' as _i19; import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/src/dummies.dart' as _i17; import 'package:stackwallet/db/isar/main_db.dart' as _i3; +import 'package:stackwallet/models/epicbox_server_model.dart' as _i24; import 'package:stackwallet/models/isar/stack_theme.dart' as _i14; import 'package:stackwallet/models/node_model.dart' as _i23; import 'package:stackwallet/networking/http.dart' as _i6; @@ -551,6 +552,19 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -780,6 +794,18 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) @@ -795,13 +821,12 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { as _i10.Future); @override - _i10.Future incrementCurrentNotificationIndex() => + _i10.Future incrementCurrentNotificationIndex() => (super.noSuchMethod( Invocation.method(#incrementCurrentNotificationIndex, []), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), + returnValue: _i10.Future.value(0), ) - as _i10.Future); + as _i10.Future); @override _i10.Future isExternalCallsSet() => @@ -1107,6 +1132,64 @@ class MockNodeService extends _i1.Mock implements _i2.NodeService { ) as _i10.Future); + @override + _i10.Future updateDefaultEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#updateDefaultEpicBoxes, []), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); + + @override + _i10.Future setPrimaryEpicBox({ + required _i24.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners = false, + }) => + (super.noSuchMethod( + Invocation.method(#setPrimaryEpicBox, [], { + #epicBox: epicBox, + #shouldNotifyListeners: shouldNotifyListeners, + }), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); + + @override + List<_i24.EpicBoxServerModel> getEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#getEpicBoxes, []), + returnValue: <_i24.EpicBoxServerModel>[], + ) + as List<_i24.EpicBoxServerModel>); + + @override + _i24.EpicBoxServerModel? getEpicBoxById({required String? id}) => + (super.noSuchMethod(Invocation.method(#getEpicBoxById, [], {#id: id})) + as _i24.EpicBoxServerModel?); + + @override + _i10.Future addEpicBox( + _i24.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners, + ) => + (super.noSuchMethod( + Invocation.method(#addEpicBox, [epicBox, shouldNotifyListeners]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); + + @override + _i10.Future deleteEpicBox(String? id, bool? shouldNotifyListeners) => + (super.noSuchMethod( + Invocation.method(#deleteEpicBox, [id, shouldNotifyListeners]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); + @override _i10.Future updateCommunityNodes() => (super.noSuchMethod( diff --git a/test/widget_tests/node_card_test.dart b/test/widget_tests/node_card_test.dart index 71a08f8514..f159dab708 100644 --- a/test/widget_tests/node_card_test.dart +++ b/test/widget_tests/node_card_test.dart @@ -11,246 +11,135 @@ import 'package:stackwallet/services/node_service.dart'; import 'package:stackwallet/themes/stack_colors.dart'; import 'package:stackwallet/utilities/util.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart'; import 'package:stackwallet/widgets/node_card.dart'; import 'package:stackwallet/widgets/node_options_sheet.dart'; import '../sample_data/theme_json.dart'; import 'node_card_test.mocks.dart'; +import 'support/platform_test_overrides.dart'; @GenerateMocks([NodeService]) void main() { - testWidgets("NodeCard builds inactive node correctly", (tester) async { - final nodeService = MockNodeService(); - - when( - nodeService.getPrimaryNodeFor( - currency: Bitcoin(CryptoCurrencyNetwork.main), - ), - ).thenAnswer( - (realInvocation) => NodeModel( - host: "127.0.0.1", - port: 2000, - name: "Stack Default", - id: "node id", - useSSL: true, - enabled: true, - coinName: "Bitcoin", - isFailover: false, - isDown: false, - torEnabled: true, - clearnetEnabled: true, - isPrimary: true, - ), + final bitcoin = Bitcoin(CryptoCurrencyNetwork.main); + + NodeModel buildNode({required String id, required String name}) { + return NodeModel( + host: '127.0.0.1', + port: 2000, + name: name, + id: id, + useSSL: true, + enabled: true, + coinName: 'Bitcoin', + isFailover: false, + isDown: false, + torEnabled: true, + clearnetEnabled: true, + isPrimary: true, ); + } - when(nodeService.getNodeById(id: "node id")).thenAnswer( - (realInvocation) => NodeModel( - host: "127.0.0.1", - port: 2000, - name: "some other name", - id: "node id", - useSSL: true, - enabled: true, - coinName: "Bitcoin", - isFailover: false, - isDown: false, - torEnabled: true, - clearnetEnabled: true, - isPrimary: true, - ), + ThemeData buildTheme() { + return ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], ); + } + Future pumpSubject( + WidgetTester tester, { + required MockNodeService nodeService, + required List extraOverrides, + }) async { await tester.pumpWidget( ProviderScope( overrides: [ nodeServiceChangeNotifierProvider.overrideWithValue(nodeService), + ...extraOverrides, ], child: MaterialApp( - theme: ThemeData( - extensions: [ - StackColors.fromStackColorTheme( - StackTheme.fromJson( - json: lightThemeJsonMap, - ), - ), - ], - ), - home: NodeCard( - nodeId: "node id", - coin: Bitcoin(CryptoCurrencyNetwork.main), - popBackToRoute: "", - ), + theme: buildTheme(), + home: NodeCard(nodeId: 'node id', coin: bitcoin, popBackToRoute: ''), ), ), ); await tester.pumpAndSettle(); + } + + testWidgets('NodeCard builds inactive node correctly', (tester) async { + final nodeService = MockNodeService(); + + when( + nodeService.getPrimaryNodeFor(currency: bitcoin), + ).thenAnswer((_) => buildNode(id: 'other node id', name: 'Stack Default')); + when( + nodeService.getNodeById(id: 'node id'), + ).thenAnswer((_) => buildNode(id: 'node id', name: 'some other name')); + + await pumpSubject( + tester, + nodeService: nodeService, + extraOverrides: const [], + ); - expect(find.text("some other name"), findsOneWidget); - expect(find.text("Disconnected"), findsOneWidget); + expect(find.text('some other name'), findsOneWidget); + expect(find.text('Disconnected'), findsOneWidget); expect(find.byType(SvgPicture), findsWidgets); - verify( - nodeService.getPrimaryNodeFor( - currency: Bitcoin(CryptoCurrencyNetwork.main), - ), - ).called(1); - verify(nodeService.getNodeById(id: "node id")).called(1); + verify(nodeService.getPrimaryNodeFor(currency: bitcoin)).called(1); + verify(nodeService.getNodeById(id: 'node id')).called(1); verify(nodeService.addListener(any)).called(1); verifyNoMoreInteractions(nodeService); }); - testWidgets("NodeCard builds active node correctly", (tester) async { + testWidgets('NodeCard builds active node correctly', (tester) async { final nodeService = MockNodeService(); + final activeNode = buildNode(id: 'node id', name: 'Some other node name'); when( - nodeService.getPrimaryNodeFor( - currency: Bitcoin(CryptoCurrencyNetwork.main), - ), - ).thenAnswer( - (realInvocation) => NodeModel( - host: "127.0.0.1", - port: 2000, - name: "Some other node name", - id: "node id", - useSSL: true, - enabled: true, - coinName: "Bitcoin", - isFailover: false, - isDown: false, - torEnabled: true, - clearnetEnabled: true, - isPrimary: true, - ), + nodeService.getPrimaryNodeFor(currency: bitcoin), + ).thenAnswer((_) => activeNode); + when(nodeService.getNodeById(id: 'node id')).thenAnswer((_) => activeNode); + + await pumpSubject( + tester, + nodeService: nodeService, + extraOverrides: const [], ); - when(nodeService.getNodeById(id: "node id")).thenAnswer( - (realInvocation) => NodeModel( - host: "127.0.0.1", - port: 2000, - name: "Some other node name", - id: "node id", - useSSL: true, - enabled: true, - coinName: "Bitcoin", - isFailover: false, - isDown: false, - torEnabled: true, - clearnetEnabled: true, - isPrimary: true, - ), - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - nodeServiceChangeNotifierProvider.overrideWithValue(nodeService), - ], - child: MaterialApp( - theme: ThemeData( - extensions: [ - StackColors.fromStackColorTheme( - StackTheme.fromJson( - json: lightThemeJsonMap, - ), - ), - ], - ), - home: NodeCard( - nodeId: "node id", - coin: Bitcoin(CryptoCurrencyNetwork.main), - popBackToRoute: "", - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text("Some other node name"), findsOneWidget); - expect(find.text("Connected"), findsOneWidget); + expect(find.text('Some other node name'), findsOneWidget); + expect(find.text('Connected'), findsOneWidget); expect(find.byType(Text), findsNWidgets(2)); expect(find.byType(SvgPicture), findsWidgets); - verify( - nodeService.getPrimaryNodeFor( - currency: Bitcoin(CryptoCurrencyNetwork.main), - ), - ).called(1); - verify(nodeService.getNodeById(id: "node id")).called(1); + verify(nodeService.getPrimaryNodeFor(currency: bitcoin)).called(1); + verify(nodeService.getNodeById(id: 'node id')).called(1); verify(nodeService.addListener(any)).called(1); - verifyNoMoreInteractions(nodeService); }); - testWidgets("tap to open context menu on default node", (tester) async { + testWidgets('tap to open context menu on default node', (tester) async { final nodeService = MockNodeService(); + final activeNode = buildNode(id: 'node id', name: 'Stack Default'); when( - nodeService.getPrimaryNodeFor( - currency: Bitcoin(CryptoCurrencyNetwork.main), - ), - ).thenAnswer( - (realInvocation) => NodeModel( - host: "127.0.0.1", - port: 2000, - name: "Stack Default", - id: "node id", - useSSL: true, - enabled: true, - coinName: "Bitcoin", - isFailover: false, - isDown: false, - torEnabled: true, - clearnetEnabled: true, - isPrimary: true, - ), + nodeService.getPrimaryNodeFor(currency: bitcoin), + ).thenAnswer((_) => activeNode); + when(nodeService.getNodeById(id: 'node id')).thenAnswer((_) => activeNode); + + await pumpSubject( + tester, + nodeService: nodeService, + extraOverrides: const [], ); - when(nodeService.getNodeById(id: "node id")).thenAnswer( - (realInvocation) => NodeModel( - host: "127.0.0.1", - port: 2000, - name: "Stack Default", - id: "node id", - useSSL: true, - enabled: true, - coinName: "Bitcoin", - isFailover: false, - isDown: false, - torEnabled: true, - clearnetEnabled: true, - isPrimary: true, - ), - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - nodeServiceChangeNotifierProvider.overrideWithValue(nodeService), - ], - child: MaterialApp( - theme: ThemeData( - extensions: [ - StackColors.fromStackColorTheme( - StackTheme.fromJson( - json: lightThemeJsonMap, - ), - ), - ], - ), - home: NodeCard( - nodeId: "node id", - coin: Bitcoin(CryptoCurrencyNetwork.main), - popBackToRoute: "", - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - expect(find.text("Stack Default"), findsOneWidget); - expect(find.text("Connected"), findsOneWidget); + expect(find.text('Stack Default'), findsOneWidget); + expect(find.text('Connected'), findsOneWidget); expect(find.byType(Text), findsNWidgets(2)); expect(find.byType(SvgPicture), findsNWidgets(2)); @@ -258,31 +147,76 @@ void main() { await tester.pumpAndSettle(); if (Util.isDesktop) { - expect(find.text("Connect"), findsNothing); - expect(find.text("Details"), findsNothing); + expect(find.text('Connect'), findsNothing); + expect(find.text('Details'), findsNothing); - verify( - nodeService.getPrimaryNodeFor( - currency: Bitcoin(CryptoCurrencyNetwork.main), - ), - ).called(1); - verify(nodeService.getNodeById(id: "node id")).called(1); + verify(nodeService.getPrimaryNodeFor(currency: bitcoin)).called(1); + verify(nodeService.getNodeById(id: 'node id')).called(1); } else { - expect(find.text("Connect"), findsOneWidget); - expect(find.text("Details"), findsOneWidget); + expect(find.text('Connect'), findsOneWidget); + expect(find.text('Details'), findsOneWidget); expect(find.byType(NodeOptionsSheet), findsOneWidget); expect(find.byType(Text), findsNWidgets(7)); - verify( - nodeService.getPrimaryNodeFor( - currency: Bitcoin(CryptoCurrencyNetwork.main), - ), - ).called(2); - verify(nodeService.getNodeById(id: "node id")).called(2); + verify(nodeService.getPrimaryNodeFor(currency: bitcoin)).called(2); + verify(nodeService.getNodeById(id: 'node id')).called(2); } verify(nodeService.addListener(any)).called(1); - verifyNoMoreInteractions(nodeService); }); + + testWidgets( + 'desktop connect failure uses seam once and does not promote node', + (tester) async { + final nodeService = MockNodeService(); + final platformOverrides = await createPlatformTestOverrides( + connectionResult: false, + ); + final disconnectedNode = buildNode(id: 'node id', name: 'Stack Default'); + + when(nodeService.getPrimaryNodeFor(currency: bitcoin)).thenAnswer( + (_) => buildNode(id: 'other node id', name: 'Some other node name'), + ); + when( + nodeService.getNodeById(id: 'node id'), + ).thenAnswer((_) => disconnectedNode); + + await pumpSubject( + tester, + nodeService: nodeService, + extraOverrides: platformOverrides.overrides, + ); + + if (!Util.isDesktop) { + return; + } + + await tester.tap(find.byType(NodeCard)); + await tester.pumpAndSettle(); + + final connectFinder = find.byWidgetPredicate( + (widget) => widget is CustomTextButton && widget.text == 'Connect', + ); + expect(connectFinder, findsOneWidget); + expect(tester.widget(connectFinder).enabled, isTrue); + + tester.widget(connectFinder).onTap?.call(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(platformOverrides.secureStorage.reads, 1); + expect(platformOverrides.connectionInvocations, hasLength(1)); + expect(platformOverrides.connectionInvocations.single.password, isNull); + expect(platformOverrides.connectionInvocations.single.host, '127.0.0.1'); + + verifyNever( + nodeService.setPrimaryNodeFor( + coin: bitcoin, + node: anyNamed('node'), + shouldNotifyListeners: anyNamed('shouldNotifyListeners'), + ), + ); + }, + ); } diff --git a/test/widget_tests/node_card_test.mocks.dart b/test/widget_tests/node_card_test.mocks.dart index a279b9bd3d..73db103f60 100644 --- a/test/widget_tests/node_card_test.mocks.dart +++ b/test/widget_tests/node_card_test.mocks.dart @@ -4,9 +4,10 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i5; -import 'dart:ui' as _i7; +import 'dart:ui' as _i8; import 'package:mockito/mockito.dart' as _i1; +import 'package:stackwallet/models/epicbox_server_model.dart' as _i7; import 'package:stackwallet/models/node_model.dart' as _i4; import 'package:stackwallet/services/node_service.dart' as _i3; import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart' @@ -170,6 +171,64 @@ class MockNodeService extends _i1.Mock implements _i3.NodeService { ) as _i5.Future); + @override + _i5.Future updateDefaultEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#updateDefaultEpicBoxes, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future setPrimaryEpicBox({ + required _i7.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners = false, + }) => + (super.noSuchMethod( + Invocation.method(#setPrimaryEpicBox, [], { + #epicBox: epicBox, + #shouldNotifyListeners: shouldNotifyListeners, + }), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + List<_i7.EpicBoxServerModel> getEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#getEpicBoxes, []), + returnValue: <_i7.EpicBoxServerModel>[], + ) + as List<_i7.EpicBoxServerModel>); + + @override + _i7.EpicBoxServerModel? getEpicBoxById({required String? id}) => + (super.noSuchMethod(Invocation.method(#getEpicBoxById, [], {#id: id})) + as _i7.EpicBoxServerModel?); + + @override + _i5.Future addEpicBox( + _i7.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners, + ) => + (super.noSuchMethod( + Invocation.method(#addEpicBox, [epicBox, shouldNotifyListeners]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future deleteEpicBox(String? id, bool? shouldNotifyListeners) => + (super.noSuchMethod( + Invocation.method(#deleteEpicBox, [id, shouldNotifyListeners]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + @override _i5.Future updateCommunityNodes() => (super.noSuchMethod( @@ -180,13 +239,13 @@ class MockNodeService extends _i1.Mock implements _i3.NodeService { as _i5.Future); @override - void addListener(_i7.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i8.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#addListener, [listener]), returnValueForMissingStub: null, ); @override - void removeListener(_i7.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i8.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#removeListener, [listener]), returnValueForMissingStub: null, ); diff --git a/test/widget_tests/node_options_sheet_test.dart b/test/widget_tests/node_options_sheet_test.dart index cedc9158b6..26cfffb339 100644 --- a/test/widget_tests/node_options_sheet_test.dart +++ b/test/widget_tests/node_options_sheet_test.dart @@ -6,256 +6,291 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:stackwallet/models/isar/stack_theme.dart'; import 'package:stackwallet/models/node_model.dart'; +import 'package:stackwallet/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart'; import 'package:stackwallet/providers/providers.dart'; import 'package:stackwallet/services/node_service.dart'; import 'package:stackwallet/services/tor_service.dart'; import 'package:stackwallet/services/wallets.dart'; import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/enums/sync_type_enum.dart'; import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; import 'package:stackwallet/widgets/node_options_sheet.dart'; import '../sample_data/theme_json.dart'; import 'node_options_sheet_test.mocks.dart'; +import 'support/platform_test_overrides.dart'; @GenerateMocks([Wallets, Prefs, NodeService, TorService]) void main() { - testWidgets("Load Node Options widget", (tester) async { - final mockWallets = MockWallets(); - final mockPrefs = MockPrefs(); - final mockNodeService = MockNodeService(); + final bitcoin = Bitcoin(CryptoCurrencyNetwork.main); + + NodeModel buildNode({required String id, required String name}) { + return NodeModel( + host: '127.0.0.1', + port: 2000, + name: name, + id: id, + useSSL: true, + enabled: true, + coinName: 'Bitcoin', + isFailover: false, + isDown: false, + torEnabled: true, + clearnetEnabled: true, + isPrimary: true, + ); + } - when(mockNodeService.getNodeById(id: "node id")) - .thenAnswer((realInvocation) => NodeModel( - host: "127.0.0.1", - port: 2000, - name: "Some other name", - id: "node id", - useSSL: true, - enabled: true, - coinName: "Bitcoin", - isFailover: false, - isDown: false, - torEnabled: true, - clearnetEnabled: true, - isPrimary: true, - )); + ThemeData buildTheme() { + return ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ); + } - when(mockNodeService.getPrimaryNodeFor( - currency: Bitcoin(CryptoCurrencyNetwork.main))) - .thenAnswer((realInvocation) => NodeModel( - host: "127.0.0.1", - port: 2000, - name: "Some other name", - id: "node id", - useSSL: true, - enabled: true, - coinName: "Bitcoin", - isFailover: false, - torEnabled: true, - clearnetEnabled: true, - isDown: false, - isPrimary: true)); + void stubCommonProviders({ + required MockWallets wallets, + required MockPrefs prefs, + required MockNodeService nodeService, + required NodeModel node, + required NodeModel primaryNode, + }) { + when(wallets.wallets).thenReturn([]); + when(prefs.syncType).thenReturn(SyncingType.currentWalletOnly); + when(nodeService.getNodeById(id: node.id)).thenAnswer((_) => node); + when( + nodeService.getPrimaryNodeFor(currency: bitcoin), + ).thenAnswer((_) => primaryNode); + } + Future pumpSubject( + WidgetTester tester, { + required MockWallets wallets, + required MockPrefs prefs, + required MockNodeService nodeService, + required List extraOverrides, + GlobalKey? navigatorKey, + RouteFactory? onGenerateRoute, + String popBackToRoute = '', + }) async { await tester.pumpWidget( ProviderScope( overrides: [ - pWallets.overrideWithValue(mockWallets), - prefsChangeNotifierProvider.overrideWithValue(mockPrefs), - nodeServiceChangeNotifierProvider.overrideWithValue(mockNodeService) + pWallets.overrideWithValue(wallets), + prefsChangeNotifierProvider.overrideWithValue(prefs), + nodeServiceChangeNotifierProvider.overrideWithValue(nodeService), + ...extraOverrides, ], child: MaterialApp( - theme: ThemeData( - extensions: [ - StackColors.fromStackColorTheme( - StackTheme.fromJson( - json: lightThemeJsonMap, - ), - ), - ], - ), + navigatorKey: navigatorKey, + theme: buildTheme(), + onGenerateRoute: onGenerateRoute, home: NodeOptionsSheet( - nodeId: "node id", - coin: Bitcoin(CryptoCurrencyNetwork.main), - popBackToRoute: ""), + nodeId: 'node id', + coin: bitcoin, + popBackToRoute: popBackToRoute, + ), ), ), ); await tester.pumpAndSettle(); - expect(find.text("Node options"), findsOneWidget); - expect(find.text("Some other name"), findsOneWidget); - expect(find.text("Connected"), findsOneWidget); + } + + testWidgets('Load Node Options widget with disabled connect state', ( + tester, + ) async { + final mockWallets = MockWallets(); + final mockPrefs = MockPrefs(); + final mockNodeService = MockNodeService(); + final connectedNode = buildNode(id: 'node id', name: 'Some other name'); + + stubCommonProviders( + wallets: mockWallets, + prefs: mockPrefs, + nodeService: mockNodeService, + node: connectedNode, + primaryNode: connectedNode, + ); + + await pumpSubject( + tester, + wallets: mockWallets, + prefs: mockPrefs, + nodeService: mockNodeService, + extraOverrides: const [], + ); + + expect(find.text('Node options'), findsOneWidget); + expect(find.text('Some other name'), findsOneWidget); + expect(find.text('Connected'), findsOneWidget); expect(find.byType(SvgPicture), findsNWidgets(2)); - expect(find.text("Details"), findsOneWidget); - expect(find.text("Connect"), findsOneWidget); + expect(find.text('Details'), findsOneWidget); + expect(find.text('Connect'), findsOneWidget); + expect( + tester + .widget(find.widgetWithText(TextButton, 'Connect')) + .onPressed, + isNull, + ); - verify(mockNodeService.getPrimaryNodeFor( - currency: Bitcoin(CryptoCurrencyNetwork.main))) - .called(1); - verify(mockNodeService.getNodeById(id: "node id")).called(1); + verify(mockNodeService.getPrimaryNodeFor(currency: bitcoin)).called(1); + verify(mockNodeService.getNodeById(id: 'node id')).called(1); verify(mockNodeService.addListener(any)).called(1); verifyNoMoreInteractions(mockNodeService); }); - testWidgets("Details tap", (tester) async { + testWidgets('Details tap pushes node details route', (tester) async { final navigatorKey = GlobalKey(); final mockWallets = MockWallets(); final mockPrefs = MockPrefs(); final mockNodeService = MockNodeService(); - final mockTorService = MockTorService(); + final node = buildNode(id: 'node id', name: 'Stack Default'); + final otherPrimary = buildNode(id: 'some node id', name: 'Stack Default'); - when(mockNodeService.getNodeById(id: "node id")).thenAnswer( - (_) => NodeModel( - host: "127.0.0.1", - port: 2000, - name: "Stack Default", - id: "node id", - useSSL: true, - enabled: true, - coinName: "Bitcoin", - isFailover: false, - isDown: false, - torEnabled: true, - clearnetEnabled: true, - isPrimary: true, - ), - ); - - when(mockNodeService.getPrimaryNodeFor( - currency: Bitcoin(CryptoCurrencyNetwork.main))) - .thenAnswer( - (_) => NodeModel( - host: "127.0.0.1", - port: 2000, - name: "Stack Default", - id: "some node id", - useSSL: true, - enabled: true, - coinName: "Bitcoin", - isFailover: false, - isDown: false, - torEnabled: true, - clearnetEnabled: true, - isPrimary: true, - ), + stubCommonProviders( + wallets: mockWallets, + prefs: mockPrefs, + nodeService: mockNodeService, + node: node, + primaryNode: otherPrimary, ); - await tester.pumpWidget( - ProviderScope( - overrides: [ - pWallets.overrideWithValue(mockWallets), - prefsChangeNotifierProvider.overrideWithValue(mockPrefs), - nodeServiceChangeNotifierProvider.overrideWithValue(mockNodeService), - pTorService.overrideWithValue(mockTorService), - ], - child: MaterialApp( - navigatorKey: navigatorKey, - theme: ThemeData( - extensions: [ - StackColors.fromStackColorTheme( - StackTheme.fromJson( - json: lightThemeJsonMap, - ), - ), - ], - ), - onGenerateRoute: (settings) { - if (settings.name == '/nodeDetails') { - return MaterialPageRoute(builder: (_) => Scaffold()); - } - return null; - }, - home: NodeOptionsSheet( - nodeId: "node id", - coin: Bitcoin(CryptoCurrencyNetwork.main), - popBackToRoute: "coinNodes", - ), - ), - ), + await pumpSubject( + tester, + wallets: mockWallets, + prefs: mockPrefs, + nodeService: mockNodeService, + extraOverrides: const [], + navigatorKey: navigatorKey, + popBackToRoute: 'coinNodes', + onGenerateRoute: (settings) { + if (settings.name == NodeDetailsView.routeName) { + return MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('details route')), + ); + } + return null; + }, ); - await tester.tap(find.text("Details")); + await tester.tap(find.text('Details')); await tester.pumpAndSettle(); - final currentRoute = navigatorKey.currentState?.overlay?.context; - expect(currentRoute, isNotNull); + expect(find.text('details route'), findsOneWidget); + expect(navigatorKey.currentState?.canPop(), isFalse); }); - testWidgets("Connect tap", (tester) async { + testWidgets('Connect tap uses fake storage and promotes node on success', ( + tester, + ) async { final mockWallets = MockWallets(); final mockPrefs = MockPrefs(); final mockNodeService = MockNodeService(); - final mockTorService = MockTorService(); - - when(mockNodeService.getNodeById(id: "node id")).thenAnswer( - (_) => NodeModel( - host: "127.0.0.1", - port: 2000, - name: "Stack Default", - id: "node id", - useSSL: true, - enabled: true, - coinName: "Bitcoin", - isFailover: false, - isDown: false, - torEnabled: true, - clearnetEnabled: true, - isPrimary: true, - ), + final node = buildNode(id: 'node id', name: 'Stack Default'); + final otherPrimary = buildNode( + id: 'some node id', + name: 'Some other node name', ); - - when(mockNodeService.getPrimaryNodeFor( - currency: Bitcoin(CryptoCurrencyNetwork.main))) - .thenAnswer( - (_) => NodeModel( - host: "127.0.0.1", - port: 2000, - name: "Some other node name", - id: "some node id", - useSSL: true, - enabled: true, - coinName: "Bitcoin", - isFailover: false, - isDown: false, - torEnabled: true, - clearnetEnabled: true, - isPrimary: true, - ), + final platformOverrides = await createPlatformTestOverrides( + secureStorageEntries: {'node id_nodePW': 'fake-node-password'}, + connectionResult: true, ); - await tester.pumpWidget( - ProviderScope( - overrides: [ - pWallets.overrideWithValue(mockWallets), - prefsChangeNotifierProvider.overrideWithValue(mockPrefs), - nodeServiceChangeNotifierProvider.overrideWithValue(mockNodeService), - pTorService.overrideWithValue(mockTorService), - ], - child: MaterialApp( - theme: ThemeData( - extensions: [ - StackColors.fromStackColorTheme( - StackTheme.fromJson( - json: lightThemeJsonMap, - ), - ), - ], - ), - home: NodeOptionsSheet( - nodeId: "node id", - coin: Bitcoin(CryptoCurrencyNetwork.main), - popBackToRoute: "", - ), - ), + stubCommonProviders( + wallets: mockWallets, + prefs: mockPrefs, + nodeService: mockNodeService, + node: node, + primaryNode: otherPrimary, + ); + when( + mockNodeService.setPrimaryNodeFor( + coin: bitcoin, + node: node, + shouldNotifyListeners: true, ), + ).thenAnswer((_) async {}); + + await pumpSubject( + tester, + wallets: mockWallets, + prefs: mockPrefs, + nodeService: mockNodeService, + extraOverrides: platformOverrides.overrides, ); - await tester.pumpAndSettle(); - expect(find.text("Node options"), findsOneWidget); - expect(find.text("Disconnected"), findsOneWidget); + expect(find.text('Disconnected'), findsOneWidget); - await tester.tap(find.text("Connect")); + await tester.tap(find.widgetWithText(TextButton, 'Connect')); await tester.pumpAndSettle(); + + expect(platformOverrides.secureStorage.reads, 1); + expect(platformOverrides.connectionInvocations, hasLength(1)); + expect( + platformOverrides.connectionInvocations.single.password, + 'fake-node-password', + ); + expect(platformOverrides.connectionInvocations.single.host, '127.0.0.1'); + + verify( + mockNodeService.setPrimaryNodeFor( + coin: bitcoin, + node: node, + shouldNotifyListeners: true, + ), + ).called(1); }); + + testWidgets( + 'Connect failure stays inside fake seam with missing stored password', + (tester) async { + final mockWallets = MockWallets(); + final mockPrefs = MockPrefs(); + final mockNodeService = MockNodeService(); + final node = buildNode(id: 'node id', name: 'Stack Default'); + final otherPrimary = buildNode( + id: 'some node id', + name: 'Some other node name', + ); + final platformOverrides = await createPlatformTestOverrides( + connectionResult: false, + ); + + stubCommonProviders( + wallets: mockWallets, + prefs: mockPrefs, + nodeService: mockNodeService, + node: node, + primaryNode: otherPrimary, + ); + + await pumpSubject( + tester, + wallets: mockWallets, + prefs: mockPrefs, + nodeService: mockNodeService, + extraOverrides: platformOverrides.overrides, + ); + + await tester.tap(find.widgetWithText(TextButton, 'Connect')); + await tester.pumpAndSettle(); + + expect(platformOverrides.secureStorage.reads, 1); + expect(platformOverrides.connectionInvocations, hasLength(1)); + expect(platformOverrides.connectionInvocations.single.password, isNull); + + verifyNever( + mockNodeService.setPrimaryNodeFor( + coin: bitcoin, + node: anyNamed('node'), + shouldNotifyListeners: anyNamed('shouldNotifyListeners'), + ), + ); + }, + ); } diff --git a/test/widget_tests/node_options_sheet_test.mocks.dart b/test/widget_tests/node_options_sheet_test.mocks.dart index 23b04ffd95..3b2ce90f4e 100644 --- a/test/widget_tests/node_options_sheet_test.mocks.dart +++ b/test/widget_tests/node_options_sheet_test.mocks.dart @@ -11,11 +11,12 @@ import 'package:logger/logger.dart' as _i16; import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/src/dummies.dart' as _i14; import 'package:stackwallet/db/isar/main_db.dart' as _i3; +import 'package:stackwallet/models/epicbox_server_model.dart' as _i20; import 'package:stackwallet/models/node_model.dart' as _i19; import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart' - as _i21; + as _i22; import 'package:stackwallet/services/node_service.dart' as _i2; -import 'package:stackwallet/services/tor_service.dart' as _i20; +import 'package:stackwallet/services/tor_service.dart' as _i21; import 'package:stackwallet/services/wallets.dart' as _i9; import 'package:stackwallet/utilities/amount/amount_unit.dart' as _i17; import 'package:stackwallet/utilities/enums/backup_frequency_type.dart' as _i15; @@ -29,7 +30,6 @@ import 'package:stackwallet/wallets/isar/models/wallet_info.dart' as _i11; import 'package:stackwallet/wallets/wallet/wallet.dart' as _i5; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart' as _i6; -import 'package:tor_ffi_plugin/tor_ffi_plugin.dart' as _i22; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -445,6 +445,19 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -674,6 +687,18 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) @@ -689,13 +714,12 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { as _i10.Future); @override - _i10.Future incrementCurrentNotificationIndex() => + _i10.Future incrementCurrentNotificationIndex() => (super.noSuchMethod( Invocation.method(#incrementCurrentNotificationIndex, []), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), + returnValue: _i10.Future.value(0), ) - as _i10.Future); + as _i10.Future); @override _i10.Future isExternalCallsSet() => @@ -943,6 +967,64 @@ class MockNodeService extends _i1.Mock implements _i2.NodeService { ) as _i10.Future); + @override + _i10.Future updateDefaultEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#updateDefaultEpicBoxes, []), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); + + @override + _i10.Future setPrimaryEpicBox({ + required _i20.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners = false, + }) => + (super.noSuchMethod( + Invocation.method(#setPrimaryEpicBox, [], { + #epicBox: epicBox, + #shouldNotifyListeners: shouldNotifyListeners, + }), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); + + @override + List<_i20.EpicBoxServerModel> getEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#getEpicBoxes, []), + returnValue: <_i20.EpicBoxServerModel>[], + ) + as List<_i20.EpicBoxServerModel>); + + @override + _i20.EpicBoxServerModel? getEpicBoxById({required String? id}) => + (super.noSuchMethod(Invocation.method(#getEpicBoxById, [], {#id: id})) + as _i20.EpicBoxServerModel?); + + @override + _i10.Future addEpicBox( + _i20.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners, + ) => + (super.noSuchMethod( + Invocation.method(#addEpicBox, [epicBox, shouldNotifyListeners]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); + + @override + _i10.Future deleteEpicBox(String? id, bool? shouldNotifyListeners) => + (super.noSuchMethod( + Invocation.method(#deleteEpicBox, [id, shouldNotifyListeners]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); + @override _i10.Future updateCommunityNodes() => (super.noSuchMethod( @@ -980,18 +1062,18 @@ class MockNodeService extends _i1.Mock implements _i2.NodeService { /// A class which mocks [TorService]. /// /// See the documentation for Mockito's code generation for more information. -class MockTorService extends _i1.Mock implements _i20.TorService { +class MockTorService extends _i1.Mock implements _i21.TorService { MockTorService() { _i1.throwOnMissingStub(this); } @override - _i21.TorConnectionStatus get status => + _i22.TorConnectionStatus get status => (super.noSuchMethod( Invocation.getter(#status), - returnValue: _i21.TorConnectionStatus.disconnected, + returnValue: _i22.TorConnectionStatus.disconnected, ) - as _i21.TorConnectionStatus); + as _i22.TorConnectionStatus); @override ({_i8.InternetAddress host, int port}) getProxyInfo() => @@ -1008,14 +1090,10 @@ class MockTorService extends _i1.Mock implements _i20.TorService { as ({_i8.InternetAddress host, int port})); @override - void init({required String? torDataDirPath, _i22.Tor? mockableOverride}) => - super.noSuchMethod( - Invocation.method(#init, [], { - #torDataDirPath: torDataDirPath, - #mockableOverride: mockableOverride, - }), - returnValueForMissingStub: null, - ); + void init({required String? torDataDirPath}) => super.noSuchMethod( + Invocation.method(#init, [], {#torDataDirPath: torDataDirPath}), + returnValueForMissingStub: null, + ); @override _i10.Future start() => diff --git a/test/widget_tests/support/platform_test_overrides.dart b/test/widget_tests/support/platform_test_overrides.dart new file mode 100644 index 0000000000..73c0e5a0e6 --- /dev/null +++ b/test/widget_tests/support/platform_test_overrides.dart @@ -0,0 +1,187 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:stackwallet/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart'; +import 'package:stackwallet/providers/global/secure_store_provider.dart'; +import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; +import 'package:stackwallet/utilities/test_node_connection.dart'; +import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +class NodeConnectionTestInvocation { + const NodeConnectionTestInvocation({ + required this.cryptoCurrency, + required this.name, + required this.host, + required this.login, + required this.password, + required this.port, + required this.useSSL, + required this.isFailover, + required this.trusted, + required this.netOption, + }); + + factory NodeConnectionTestInvocation.fromFormData({ + required CryptoCurrency cryptoCurrency, + required NodeFormData nodeFormData, + }) { + return NodeConnectionTestInvocation( + cryptoCurrency: cryptoCurrency, + name: nodeFormData.name, + host: nodeFormData.host, + login: nodeFormData.login, + password: nodeFormData.password, + port: nodeFormData.port, + useSSL: nodeFormData.useSSL, + isFailover: nodeFormData.isFailover, + trusted: nodeFormData.trusted, + netOption: nodeFormData.netOption, + ); + } + + final CryptoCurrency cryptoCurrency; + final String? name; + final String? host; + final String? login; + final String? password; + final int? port; + final bool? useSSL; + final bool? isFailover; + final bool? trusted; + final TorPlainNetworkOption? netOption; +} + +typedef PlatformNodeConnectionHandler = + FutureOr Function(NodeConnectionTestInvocation invocation); + +class RecordingFakeSecureStorage extends FakeSecureStorage { + final List readKeys = []; + final List writtenKeys = []; + final List deletedKeys = []; + + @override + Future read({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) { + readKeys.add(key); + return super.read( + key: key, + iOptions: iOptions, + aOptions: aOptions, + lOptions: lOptions, + webOptions: webOptions, + mOptions: mOptions, + wOptions: wOptions, + ); + } + + @override + Future write({ + required String key, + required String? value, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) { + writtenKeys.add(key); + return super.write( + key: key, + value: value, + iOptions: iOptions, + aOptions: aOptions, + lOptions: lOptions, + webOptions: webOptions, + mOptions: mOptions, + wOptions: wOptions, + ); + } + + @override + Future delete({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) { + deletedKeys.add(key); + return super.delete( + key: key, + iOptions: iOptions, + aOptions: aOptions, + lOptions: lOptions, + webOptions: webOptions, + mOptions: mOptions, + wOptions: wOptions, + ); + } +} + +class PlatformTestOverrides { + const PlatformTestOverrides._({ + required this.secureStorage, + required this.connectionInvocations, + required this.overrides, + }); + + final RecordingFakeSecureStorage secureStorage; + final List connectionInvocations; + final List overrides; +} + +Future createPlatformTestOverrides({ + Map secureStorageEntries = const {}, + bool connectionResult = true, + PlatformNodeConnectionHandler? onTestNodeConnection, +}) async { + final secureStorage = RecordingFakeSecureStorage(); + for (final entry in secureStorageEntries.entries) { + await secureStorage.write(key: entry.key, value: entry.value); + } + + final connectionInvocations = []; + + return PlatformTestOverrides._( + secureStorage: secureStorage, + connectionInvocations: connectionInvocations, + overrides: [ + secureStoreProvider.overrideWithValue(secureStorage), + testNodeConnectionProvider.overrideWithValue(({ + required BuildContext context, + required NodeFormData nodeFormData, + required CryptoCurrency cryptoCurrency, + void Function(NodeFormData)? onSuccess, + }) async { + final invocation = NodeConnectionTestInvocation.fromFormData( + cryptoCurrency: cryptoCurrency, + nodeFormData: nodeFormData, + ); + connectionInvocations.add(invocation); + + final result = onTestNodeConnection != null + ? await onTestNodeConnection(invocation) + : connectionResult; + + if (result) { + onSuccess?.call(nodeFormData); + } + + return result; + }), + ], + ); +} diff --git a/test/widget_tests/transaction_card_test.mocks.dart b/test/widget_tests/transaction_card_test.mocks.dart index 4ac1c8177d..a0a6dd3d13 100644 --- a/test/widget_tests/transaction_card_test.mocks.dart +++ b/test/widget_tests/transaction_card_test.mocks.dart @@ -520,6 +520,19 @@ class MockPrefs extends _i1.Mock implements _i13.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -749,6 +762,18 @@ class MockPrefs extends _i1.Mock implements _i13.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) @@ -764,13 +789,12 @@ class MockPrefs extends _i1.Mock implements _i13.Prefs { as _i10.Future); @override - _i10.Future incrementCurrentNotificationIndex() => + _i10.Future incrementCurrentNotificationIndex() => (super.noSuchMethod( Invocation.method(#incrementCurrentNotificationIndex, []), - returnValue: _i10.Future.value(), - returnValueForMissingStub: _i10.Future.value(), + returnValue: _i10.Future.value(0), ) - as _i10.Future); + as _i10.Future); @override _i10.Future isExternalCallsSet() => @@ -910,6 +934,14 @@ class MockPriceService extends _i1.Mock implements _i21.PriceService { ) as _i10.Future>); + @override + _i10.Future> get solTokenContractAddressesToCheck => + (super.noSuchMethod( + Invocation.getter(#solTokenContractAddressesToCheck), + returnValue: _i10.Future>.value({}), + ) + as _i10.Future>); + @override Duration get updateInterval => (super.noSuchMethod( @@ -1647,6 +1679,50 @@ class MockMainDB extends _i1.Mock implements _i3.MainDB { returnValueForMissingStub: _i10.Future.value(), ) as _i10.Future); + + @override + _i8.QueryBuilder<_i28.SolContract, _i28.SolContract, _i8.QWhere> + getSolContracts() => + (super.noSuchMethod( + Invocation.method(#getSolContracts, []), + returnValue: + _FakeQueryBuilder_7< + _i28.SolContract, + _i28.SolContract, + _i8.QWhere + >(this, Invocation.method(#getSolContracts, [])), + ) + as _i8.QueryBuilder<_i28.SolContract, _i28.SolContract, _i8.QWhere>); + + @override + _i10.Future<_i28.SolContract?> getSolContract(String? tokenMint) => + (super.noSuchMethod( + Invocation.method(#getSolContract, [tokenMint]), + returnValue: _i10.Future<_i28.SolContract?>.value(), + ) + as _i10.Future<_i28.SolContract?>); + + @override + _i28.SolContract? getSolContractSync(String? tokenMint) => + (super.noSuchMethod(Invocation.method(#getSolContractSync, [tokenMint])) + as _i28.SolContract?); + + @override + _i10.Future putSolContract(_i28.SolContract? token) => + (super.noSuchMethod( + Invocation.method(#putSolContract, [token]), + returnValue: _i10.Future.value(0), + ) + as _i10.Future); + + @override + _i10.Future putSolContracts(List<_i28.SolContract>? tokens) => + (super.noSuchMethod( + Invocation.method(#putSolContracts, [tokens]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); } /// A class which mocks [IThemeAssets]. diff --git a/test/widget_tests/wallet_info_row/sub_widgets/wallet_info_row_balance_future_test.mocks.dart b/test/widget_tests/wallet_info_row/sub_widgets/wallet_info_row_balance_future_test.mocks.dart index 94f1c27403..ecc2874ec6 100644 --- a/test/widget_tests/wallet_info_row/sub_widgets/wallet_info_row_balance_future_test.mocks.dart +++ b/test/widget_tests/wallet_info_row/sub_widgets/wallet_info_row_balance_future_test.mocks.dart @@ -4,10 +4,11 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i8; -import 'dart:ui' as _i12; +import 'dart:ui' as _i13; import 'package:mockito/mockito.dart' as _i1; import 'package:stackwallet/db/isar/main_db.dart' as _i3; +import 'package:stackwallet/models/epicbox_server_model.dart' as _i12; import 'package:stackwallet/models/node_model.dart' as _i11; import 'package:stackwallet/services/node_service.dart' as _i2; import 'package:stackwallet/services/wallets.dart' as _i7; @@ -298,6 +299,64 @@ class MockNodeService extends _i1.Mock implements _i2.NodeService { ) as _i8.Future); + @override + _i8.Future updateDefaultEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#updateDefaultEpicBoxes, []), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) + as _i8.Future); + + @override + _i8.Future setPrimaryEpicBox({ + required _i12.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners = false, + }) => + (super.noSuchMethod( + Invocation.method(#setPrimaryEpicBox, [], { + #epicBox: epicBox, + #shouldNotifyListeners: shouldNotifyListeners, + }), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) + as _i8.Future); + + @override + List<_i12.EpicBoxServerModel> getEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#getEpicBoxes, []), + returnValue: <_i12.EpicBoxServerModel>[], + ) + as List<_i12.EpicBoxServerModel>); + + @override + _i12.EpicBoxServerModel? getEpicBoxById({required String? id}) => + (super.noSuchMethod(Invocation.method(#getEpicBoxById, [], {#id: id})) + as _i12.EpicBoxServerModel?); + + @override + _i8.Future addEpicBox( + _i12.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners, + ) => + (super.noSuchMethod( + Invocation.method(#addEpicBox, [epicBox, shouldNotifyListeners]), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) + as _i8.Future); + + @override + _i8.Future deleteEpicBox(String? id, bool? shouldNotifyListeners) => + (super.noSuchMethod( + Invocation.method(#deleteEpicBox, [id, shouldNotifyListeners]), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) + as _i8.Future); + @override _i8.Future updateCommunityNodes() => (super.noSuchMethod( @@ -308,13 +367,13 @@ class MockNodeService extends _i1.Mock implements _i2.NodeService { as _i8.Future); @override - void addListener(_i12.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i13.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#addListener, [listener]), returnValueForMissingStub: null, ); @override - void removeListener(_i12.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i13.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#removeListener, [listener]), returnValueForMissingStub: null, ); diff --git a/test/widget_tests/wallet_info_row/wallet_info_row_test.mocks.dart b/test/widget_tests/wallet_info_row/wallet_info_row_test.mocks.dart index 4aad121c69..c2139bdfd6 100644 --- a/test/widget_tests/wallet_info_row/wallet_info_row_test.mocks.dart +++ b/test/widget_tests/wallet_info_row/wallet_info_row_test.mocks.dart @@ -5,10 +5,11 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i9; import 'dart:typed_data' as _i14; -import 'dart:ui' as _i16; +import 'dart:ui' as _i17; import 'package:mockito/mockito.dart' as _i1; import 'package:stackwallet/db/isar/main_db.dart' as _i3; +import 'package:stackwallet/models/epicbox_server_model.dart' as _i16; import 'package:stackwallet/models/isar/stack_theme.dart' as _i13; import 'package:stackwallet/models/node_model.dart' as _i15; import 'package:stackwallet/networking/http.dart' as _i6; @@ -414,6 +415,64 @@ class MockNodeService extends _i1.Mock implements _i2.NodeService { ) as _i9.Future); + @override + _i9.Future updateDefaultEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#updateDefaultEpicBoxes, []), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + _i9.Future setPrimaryEpicBox({ + required _i16.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners = false, + }) => + (super.noSuchMethod( + Invocation.method(#setPrimaryEpicBox, [], { + #epicBox: epicBox, + #shouldNotifyListeners: shouldNotifyListeners, + }), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + List<_i16.EpicBoxServerModel> getEpicBoxes() => + (super.noSuchMethod( + Invocation.method(#getEpicBoxes, []), + returnValue: <_i16.EpicBoxServerModel>[], + ) + as List<_i16.EpicBoxServerModel>); + + @override + _i16.EpicBoxServerModel? getEpicBoxById({required String? id}) => + (super.noSuchMethod(Invocation.method(#getEpicBoxById, [], {#id: id})) + as _i16.EpicBoxServerModel?); + + @override + _i9.Future addEpicBox( + _i16.EpicBoxServerModel? epicBox, + bool? shouldNotifyListeners, + ) => + (super.noSuchMethod( + Invocation.method(#addEpicBox, [epicBox, shouldNotifyListeners]), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + _i9.Future deleteEpicBox(String? id, bool? shouldNotifyListeners) => + (super.noSuchMethod( + Invocation.method(#deleteEpicBox, [id, shouldNotifyListeners]), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + @override _i9.Future updateCommunityNodes() => (super.noSuchMethod( @@ -424,13 +483,13 @@ class MockNodeService extends _i1.Mock implements _i2.NodeService { as _i9.Future); @override - void addListener(_i16.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i17.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#addListener, [listener]), returnValueForMissingStub: null, ); @override - void removeListener(_i16.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i17.VoidCallback? listener) => super.noSuchMethod( Invocation.method(#removeListener, [listener]), returnValueForMissingStub: null, ); diff --git a/tool/build_standalone_mwebd_windows.dart b/tool/build_standalone_mwebd_windows.dart new file mode 100644 index 0000000000..3f8c1785f4 --- /dev/null +++ b/tool/build_standalone_mwebd_windows.dart @@ -0,0 +1,186 @@ +import 'dart:io'; + +const _mwebdVersion = "v0.1.8"; +const _defaultFetchBaseUrl = + "https://github.com/cypherstack/stack_wallet/releases/download"; + +Future main(List args) async { + final projectToolDir = File(Platform.script.toFilePath()).parent; + + if (args.contains("--fetch")) { + await _fetchPrebuilt(projectToolDir); + } else { + await _buildFromSource(projectToolDir); + } +} + +Future _fetchPrebuilt(Directory projectToolDir) async { + final baseUrl = + Platform.environment["MWEBD_FETCH_BASE_URL"] ?? _defaultFetchBaseUrl; + final tag = "mwebd-$_mwebdVersion"; + + final winAssetsDir = Directory( + "${projectToolDir.parent.path}" + "${Platform.pathSeparator}assets" + "${Platform.pathSeparator}windows", + ); + if (!(await winAssetsDir.exists())) { + await winAssetsDir.create(recursive: true); + } + final exePath = "${winAssetsDir.path}${Platform.pathSeparator}mwebd.exe"; + final shaPath = "$exePath.sha256"; + + await _waitForProcess( + await Process.start( + "curl", + ["-fL", "-o", exePath, "$baseUrl/$tag/mwebd.exe"], + runInShell: true, + mode: ProcessStartMode.inheritStdio, + ), + ); + await _waitForProcess( + await Process.start( + "curl", + ["-fL", "-o", shaPath, "$baseUrl/$tag/mwebd.exe.sha256"], + runInShell: true, + mode: ProcessStartMode.inheritStdio, + ), + ); + + final expected = (await File( + shaPath, + ).readAsString()).trim().split(RegExp(r"\s+")).first; + final actual = (await Process.run("sha256sum", [ + exePath, + ], runInShell: true)).stdout.toString().trim().split(RegExp(r"\s+")).first; + if (expected.toLowerCase() != actual.toLowerCase()) { + stderr.writeln( + "mwebd.exe sha256 mismatch: expected $expected, got $actual", + ); + exit(1); + } + await File(shaPath).delete(); +} + +Future _buildFromSource(Directory projectToolDir) async { + // setup temp build dir + final tempBuildDir = Directory( + "${projectToolDir.path}" + "${Platform.pathSeparator}build", + ); + if (await tempBuildDir.exists()) { + await tempBuildDir.delete(recursive: true); + } + await tempBuildDir.create(); + + // change working dir and clone mwebd + Directory.current = tempBuildDir; + final clone = await Process.start( + "git", + [ + "clone", + "https://www.github.com/ltcmweb/mwebd.git", + "--branch", + _mwebdVersion, + ], + runInShell: true, + mode: ProcessStartMode.inheritStdio, + ); + await _waitForProcess(clone); + + // change working dir and build mwebd.exe + Directory.current = Directory( + "${tempBuildDir.path}" + "${Platform.pathSeparator}mwebd", + ); + final isCI = Platform.environment['CI'] == 'true'; + final Process build; + if (Platform.isWindows && isCI) { + build = await Process.start( + "go", + [ + "build", + "-v", + "-o", + "../mwebd.exe", + "github.com/ltcmweb/mwebd/cmd/mwebd", + ], + environment: {"CGO_ENABLED": "1"}, + runInShell: true, + mode: ProcessStartMode.inheritStdio, + ); + } else if (Platform.isWindows) { + build = await Process.start( + "wsl", + [ + "bash", + "-l", + "-c", + "GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc " + "go build -v -o ../mwebd.exe github.com/ltcmweb/mwebd/cmd/mwebd", + ], + runInShell: true, + mode: ProcessStartMode.inheritStdio, + ); + } else { + build = await Process.start( + "go", + [ + "build", + "-v", + "-o", + "../mwebd.exe", + "github.com/ltcmweb/mwebd/cmd/mwebd", + ], + environment: { + "GOOS": "windows", + "GOARCH": "amd64", + "CGO_ENABLED": "1", + "CC": "x86_64-w64-mingw32-gcc", + }, + runInShell: true, + mode: ProcessStartMode.inheritStdio, + ); + } + await _waitForProcess(build); + + // create assets/windows dir if needed + final winAssetsDir = Directory( + "${Directory.current.parent.parent.parent.path}" + "${Platform.pathSeparator}assets" + "${Platform.pathSeparator}windows", + ); + if (!(await winAssetsDir.exists())) { + await winAssetsDir.create(); + } + + // copy the build mwebd.exe to assets/windows + final copy = Platform.isWindows + ? await Process.start("cmd", [ + "/C", + "copy", + "${Directory.current.parent.path}" + "${Platform.pathSeparator}mwebd.exe", + "${winAssetsDir.path}" + "${Platform.pathSeparator}mwebd.exe", + ], mode: ProcessStartMode.inheritStdio) + : await Process.start("cp", [ + "${Directory.current.parent.path}" + "${Platform.pathSeparator}mwebd.exe", + "${winAssetsDir.path}" + "${Platform.pathSeparator}mwebd.exe", + ], mode: ProcessStartMode.inheritStdio); + await _waitForProcess(copy); + + // cleanup + Directory.current = projectToolDir; + await tempBuildDir.delete(recursive: true); +} + +Future _waitForProcess(Process process) async { + final exitCode = await process.exitCode; + if (exitCode != 0) { + print("Exited process with code=$exitCode\n${StackTrace.current}"); + exit(exitCode); + } +} diff --git a/tool/wl_templates/EPIC_libepiccash_interface_impl.template.dart b/tool/wl_templates/EPIC_libepiccash_interface_impl.template.dart index 579c322878..4e78631bdc 100644 --- a/tool/wl_templates/EPIC_libepiccash_interface_impl.template.dart +++ b/tool/wl_templates/EPIC_libepiccash_interface_impl.template.dart @@ -1,9 +1,11 @@ //ON +import 'package:flutter_libepiccash/epic_cash.dart' as epc; import 'package:flutter_libepiccash/git_versions.dart' as epic_versions; import 'package:flutter_libepiccash/lib.dart'; import 'package:flutter_libepiccash/models/transaction.dart'; //END_ON +import '../../utilities/dynamic_object.dart'; import '../interfaces/libepiccash_interface.dart'; LibEpicCashInterface get libEpic => _getLib(); @@ -20,55 +22,80 @@ final class _LibEpicCashInterfaceImpl extends LibEpicCashInterface { @override Future cancelTransaction({ - required String wallet, + required DynamicObject wallet, required String transactionId, }) { - return LibEpiccash.cancelTransaction( - wallet: wallet, + return wallet.get().cancelTransaction( transactionId: transactionId, ); } @override - Future<({String commitId, String slateId})> createTransaction({ - required String wallet, + Future<({String slateId, String commitId, String slateJson})> txReceive({ + required DynamicObject wallet, + required String slateJson, + }) async { + return (await wallet.get().txReceive( + slateJson: slateJson, + )).toRecord(); + } + + @override + Future<({String slateId, String commitId, String slateJson})> txFinalize({ + required DynamicObject wallet, + required String slateJson, + }) async { + return (await wallet.get().txFinalize( + slateJson: slateJson, + )).toRecord(); + } + + @override + Future<({String commitId, String slateId, String slateJson})> + createTransaction({ + required DynamicObject wallet, required int amount, required String address, required int secretKeyIndex, - required String epicboxConfig, required int minimumConfirmations, required String note, - }) { - return LibEpiccash.createTransaction( - wallet: wallet, + bool returnSlate = false, + }) async { + return (await wallet.get().createTransaction( amount: amount, address: address, secretKeyIndex: secretKeyIndex, - epicboxConfig: epicboxConfig, minimumConfirmations: minimumConfirmations, note: note, - ); + returnSlate: returnSlate, + )).toRecord(); } @override - Future deleteWallet({ - required String wallet, - required String config, + void updateEpicboxConfig({ + required DynamicObject wallet, + required String epicBoxConfig, }) { - return LibEpiccash.deleteWallet(wallet: wallet, config: config); + return wallet.get().updateEpicboxConfig(epicBoxConfig); + } + + @override + void updateConfig({required DynamicObject wallet, required String config}) { + return wallet.get().updateConfig(config); + } + + @override + Future deleteWallet({required String config}) { + return EpicWallet.deleteWallet(config: config); } @override Future getAddressInfo({ - required String wallet, + required DynamicObject wallet, required int index, required String epicboxConfig, }) { - return LibEpiccash.getAddressInfo( - wallet: wallet, - index: index, - epicboxConfig: epicboxConfig, - ); + return wallet.get().getAddressInfo(index: index); } @override @@ -78,26 +105,22 @@ final class _LibEpicCashInterfaceImpl extends LibEpicCashInterface { @override Future<({int fee, bool strategyUseAll, int total})> getTransactionFees({ - required String wallet, + required DynamicObject wallet, required int amount, required int minimumConfirmations, - required int available, }) { - return LibEpiccash.getTransactionFees( - wallet: wallet, + return wallet.get().getTransactionFees( amount: amount, minimumConfirmations: minimumConfirmations, - available: available, ); } @override Future> getTransactions({ - required String wallet, + required DynamicObject wallet, required int refreshFromNode, }) async { - final transactions = await LibEpiccash.getTransactions( - wallet: wallet, + final transactions = await wallet.get().getTransactions( refreshFromNode: refreshFromNode, ); @@ -146,87 +169,99 @@ final class _LibEpicCashInterfaceImpl extends LibEpicCashInterface { }) > getWalletBalances({ - required String wallet, + required DynamicObject wallet, required int refreshFromNode, required int minimumConfirmations, }) { - return LibEpiccash.getWalletBalances( - wallet: wallet, + return wallet.get().getBalancesRecord( refreshFromNode: refreshFromNode, minimumConfirmations: minimumConfirmations, ); } @override - Future initializeNewWallet({ + Future initializeNewWallet({ required String config, required String mnemonic, required String password, required String name, - }) { - return LibEpiccash.initializeNewWallet( + required String epicBoxConfig, + }) async { + final wallet = await EpicWallet.create( config: config, mnemonic: mnemonic, password: password, name: name, + epicboxConfig: epicBoxConfig, ); + + return DynamicObject(wallet); } @override - Future openWallet({ + Future openWallet({ required String config, required String password, - }) { - return LibEpiccash.openWallet(config: config, password: password); + required String epicboxConfig, + }) async { + final wallet = await EpicWallet.load( + config: config, + password: password, + epicboxConfig: epicboxConfig, + ); + + return DynamicObject(wallet); } @override - Future recoverWallet({ + Future recoverWallet({ required String config, required String password, required String mnemonic, required String name, - }) { - return LibEpiccash.recoverWallet( + required String epicBoxConfig, + }) async { + final wallet = await EpicWallet.recover( config: config, password: password, mnemonic: mnemonic, name: name, + epicboxConfig: epicBoxConfig, ); + + return DynamicObject(wallet); } @override Future scanOutputs({ - required String wallet, + required DynamicObject wallet, required int startHeight, required int numberOfBlocks, }) { - return LibEpiccash.scanOutputs( - wallet: wallet, + return wallet.get().scanOutputs( startHeight: startHeight, numberOfBlocks: numberOfBlocks, ); } @override - void startEpicboxListener({ - required String wallet, - required String epicboxConfig, - }) { - return LibEpiccash.startEpicboxListener( - wallet: wallet, - epicboxConfig: epicboxConfig, - ); + Future startEpicboxListener({required DynamicObject wallet}) { + return wallet.get().startListener(); + } + + @override + Future stopEpicboxListener({required DynamicObject wallet}) { + return wallet.get().stopListener(); } @override - void stopEpicboxListener() { - return LibEpiccash.stopEpicboxListener(); + Future isEpicboxListenerRunning({required DynamicObject wallet}) { + return wallet.get().isEpicboxListenerRunning(); } @override Future<({String commitId, String slateId})> txHttpSend({ - required String wallet, + required DynamicObject wallet, required int selectionStrategyIsAll, required int minimumConfirmations, required String message, @@ -234,8 +269,7 @@ final class _LibEpicCashInterfaceImpl extends LibEpicCashInterface { required String address, }) { try { - return LibEpiccash.txHttpSend( - wallet: wallet, + return wallet.get().txHttpSend( selectionStrategyIsAll: selectionStrategyIsAll, minimumConfirmations: minimumConfirmations, message: message, @@ -263,8 +297,18 @@ final class _LibEpicCashInterfaceImpl extends LibEpicCashInterface { } @override - bool validateSendAddress({required String address}) { - return LibEpiccash.validateSendAddress(address: address); + Future validateSendAddress({required String address}) { + return EpicWallet.validateSendAddress(address: address); + } + + @override + bool validateSendAddressSync({required String address}) { + return epc.validateSendAddress(address) == "1"; //lol + } + + @override + Future close({required DynamicObject wallet}) { + return wallet.get().close(); } @override diff --git a/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart b/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart index 0842a48e7c..5408f11fa6 100644 --- a/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart +++ b/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart @@ -48,12 +48,12 @@ class _LibSparkInterfaceImpl extends LibSparkInterface { String get nameRegexString => kNameRegexString; @override - String get stage3DevelopmentFundAddressMainNet => - kStage3DevelopmentFundAddressMainNet; + String get stage3CommunityFundAddressMainNet => + kStage3CommunityFundAddressMainNet; @override - String get stage3DevelopmentFundAddressTestNet => - kStage3DevelopmentFundAddressTestNet; + String get stage3CommunityFundAddressTestNet => + kStage3DCommunityFundAddressTestNet; @override List get standardSparkNamesFee => @@ -96,12 +96,27 @@ class _LibSparkInterfaceImpl extends LibSparkInterface { isTestNet: isTestNet, ); + @override + LibSparkSpendVersion getSpendVersionForBlockHeight({ + required int nextBlockHeight, + required int chaumV2ActivationHeight, + }) { + final version = SparkSpendVersion.forBlockHeight( + nextBlockHeight: nextBlockHeight, + chaumV2ActivationHeight: chaumV2ActivationHeight, + ); + return switch (version) { + .chaumV1 => .chaumV1, + .chaumV2 => .chaumV2, + }; + } + @override ({Uint8List script, int size}) createSparkNameScript({ required int sparkNameValidityBlocks, required String name, required String additionalInfo, - required String scalarHex, + required LibSparkNameProofInput proofInput, required String privateKeyHex, required int spendKeyIndex, required int diversifier, @@ -112,7 +127,10 @@ class _LibSparkInterfaceImpl extends LibSparkInterface { sparkNameValidityBlocks: sparkNameValidityBlocks, name: name, additionalInfo: additionalInfo, - scalarHex: scalarHex, + proofInput: switch (proofInput.spendVersion) { + .chaumV1 => .chaumV1(scalarHex: proofInput.inputHex), + .chaumV2 => .chaumV2(ownershipDigest: proofInput.inputHex), + }, privateKeyHex: privateKeyHex, spendKeyIndex: spendKeyIndex, diversifier: diversifier, @@ -121,6 +139,13 @@ class _LibSparkInterfaceImpl extends LibSparkInterface { ignoreProof: ignoreProof, ); + @override + Uint8List getSparkNameCommitment({ + required Uint8List serializedSparkNameData, + }) => LibSpark.getSparkNameCommitment( + serializedSparkNameData: serializedSparkNameData, + ); + @override List<({int amount, Uint8List scriptPubKey, bool subtractFeeFromAmount})> createSparkMintRecipients({ @@ -178,6 +203,67 @@ class _LibSparkInterfaceImpl extends LibSparkInterface { ); } + @override + WrappedLibSparkCoin? identifyAndRecoverCoinByFullViewKey( + String serializedCoin, { + required String fullViewKeyHex, + required Uint8List context, + bool isTestNet = false, + }) { + final coin = LibSpark.identifyAndRecoverCoinByFullViewKey( + serializedCoin: serializedCoin, + fullViewKeyHex: fullViewKeyHex, + context: context, + isTestNet: isTestNet, + ); + + if (coin == null) return null; + + return WrappedLibSparkCoin( + type: WrappedLibSparkCoinType.values.firstWhere( + (e) => e.value == coin.type.value, + ), + + id: coin.id, + height: coin.height, + isUsed: coin.isUsed, + nonceHex: coin.nonceHex, + address: coin.address, + value: coin.value, + serial: coin.serial, + memo: coin.memo, + txHash: coin.txHash, + serialContext: coin.serialContext, + diversifier: coin.diversifier, + encryptedDiversifier: coin.encryptedDiversifier, + tag: coin.tag, + lTagHash: coin.lTagHash, + serializedCoin: coin.serializedCoin, + ); + } + + @override + Future getAddressFromFullViewKey({ + required String fullViewKeyHex, + required int index, + required int diversifier, + bool isTestNet = false, + }) => LibSpark.getAddressFromFullViewKey( + fullViewKeyHex: fullViewKeyHex, + index: index, + diversifier: diversifier, + isTestNet: isTestNet, + ); + + @override + String getFullViewKeyHexFromPrivateKeyData({ + required String privateKeyHex, + required int index, + }) => LibSpark.getFullViewKeyHexFromPrivateKeyData( + privateKeyHex: privateKeyHex, + index: index, + ); + @override ({ int fee, @@ -227,6 +313,8 @@ class _LibSparkInterfaceImpl extends LibSparkInterface { required List<({Uint8List blockHash, int setId})> idAndBlockHashes, required Uint8List txHash, required int additionalTxSize, + required LibSparkSpendVersion spendVersion, + Uint8List? extensionCommitment, }) => LibSpark.createSparkSendTransaction( index: index, privateKeyHex: privateKeyHex, @@ -237,6 +325,11 @@ class _LibSparkInterfaceImpl extends LibSparkInterface { idAndBlockHashes: idAndBlockHashes, txHash: txHash, additionalTxSize: additionalTxSize, + spendVersion: switch (spendVersion) { + .chaumV1 => .chaumV1, + .chaumV2 => .chaumV2, + }, + extensionCommitment: extensionCommitment, ); @override @@ -257,6 +350,7 @@ class _LibSparkInterfaceImpl extends LibSparkInterface { required int privateRecipientsCount, required int utxoNum, required int additionalTxSize, + required LibSparkSpendVersion spendVersion, }) => LibSpark.estimateSparkFee( privateKeyHex: privateKeyHex, sendAmount: sendAmount, @@ -265,6 +359,10 @@ class _LibSparkInterfaceImpl extends LibSparkInterface { privateRecipientsCount: privateRecipientsCount, utxoNum: utxoNum, additionalTxSize: additionalTxSize, + spendVersion: switch (spendVersion) { + .chaumV1 => .chaumV1, + .chaumV2 => .chaumV2, + }, index: index, ); } diff --git a/tool/wl_templates/MWC_libmwc_interface_impl.template.dart b/tool/wl_templates/MWC_libmwc_interface_impl.template.dart index 0262cc5e35..c88c1662e1 100644 --- a/tool/wl_templates/MWC_libmwc_interface_impl.template.dart +++ b/tool/wl_templates/MWC_libmwc_interface_impl.template.dart @@ -117,6 +117,11 @@ final class _LibMwcInterfaceImpl extends LibMwcInterface { return mimblewimblecoin.Libmwc.getChainHeight(config: config); } + @override + Future initLogs({required String config}) { + return mimblewimblecoin.Libmwc.initLogs(config: config); + } + @override Future<({int fee, bool strategyUseAll, int total})> getTransactionFees({ required String wallet, diff --git a/tool/wl_templates/MWEBD_mwebd_server_interface_impl.template.dart b/tool/wl_templates/MWEBD_mwebd_server_interface_impl.template.dart index 55ba2c630a..f7bec47186 100644 --- a/tool/wl_templates/MWEBD_mwebd_server_interface_impl.template.dart +++ b/tool/wl_templates/MWEBD_mwebd_server_interface_impl.template.dart @@ -1,7 +1,17 @@ //ON -import 'package:flutter_mwebd/flutter_mwebd.dart' hide Status; +import 'dart:async'; +import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_mwebd/flutter_mwebd.dart'; +import 'package:path/path.dart'; + +import '../../app_config.dart'; //END_ON +import '../../utilities/dynamic_object.dart'; +import '../../utilities/extensions/extensions.dart'; +import '../../utilities/stack_file_system.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../interfaces/mwebd_server_interface.dart'; @@ -14,15 +24,46 @@ MwebdServerInterface _getInterface() => throw Exception("MWEBD not enabled!"); //ON MwebdServerInterface _getInterface() => const _MwebdServerInterfaceImpl(); -extension _OpaqueMwebdServerExt on OpaqueMwebdServer { - MwebdServer get value => get(); -} - class _MwebdServerInterfaceImpl extends MwebdServerInterface { const _MwebdServerInterfaceImpl(); + static const _kExe = "mwebd.exe"; + + static String? _cachedWinExePath; + + Future _prepareWindowsExeDirPath() async { + if (_cachedWinExePath == null) { + final dir = (await StackFileSystem.applicationMwebdDirectory( + "dummy", + )).parent.path; + + final exe = File(join(dir, _kExe)); + + if (await exe.exists()) { + await exe.delete(); + } + + final bytes = await rootBundle.load("assets/windows/mwebd.exe"); + await exe.writeAsBytes( + bytes.buffer.asUint8List(bytes.offsetInBytes, bytes.lengthInBytes), + flush: true, + ); + _cachedWinExePath = exe.parent.path; + } + + final hash = await sha256 + .bind(File(join(_cachedWinExePath!, _kExe)).openRead()) + .first; + final hexHash = Uint8List.fromList(hash.bytes).toHex; + if (AppConfig.windowsMwebdExeHash != hexHash) { + throw Exception("Windows mwebd.exe sha256 has mismatch!!!"); + } + + return _cachedWinExePath!; + } + @override - Future<({OpaqueMwebdServer server, int port})> createAndStartServer( + Future<({DynamicObject server, int port})> createAndStartServer( CryptoCurrencyNetwork net, { required String chain, required String dataDir, @@ -37,36 +78,51 @@ class _MwebdServerInterfaceImpl extends MwebdServerInterface { proxy: proxy, serverPort: serverPort, ); - await newServer.createServer(); - await newServer.startServer(); - return (server: OpaqueMwebdServer(newServer), port: newServer.serverPort); + + if (Platform.isWindows) { + final exeDirPath = await _prepareWindowsExeDirPath(); + final process = await Process.start(join(exeDirPath, _kExe), [ + "-c", + chain, + "-d", + chain, + "-l", + "127.0.0.1:$serverPort", + "-p", + peer, + "-proxy", + proxy, + ], workingDirectory: exeDirPath); + return (server: DynamicObject((process, newServer)), port: serverPort); + } else { + await newServer.createServer(); + await newServer.startServer(); + return (server: DynamicObject(newServer), port: newServer.serverPort); + } } @override Future<({String chain, String dataDir, String peer})> stopServer( - OpaqueMwebdServer server, + DynamicObject server, ) async { - final actual = server.value; - final data = ( - chain: actual.chain, - dataDir: actual.dataDir, - peer: actual.peer, - ); - await actual.stopServer(); - return data; - } - - @override - Future getServerStatus(OpaqueMwebdServer? server) async { - final status = await server?.value.getStatus(); - if (status == null) return null; - - return Status( - blockHeaderHeight: status.blockHeaderHeight, - mwebHeaderHeight: status.mwebHeaderHeight, - mwebUtxosHeight: status.mwebUtxosHeight, - blockTime: status.blockTime, - ); + if (server.get() is (Process, MwebdServer)) { + final actual = server.get<(Process, MwebdServer)>(); + actual.$1.kill(); + return ( + chain: actual.$2.chain, + dataDir: actual.$2.dataDir, + peer: actual.$2.peer, + ); + } else { + final actual = server.get(); + final data = ( + chain: actual.chain, + dataDir: actual.dataDir, + peer: actual.peer, + ); + await actual.stopServer(); + return data; + } } } diff --git a/tool/wl_templates/SAL_cs_salvium_interface_impl.template.dart b/tool/wl_templates/SAL_cs_salvium_interface_impl.template.dart index fb13045843..701dc3dfce 100644 --- a/tool/wl_templates/SAL_cs_salvium_interface_impl.template.dart +++ b/tool/wl_templates/SAL_cs_salvium_interface_impl.template.dart @@ -555,6 +555,10 @@ class _CsSalviumInterfaceImpl extends CsSalviumInterface { @override String getSeed(WrappedWallet wallet) => wallet.actual.getSeed(); + + @override + Future close(WrappedWallet wallet, {bool save = false}) => + wallet.actual.close(save: save); } //END_ON diff --git a/tool/wl_templates/TOR_tor_service_impl.template.dart b/tool/wl_templates/TOR_tor_service_impl.template.dart index 3e26cafa2c..63ee6ef0c5 100644 --- a/tool/wl_templates/TOR_tor_service_impl.template.dart +++ b/tool/wl_templates/TOR_tor_service_impl.template.dart @@ -21,10 +21,15 @@ FusionTorService _getFusionInterface() => throw Exception("TOR not enabled!"); //END_OFF //ON -TorService _getInterface() => _TorServiceImpl(); -FusionTorService _getFusionInterface() => _FusionTorServiceImpl(); +TorService _getInterface() => _TorServiceImpl.instance; +FusionTorService _getFusionInterface() => _FusionTorServiceImpl.instance; class _TorServiceImpl extends TorService { + static _TorServiceImpl? _instance; + static _TorServiceImpl get instance => _instance ??= _TorServiceImpl._(); + + _TorServiceImpl._(); + Tor? _tor; String? _torDataDirPath; TorConnectionStatus _status = TorConnectionStatus.disconnected; @@ -131,6 +136,12 @@ class _TorServiceImpl extends TorService { } class _FusionTorServiceImpl extends FusionTorService { + static _FusionTorServiceImpl? _instance; + static _FusionTorServiceImpl get instance => + _instance ??= _FusionTorServiceImpl._(); + + _FusionTorServiceImpl._(); + Tor? _tor; String? _torDataDirPath; diff --git a/tool/wl_templates/WOW_cs_wownero_interface_impl.template.dart b/tool/wl_templates/WOW_cs_wownero_interface_impl.template.dart new file mode 100644 index 0000000000..b6dee749ee --- /dev/null +++ b/tool/wl_templates/WOW_cs_wownero_interface_impl.template.dart @@ -0,0 +1,514 @@ +//ON +import 'package:cs_wownero/cs_wownero.dart' as lib_wownero; +import 'package:cs_wownero/src/deprecated/get_height_by_date.dart' + as cs_wownero_deprecated; +import 'package:cs_wownero/src/ffi_bindings/wownero_wallet_bindings.dart' + as wow_wallet_ffi; + +//END_ON +import '../../models/input.dart'; +import '../interfaces/cs_monero_interface.dart'; +import '../interfaces/cs_salvium_interface.dart' show WrappedWallet; +import '../interfaces/cs_wownero_interface.dart'; + +CsWowneroInterface get csWownero => _getInterface(); + +//OFF +CsWowneroInterface _getInterface() => throw Exception("WOW not enabled!"); + +//END_OFF +//ON +CsWowneroInterface _getInterface() => const _CsWowneroInterfaceImpl(); + +class _CsWowneroInterfaceImpl extends CsWowneroInterface { + const _CsWowneroInterfaceImpl(); + + @override + void setUseCsWowneroLoggerInternal(bool enable) => + lib_wownero.Logging.useLogger = enable; + + @override + bool walletExists(String path) => + lib_wownero.WowneroWallet.isWalletExist(path); + + @override + Future estimateFee( + int rate, + BigInt amount, { + required WrappedWallet wallet, + }) { + lib_wownero.TransactionPriority priority; + switch (rate) { + case 1: + priority = lib_wownero.TransactionPriority.low; + break; + case 2: + priority = lib_wownero.TransactionPriority.medium; + break; + case 3: + priority = lib_wownero.TransactionPriority.high; + break; + case 4: + priority = lib_wownero.TransactionPriority.last; + break; + case 0: + default: + priority = lib_wownero.TransactionPriority.normal; + break; + } + + return wallet.get().estimateFee( + priority, + amount.toInt(), + ); + } + + @override + Future loadWallet( + String walletId, { + required String path, + required String password, + }) async { + return WrappedWallet( + await lib_wownero.WowneroWallet.loadWallet( + path: path, + password: password, + ), + ); + } + + @override + int getTxPriorityHigh() => lib_wownero.TransactionPriority.high.value; + + @override + int getTxPriorityMedium() => lib_wownero.TransactionPriority.medium.value; + + @override + int getTxPriorityNormal() => lib_wownero.TransactionPriority.normal.value; + + @override + String getAddress( + WrappedWallet wallet, { + int accountIndex = 0, + int addressIndex = 0, + }) => wallet + .get() + .getAddress(accountIndex: accountIndex, addressIndex: addressIndex) + .value; + + @override + Future getCreatedWallet({ + required String path, + required String password, + required int wordCount, + required String seedOffset, + }) async { + final type = switch (wordCount) { + 16 => lib_wownero.WowneroSeedType.sixteen, + 25 => lib_wownero.WowneroSeedType.twentyFive, + _ => throw Exception("Invalid mnemonic word count: $wordCount"), + }; + + final wallet = await lib_wownero.WowneroWallet.create( + path: path, + password: password, + seedType: type, + seedOffset: seedOffset, + ); + + return WrappedWallet(wallet); + } + + @override + Future getRestoredWallet({ + required String walletId, + + required String path, + required String password, + required String mnemonic, + required String seedOffset, + int height = 0, + }) async { + return WrappedWallet( + await lib_wownero.WowneroWallet.restoreWalletFromSeed( + path: path, + password: password, + seed: mnemonic, + restoreHeight: height, + seedOffset: seedOffset, + ), + ); + } + + @override + Future getRestoredFromViewKeyWallet({ + required String walletId, + + required String path, + required String password, + required String address, + required String privateViewKey, + int height = 0, + }) async { + return WrappedWallet( + await lib_wownero.WowneroWallet.createViewOnlyWallet( + path: path, + password: password, + address: address, + viewKey: privateViewKey, + restoreHeight: height, + ), + ); + } + + @override + String getTxKey(WrappedWallet wallet, String txid) => + wallet.get().getTxKey(txid); + + @override + Future save(WrappedWallet wallet) => + wallet.get().save(); + + @override + String getPublicViewKey(WrappedWallet wallet) => + wallet.get().getPublicViewKey(); + + @override + String getPrivateViewKey(WrappedWallet wallet) => + wallet.get().getPrivateViewKey(); + + @override + String getPublicSpendKey(WrappedWallet wallet) => + wallet.get().getPublicSpendKey(); + + @override + String getPrivateSpendKey(WrappedWallet wallet) => + wallet.get().getPrivateSpendKey(); + + @override + Future isSynced(WrappedWallet wallet) => + wallet.get().isSynced(); + + @override + void startSyncing(WrappedWallet wallet) => + wallet.get().startSyncing(); + + @override + void stopSyncing(WrappedWallet wallet) => + wallet.get().stopSyncing(); + + @override + void startAutoSaving(WrappedWallet wallet) => + wallet.get().startAutoSaving(); + + @override + void stopAutoSaving(WrappedWallet wallet) => + wallet.get().stopAutoSaving(); + + @override + bool hasListeners(WrappedWallet wallet) => + wallet.get().getListeners().isNotEmpty; + + @override + void addListener(WrappedWallet wallet, CsWalletListener listener) => + wallet.get().addListener( + lib_wownero.WalletListener( + onSyncingUpdate: listener.onSyncingUpdate, + onNewBlock: listener.onNewBlock, + onBalancesChanged: listener.onBalancesChanged, + onError: listener.onError, + ), + ); + + @override + void startListeners(WrappedWallet wallet) => + wallet.get().startListeners(); + + @override + void stopListeners(WrappedWallet wallet) => + wallet.get().stopListeners(); + + @override + int getRefreshFromBlockHeight(WrappedWallet wallet) => + wallet.get().getRefreshFromBlockHeight(); + + @override + void setRefreshFromBlockHeight(WrappedWallet wallet, int height) => + wallet.get().setRefreshFromBlockHeight(height); + + @override + Future rescanBlockchain(WrappedWallet wallet) => + wallet.get().rescanBlockchain(); + + @override + Future isConnectedToDaemon(WrappedWallet wallet) => + wallet.get().isConnectedToDaemon(); + + @override + Future connect( + WrappedWallet wallet, { + required String daemonAddress, + required bool trusted, + String? daemonUsername, + String? daemonPassword, + bool useSSL = false, + bool isLightWallet = false, + String? socksProxyAddress, + }) async { + await wallet.get().connect( + daemonAddress: daemonAddress, + trusted: trusted, + daemonUsername: daemonUsername, + daemonPassword: daemonPassword, + useSSL: useSSL, + socksProxyAddress: socksProxyAddress, + isLightWallet: isLightWallet, + ); + } + + @override + Future> getAllTxids( + WrappedWallet wallet, { + bool refresh = false, + }) => wallet.get().getAllTxids(refresh: refresh); + + @override + BigInt? getBalance(WrappedWallet wallet, {int accountIndex = 0}) => + wallet.get().getBalance(accountIndex: accountIndex); + + @override + BigInt? getUnlockedBalance(WrappedWallet wallet, {int accountIndex = 0}) => + wallet.get().getUnlockedBalance( + accountIndex: accountIndex, + ); + + @override + Future> getAllTxs( + WrappedWallet wallet, { + bool refresh = false, + }) async { + final transactions = await wallet.get().getAllTxs( + refresh: refresh, + ); + return transactions + .map( + (e) => CsTransaction( + displayLabel: e.displayLabel, + description: e.description, + fee: e.fee, + confirmations: e.confirmations, + blockHeight: e.blockHeight, + accountIndex: e.accountIndex, + addressIndexes: e.addressIndexes, + paymentId: e.paymentId, + amount: e.amount, + isSpend: e.isSpend, + hash: e.hash, + key: e.key, + timeStamp: e.timeStamp, + minConfirms: e.minConfirms.value, + ), + ) + .toList(); + } + + @override + Future> getTxs( + WrappedWallet wallet, { + required Set txids, + bool refresh = false, + }) async { + final transactions = await wallet.get().getTxs( + txids: txids, + refresh: refresh, + ); + return transactions + .map( + (e) => CsTransaction( + displayLabel: e.displayLabel, + description: e.description, + fee: e.fee, + confirmations: e.confirmations, + blockHeight: e.blockHeight, + accountIndex: e.accountIndex, + addressIndexes: e.addressIndexes, + paymentId: e.paymentId, + amount: e.amount, + isSpend: e.isSpend, + hash: e.hash, + key: e.key, + timeStamp: e.timeStamp, + minConfirms: e.minConfirms.value, + ), + ) + .toList(); + } + + @override + Future createTx( + WrappedWallet wallet, { + required CsRecipient output, + required int priority, + required bool sweep, + List? preferredInputs, + required int accountIndex, + required int minConfirms, + required int currentHeight, + }) async { + final pending = await wallet.get().createTx( + output: lib_wownero.Recipient( + address: output.address, + amount: output.amount, + ), + paymentId: "", + sweep: sweep, + priority: lib_wownero.TransactionPriority.values.firstWhere( + (e) => e.value == priority, + ), + preferredInputs: preferredInputs + ?.map( + (e) => lib_wownero.Output( + address: e.address!, + hash: e.utxo.txid, + keyImage: e.utxo.keyImage!, + value: e.value, + isFrozen: e.utxo.isBlocked, + isUnlocked: + e.utxo.blockHeight != null && + (currentHeight - (e.utxo.blockHeight ?? 0)) >= minConfirms, + height: e.utxo.blockHeight ?? 0, + vout: e.utxo.vout, + spent: e.utxo.used ?? false, + spentHeight: null, // doesn't matter here + coinbase: e.utxo.isCoinbase, + ), + ) + .toList(), + accountIndex: accountIndex, + ); + + return CsPendingTransaction( + pending, + pending.amount, + pending.fee, + pending.txid, + ); + } + + @override + Future createTxMultiDest( + WrappedWallet wallet, { + required List outputs, + required int priority, + required bool sweep, + List? preferredInputs, + required int accountIndex, + required int minConfirms, + required int currentHeight, + }) async { + final pending = await wallet.get().createTxMultiDest( + outputs: outputs + .map( + (e) => lib_wownero.Recipient(address: e.address, amount: e.amount), + ) + .toList(), + paymentId: "", + sweep: sweep, + priority: lib_wownero.TransactionPriority.values.firstWhere( + (e) => e.value == priority, + ), + preferredInputs: preferredInputs + ?.map( + (e) => lib_wownero.Output( + address: e.address!, + hash: e.utxo.txid, + keyImage: e.utxo.keyImage!, + value: e.value, + isFrozen: e.utxo.isBlocked, + isUnlocked: + e.utxo.blockHeight != null && + (currentHeight - (e.utxo.blockHeight ?? 0)) >= minConfirms, + height: e.utxo.blockHeight ?? 0, + vout: e.utxo.vout, + spent: e.utxo.used ?? false, + spentHeight: null, // doesn't matter here + coinbase: e.utxo.isCoinbase, + ), + ) + .toList(), + accountIndex: accountIndex, + ); + + return CsPendingTransaction( + pending, + pending.amount, + pending.fee, + pending.txid, + ); + } + + @override + Future commitTx(WrappedWallet wallet, CsPendingTransaction tx) => wallet + .get() + .commitTx(tx.value as lib_wownero.PendingTransaction); + + @override + Future> getOutputs( + WrappedWallet wallet, { + bool refresh = false, + bool includeSpent = false, + }) async { + final outputs = await wallet.get().getOutputs( + includeSpent: includeSpent, + refresh: refresh, + ); + + return outputs + .map( + (e) => CsOutput( + address: e.address, + hash: e.hash, + keyImage: e.keyImage, + value: e.value, + isFrozen: e.isFrozen, + isUnlocked: e.isUnlocked, + height: e.height, + spentHeight: e.spentHeight, + vout: e.vout, + spent: e.spent, + coinbase: e.coinbase, + ), + ) + .toList(); + } + + @override + Future freezeOutput(WrappedWallet wallet, String keyImage) => + wallet.get().freezeOutput(keyImage); + + @override + Future thawOutput(WrappedWallet wallet, String keyImage) => + wallet.get().thawOutput(keyImage); + + @override + List getWowneroWordList(String language, int seedLength) => + lib_wownero.getWowneroWordList(language, seedWordsLength: seedLength); + + @override + int getHeightByDate(DateTime date) => + cs_wownero_deprecated.getWowneroHeightByDate(date: date); + + @override + bool validateAddress(String address, int network) => + wow_wallet_ffi.validateAddress(address, network); + + @override + String getSeed(WrappedWallet wallet) => + wallet.get().getSeed(); + + @override + Future close(WrappedWallet wallet, {bool save = false}) => + wallet.get().close(save: save); +} + +//END_ON diff --git a/tool/wl_templates/XEL_lib_xelis_interface_impl.template.dart b/tool/wl_templates/XEL_lib_xelis_interface_impl.template.dart index 75941a3908..df1d41ac5d 100644 --- a/tool/wl_templates/XEL_lib_xelis_interface_impl.template.dart +++ b/tool/wl_templates/XEL_lib_xelis_interface_impl.template.dart @@ -2,16 +2,21 @@ import 'dart:convert'; import 'package:logger/logger.dart'; +import 'package:xelis_dart_sdk/src/data_transfer_objects/get_asset/max_supply_mode.dart'; import 'package:xelis_dart_sdk/xelis_dart_sdk.dart' as xelis_sdk; import 'package:xelis_flutter/src/api/api.dart' as xelis_api; import 'package:xelis_flutter/src/api/logger.dart' as xelis_logging; +import 'package:xelis_flutter/src/api/models/wallet_dtos.dart' as x_wallet_dtos; import 'package:xelis_flutter/src/api/network.dart' as x_network; +import 'package:xelis_flutter/src/api/precomputed_tables.dart' as x_tables; +import 'package:xelis_flutter/src/api/progress_report.dart' as x_report; import 'package:xelis_flutter/src/api/seed_search_engine.dart' as x_seed; import 'package:xelis_flutter/src/api/utils.dart' as x_utils; import 'package:xelis_flutter/src/api/wallet.dart' as x_wallet; import 'package:xelis_flutter/src/frb_generated.dart' as xelis_rust; import '../../providers/progress_report/xelis_table_progress_provider.dart'; +import '../../utilities/dynamic_object.dart'; import '../../utilities/logger.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; //END_ON @@ -72,17 +77,30 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { @override Stream createProgressReportStream() { double lastPrintedProgress = 0.0; + XelisTableGenerationStep? lastStep; + return xelis_api.createProgressReportStream().map((report) { return report.when( - tableGeneration: (progress, step, _) { + tableGeneration: (progress, step, message) { final currentStep = XelisTableGenerationStep.fromString(step); - if ((progress - lastPrintedProgress).abs() >= 0.05 || - currentStep != XelisTableGenerationStep.fromString(step) || - progress >= 0.99) { + + final hasProgressJump = + (progress - lastPrintedProgress).abs() >= 0.05; + final stepChanged = currentStep != lastStep; + final isFinished = progress >= 0.99; + + if (hasProgressJump || stepChanged || isFinished) { + final percent = (progress * 100).toStringAsFixed(1); + final extra = (message != null && message.isNotEmpty) + ? ' – $message' + : ''; + Logging.instance.d( - "Xelis Table Generation: $step - ${progress * 100.0}%", + 'Xelis Table Generation: $step - $percent%$extra', ); + lastPrintedProgress = progress; + lastStep = currentStep; } return XelisTableProgressState( @@ -90,14 +108,24 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { currentStep: currentStep, ); }, - misc: (_) => const XelisTableProgressState(), + misc: (message) { + if (message != null && message.isNotEmpty) { + Logging.instance.d('Xelis Table Generation (misc): $message'); + } + return const XelisTableProgressState(); + }, ); }); } @override - bool isAddressValid({required String address}) => - x_utils.isAddressValid(strAddress: address); + bool isAddressValid({ + required String address, + required CryptoCurrencyNetwork network, + }) => x_utils.isAddressValid( + strAddress: address, + network: network.xelisNetwork, + ); @override bool validateSeedWord(String word) { @@ -124,7 +152,11 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { json['data'] as Map, ); - yield NewAsset(data.name, data.decimals, data.maxSupply); + yield NewAsset( + data.name, + data.decimals, + DynamicObject(data.maxSupply), + ); case xelis_sdk.WalletEvent.newTransaction: final tx = xelis_sdk.TransactionEntry.fromJson( json['data'] as Map, @@ -138,11 +170,19 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { topoheight: tx.topoheight, ), ); + case xelis_sdk.WalletEvent.newPendingTransaction: + continue; case xelis_sdk.WalletEvent.balanceChanged: final data = xelis_sdk.BalanceChangedEvent.fromJson( json['data'] as Map, ); yield BalanceChanged(data.assetHash, data.balance); + case xelis_sdk.WalletEvent.trackAsset: + // TODO + continue; + case xelis_sdk.WalletEvent.untrackAsset: + // TODO + continue; case xelis_sdk.WalletEvent.rescan: yield Rescan(json['data']['start_topoheight'] as int); case xelis_sdk.WalletEvent.online: @@ -151,6 +191,9 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { yield const Offline(); case xelis_sdk.WalletEvent.historySynced: yield HistorySynced(json['data']['topoheight'] as int); + case xelis_sdk.WalletEvent.syncError: + print("ERROR SYNCING: ${json['data']['message']}"); + yield const Offline(); // TODO: make a message describing the error with json['data']['message'] } } catch (e, s) { Logging.instance.e( @@ -176,11 +219,20 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { @override Future updateTables({ required String precomputedTablesPath, - required bool l1Low, - }) => x_wallet.updateTables( - precomputedTablesPath: precomputedTablesPath, - l1Low: l1Low, - ); + required bool stack_l1Low, + }) async { + // TODO: add more granular table size management interface + // for now, just patching the old system into the new FFI API + + x_tables.PrecomputedTableType tableType = stack_l1Low + ? x_tables.PrecomputedTableType.l1Low() + : x_tables.PrecomputedTableType.l1Full(); + + return x_wallet.updateTables( + precomputedTablesPath: precomputedTablesPath, + precomputedTableType: tableType, + ); + } @override Future getSeed(OpaqueXelisWallet wallet) => wallet.actual.getSeed(); @@ -195,8 +247,15 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { String? seed, String? privateKey, String? precomputedTablesPath, - bool? l1Low, + bool? stack_l1Low, }) async { + // TODO: add more granular table size management interface + // for now, just patching the old system into the new FFI API + + x_tables.PrecomputedTableType tableType = stack_l1Low ?? false + ? x_tables.PrecomputedTableType.l1Low() + : x_tables.PrecomputedTableType.l1Full(); + final wallet = await x_wallet.createXelisWallet( name: name, directory: directory, @@ -205,7 +264,7 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { seed: seed, network: network.xelisNetwork, precomputedTablesPath: precomputedTablesPath, - l1Low: l1Low, + precomputedTableType: tableType, ); return OpaqueXelisWallet(wallet); @@ -219,15 +278,22 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { required String password, required CryptoCurrencyNetwork network, String? precomputedTablesPath, - bool? l1Low, + bool? stack_l1Low, }) async { + // TODO: add more granular table size management interface + // for now, just patching the old system into the new FFI API + + x_tables.PrecomputedTableType tableType = (stack_l1Low ?? false) + ? x_tables.PrecomputedTableType.l1Low() + : x_tables.PrecomputedTableType.l1Full(); + final wallet = await x_wallet.openXelisWallet( name: name, directory: directory, password: password, network: network.xelisNetwork, precomputedTablesPath: precomputedTablesPath, - l1Low: l1Low, + precomputedTableType: tableType, ); return OpaqueXelisWallet(wallet); @@ -274,7 +340,7 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { }) => wallet.actual.createTransfersTransaction( transfers: transfers .map( - (e) => x_wallet.Transfer( + (e) => x_wallet_dtos.Transfer( floatAmount: e.floatAmount, strAddress: e.strAddress, assetHash: e.assetHash, @@ -291,7 +357,7 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { }) => wallet.actual.estimateFees( transfers: transfers .map( - (e) => x_wallet.Transfer( + (e) => x_wallet_dtos.Transfer( floatAmount: e.floatAmount, strAddress: e.strAddress, assetHash: e.assetHash, diff --git a/tool/wl_templates/XMR_cs_monero_interface_impl.template.dart b/tool/wl_templates/XMR_cs_monero_interface_impl.template.dart index af0016e6a6..b957ad36dd 100644 --- a/tool/wl_templates/XMR_cs_monero_interface_impl.template.dart +++ b/tool/wl_templates/XMR_cs_monero_interface_impl.template.dart @@ -4,8 +4,6 @@ import 'package:cs_monero/src/deprecated/get_height_by_date.dart' as cs_monero_deprecated; import 'package:cs_monero/src/ffi_bindings/monero_wallet_bindings.dart' as xmr_wallet_ffi; -import 'package:cs_monero/src/ffi_bindings/wownero_wallet_bindings.dart' - as wow_wallet_ffi; //END_ON import '../../models/input.dart'; @@ -15,7 +13,7 @@ import '../interfaces/cs_salvium_interface.dart' show WrappedWallet; CsMoneroInterface get csMonero => _getInterface(); //OFF -CsMoneroInterface _getInterface() => throw Exception("XMR/WOW not enabled!"); +CsMoneroInterface _getInterface() => throw Exception("XMR not enabled!"); //END_OFF //ON @@ -29,10 +27,7 @@ class _CsMoneroInterfaceImpl extends CsMoneroInterface { lib_monero.Logging.useLogger = enable; @override - bool walletExists(String path, {required CsCoin csCoin}) => switch (csCoin) { - CsCoin.monero => lib_monero.MoneroWallet.isWalletExist(path), - CsCoin.wownero => lib_monero.WowneroWallet.isWalletExist(path), - }; + bool walletExists(String path) => lib_monero.MoneroWallet.isWalletExist(path); @override Future estimateFee( @@ -69,21 +64,19 @@ class _CsMoneroInterfaceImpl extends CsMoneroInterface { @override Future loadWallet( String walletId, { - required CsCoin csCoin, required String path, required String password, + int network = 0, // default to mainnet }) async { - return WrappedWallet(switch (csCoin) { - CsCoin.monero => await lib_monero.MoneroWallet.loadWallet( - path: path, - password: password, - ), - - CsCoin.wownero => await lib_monero.WowneroWallet.loadWallet( + return WrappedWallet( + await lib_monero.MoneroWallet.loadWallet( path: path, password: password, + networkType: lib_monero.Network.values.firstWhere( + (e) => e.value == network, + ), ), - }); + ); } @override @@ -96,56 +89,38 @@ class _CsMoneroInterfaceImpl extends CsMoneroInterface { int getTxPriorityNormal() => lib_monero.TransactionPriority.normal.value; @override - String getAddress( + Future getAddress( WrappedWallet wallet, { int accountIndex = 0, int addressIndex = 0, - }) => wallet - .get() - .getAddress(accountIndex: accountIndex, addressIndex: addressIndex) - .value; + }) async => (await wallet.get().getAddress( + accountIndex: accountIndex, + addressIndex: addressIndex, + )).value; @override Future getCreatedWallet({ - required CsCoin csCoin, required String path, required String password, required int wordCount, required String seedOffset, + int network = 0, // default to mainnet }) async { - final lib_monero.Wallet wallet; - - switch (csCoin) { - case CsCoin.monero: - final type = switch (wordCount) { - 16 => lib_monero.MoneroSeedType.sixteen, - 25 => lib_monero.MoneroSeedType.twentyFive, - _ => throw Exception("Invalid mnemonic word count: $wordCount"), - }; - - wallet = await lib_monero.MoneroWallet.create( - path: path, - password: password, - seedType: type, - seedOffset: seedOffset, - ); - break; - - case CsCoin.wownero: - final type = switch (wordCount) { - 16 => lib_monero.WowneroSeedType.sixteen, - 25 => lib_monero.WowneroSeedType.twentyFive, - _ => throw Exception("Invalid mnemonic word count: $wordCount"), - }; - - wallet = await lib_monero.WowneroWallet.create( - path: path, - password: password, - seedType: type, - seedOffset: seedOffset, - ); - break; - } + final type = switch (wordCount) { + 16 => lib_monero.MoneroSeedType.sixteen, + 25 => lib_monero.MoneroSeedType.twentyFive, + _ => throw Exception("Invalid mnemonic word count: $wordCount"), + }; + + final wallet = await lib_monero.MoneroWallet.create( + path: path, + password: password, + seedType: type, + seedOffset: seedOffset, + networkType: lib_monero.Network.values.firstWhere( + (e) => e.value == network, + ), + ); return WrappedWallet(wallet); } @@ -153,63 +128,53 @@ class _CsMoneroInterfaceImpl extends CsMoneroInterface { @override Future getRestoredWallet({ required String walletId, - required CsCoin csCoin, required String path, required String password, required String mnemonic, required String seedOffset, + int network = 0, // default to mainnet int height = 0, }) async { - return WrappedWallet(switch (csCoin) { - CsCoin.monero => await lib_monero.MoneroWallet.restoreWalletFromSeed( - path: path, - password: password, - seed: mnemonic, - restoreHeight: height, - seedOffset: seedOffset, - ), - - CsCoin.wownero => await lib_monero.WowneroWallet.restoreWalletFromSeed( + return WrappedWallet( + await lib_monero.MoneroWallet.restoreWalletFromSeed( path: path, password: password, seed: mnemonic, restoreHeight: height, seedOffset: seedOffset, + networkType: lib_monero.Network.values.firstWhere( + (e) => e.value == network, + ), ), - }); + ); } @override Future getRestoredFromViewKeyWallet({ required String walletId, - required CsCoin csCoin, required String path, required String password, required String address, required String privateViewKey, + int network = 0, // default to mainnet int height = 0, }) async { - return WrappedWallet(switch (csCoin) { - CsCoin.monero => await lib_monero.MoneroWallet.createViewOnlyWallet( - path: path, - password: password, - address: address, - viewKey: privateViewKey, - restoreHeight: height, - ), - - CsCoin.wownero => await lib_monero.WowneroWallet.createViewOnlyWallet( + return WrappedWallet( + await lib_monero.MoneroWallet.createViewOnlyWallet( path: path, password: password, address: address, viewKey: privateViewKey, restoreHeight: height, + networkType: lib_monero.Network.values.firstWhere( + (e) => e.value == network, + ), ), - }); + ); } @override - String getTxKey(WrappedWallet wallet, String txid) => + Future getTxKey(WrappedWallet wallet, String txid) => wallet.get().getTxKey(txid); @override @@ -217,19 +182,19 @@ class _CsMoneroInterfaceImpl extends CsMoneroInterface { wallet.get().save(); @override - String getPublicViewKey(WrappedWallet wallet) => + Future getPublicViewKey(WrappedWallet wallet) => wallet.get().getPublicViewKey(); @override - String getPrivateViewKey(WrappedWallet wallet) => + Future getPrivateViewKey(WrappedWallet wallet) => wallet.get().getPrivateViewKey(); @override - String getPublicSpendKey(WrappedWallet wallet) => + Future getPublicSpendKey(WrappedWallet wallet) => wallet.get().getPublicSpendKey(); @override - String getPrivateSpendKey(WrappedWallet wallet) => + Future getPrivateSpendKey(WrappedWallet wallet) => wallet.get().getPrivateSpendKey(); @override @@ -237,11 +202,11 @@ class _CsMoneroInterfaceImpl extends CsMoneroInterface { wallet.get().isSynced(); @override - void startSyncing(WrappedWallet wallet) => + Future startSyncing(WrappedWallet wallet) => wallet.get().startSyncing(); @override - void stopSyncing(WrappedWallet wallet) => + Future stopSyncing(WrappedWallet wallet) => wallet.get().stopSyncing(); @override @@ -268,23 +233,23 @@ class _CsMoneroInterfaceImpl extends CsMoneroInterface { ); @override - void startListeners(WrappedWallet wallet) => + Future startListeners(WrappedWallet wallet) => wallet.get().startListeners(); @override - void stopListeners(WrappedWallet wallet) => + Future stopListeners(WrappedWallet wallet) => wallet.get().stopListeners(); @override - int getRefreshFromBlockHeight(WrappedWallet wallet) => + Future getRefreshFromBlockHeight(WrappedWallet wallet) => wallet.get().getRefreshFromBlockHeight(); @override - void setRefreshFromBlockHeight(WrappedWallet wallet, int height) => + Future setRefreshFromBlockHeight(WrappedWallet wallet, int height) => wallet.get().setRefreshFromBlockHeight(height); @override - Future rescanBlockchain(WrappedWallet wallet) => + Future rescanBlockchain(WrappedWallet wallet) => wallet.get().rescanBlockchain(); @override @@ -320,14 +285,16 @@ class _CsMoneroInterfaceImpl extends CsMoneroInterface { }) => wallet.get().getAllTxids(refresh: refresh); @override - BigInt? getBalance(WrappedWallet wallet, {int accountIndex = 0}) => + Future getBalance(WrappedWallet wallet, {int accountIndex = 0}) => wallet.get().getBalance(accountIndex: accountIndex); @override - BigInt? getUnlockedBalance(WrappedWallet wallet, {int accountIndex = 0}) => - wallet.get().getUnlockedBalance( - accountIndex: accountIndex, - ); + Future getUnlockedBalance( + WrappedWallet wallet, { + int accountIndex = 0, + }) => wallet.get().getUnlockedBalance( + accountIndex: accountIndex, + ); @override Future> getAllTxs( @@ -542,28 +509,20 @@ class _CsMoneroInterfaceImpl extends CsMoneroInterface { lib_monero.getMoneroWordList(language); @override - List getWowneroWordList(String language, int seedLength) => - lib_monero.getWowneroWordList(language, seedWordsLength: seedLength); + int getHeightByDate(DateTime date) => + cs_monero_deprecated.getMoneroHeightByDate(date: date); @override - int getHeightByDate(DateTime date, {required CsCoin csCoin}) => - switch (csCoin) { - CsCoin.monero => cs_monero_deprecated.getMoneroHeightByDate(date: date), - CsCoin.wownero => cs_monero_deprecated.getWowneroHeightByDate( - date: date, - ), - }; + bool validateAddress(String address, int network) => + xmr_wallet_ffi.validateAddress(address, network); @override - bool validateAddress(String address, int network, {required CsCoin csCoin}) => - switch (csCoin) { - CsCoin.monero => xmr_wallet_ffi.validateAddress(address, network), - CsCoin.wownero => wow_wallet_ffi.validateAddress(address, network), - }; + Future getSeed(WrappedWallet wallet) => + wallet.get().getSeed(); @override - String getSeed(WrappedWallet wallet) => - wallet.get().getSeed(); + Future close(WrappedWallet wallet, {bool save = false}) => + wallet.get().close(save: save); } //END_ON