diff --git a/.docker/android-sdk.dockerfile b/.docker/android-sdk.dockerfile index c4f234a9ea..72699d2ec2 100644 --- a/.docker/android-sdk.dockerfile +++ b/.docker/android-sdk.dockerfile @@ -41,7 +41,7 @@ RUN set -o xtrace \ ruby-full ruby-bundler libstdc++6 libpulse0 libglu1-mesa locales lcov libsqlite3-dev --no-install-recommends \ # For Linux build xz-utils acl \ - clang cmake git \ + binutils clang cmake git lld llvm \ ninja-build pkg-config \ libgtk-3-dev liblzma-dev \ libstdc++-12-dev libsecret-1-dev \ @@ -68,4 +68,4 @@ RUN set -o xtrace \ "build-tools;$ANDROID_BUILD_TOOLS_VERSION" \ && yes | sdkmanager "ndk;$ANDROID_NDK_VERSION" \ && sudo chown -R $USER:$USER $ANDROID_HOME \ - && sudo setfacl -R -m u:$USER:rwX -m d:u:$USER:rwX /usr/local \ No newline at end of file + && sudo setfacl -R -m u:$USER:rwX -m d:u:$USER:rwX /usr/local diff --git a/.docker/build.sh b/.docker/build.sh index a40cb17e35..c74827952b 100755 --- a/.docker/build.sh +++ b/.docker/build.sh @@ -54,6 +54,7 @@ docker build $PLATFORM_FLAG -f .docker/komodo-wallet-android.dockerfile . -t kom mkdir -p ./build COMMIT_HASH=$(git rev-parse --short HEAD | cut -c1-7) +BUILD_DATE=$(date -u '+%Y-%m-%dT%H:%MZ') # Only pass GITHUB_API_PUBLIC_READONLY_TOKEN as environment variable ENV_ARGS="" @@ -63,10 +64,14 @@ fi # Build command logic BUILD_COMMAND="flutter build $BUILD_TARGET --no-pub --$BUILD_MODE" +if [ "$BUILD_TARGET" = "web" ]; then + BUILD_COMMAND="$BUILD_COMMAND --wasm" +fi # Prepare build command with feedback service credentials BUILD_CMD="$BUILD_COMMAND" -# Add commit hash to build command +# Add commit hash and build date to build command BUILD_CMD="$BUILD_CMD --dart-define=COMMIT_HASH=$COMMIT_HASH" +BUILD_CMD="$BUILD_CMD --dart-define=BUILD_DATE=$BUILD_DATE" # Check and add the shared Trello board and list IDs if they are available HAVE_TRELLO_IDS=false diff --git a/.docker/komodo-wallet-android.dockerfile b/.docker/komodo-wallet-android.dockerfile index d690c4cd91..fb40209619 100644 --- a/.docker/komodo-wallet-android.dockerfile +++ b/.docker/komodo-wallet-android.dockerfile @@ -1,6 +1,6 @@ FROM komodo/android-sdk:35 AS final -ENV FLUTTER_VERSION="3.35.3" +ENV FLUTTER_VERSION="3.41.4" ENV HOME="/home/komodo" ENV USER="komodo" ENV PATH=$PATH:$HOME/flutter/bin diff --git a/.github/actions/flutter-deps/action.yml b/.github/actions/flutter-deps/action.yml index 0aade2d1b2..f5ae3ded2a 100644 --- a/.github/actions/flutter-deps/action.yml +++ b/.github/actions/flutter-deps/action.yml @@ -8,7 +8,7 @@ runs: uses: subosito/flutter-action@v2 with: # NB! Keep up-to-date with the flutter version used for development - flutter-version: "3.35.3" + flutter-version: "3.41.4" channel: "stable" - name: Prepare build directory diff --git a/.github/actions/generate-assets/action.yml b/.github/actions/generate-assets/action.yml index 4d80d2f738..ac1fa89d1a 100644 --- a/.github/actions/generate-assets/action.yml +++ b/.github/actions/generate-assets/action.yml @@ -8,7 +8,7 @@ inputs: BUILD_COMMAND: description: "The flutter build command to run to generate assets for the deployment build" required: false - default: "flutter build web --no-pub --release" + default: "flutter build web --no-pub --release --wasm" # Optional Trello feedback provider configuration TRELLO_API_KEY: @@ -72,6 +72,8 @@ runs: fi # Export for subsequent steps echo "COMMIT_HASH=$COMMIT_HASH" >> $GITHUB_ENV + BUILD_DATE=$(date -u '+%Y-%m-%dT%H:%MZ') + echo "BUILD_DATE=$BUILD_DATE" >> $GITHUB_ENV - name: Fetch packages and generate assets id: fetch_and_build shell: bash @@ -101,8 +103,9 @@ runs: # Prepare build command with feedback service credentials BUILD_CMD="${{ inputs.BUILD_COMMAND }}" - # Add commit hash to build command + # Add commit hash and build date to build command BUILD_CMD="$BUILD_CMD --dart-define=COMMIT_HASH=$COMMIT_HASH" + BUILD_CMD="$BUILD_CMD --dart-define=BUILD_DATE=$BUILD_DATE" # Check and add the shared Trello board and list IDs if they are available HAVE_TRELLO_IDS=false @@ -155,13 +158,14 @@ runs: BUILD_CMD="$BUILD_CMD --dart-define=CI=true --dart-define=ANALYTICS_DISABLED=true" fi - # Run flutter build once to download coin icons and config files. - # This step is expected to "fail", since flutter build has to run again - # after the assets are downloaded to register them in AssetManifest.bin + # Run flutter build once to download coin icons and config files. + # The second build must re-register them in AssetManifest.bin, so we + # wipe the build directory afterwards -- but preserve native_assets + # which desktop builds generate and need to reuse. echo "" flutter pub get --enforce-lockfile > /dev/null 2>&1 || true $BUILD_CMD > /dev/null 2>&1 || true - rm -rf build/* + find build -mindepth 1 -maxdepth 1 ! -name 'native_assets' -exec rm -rf {} + 2>/dev/null || true # Run flutter build and capture its output flutter pub get --enforce-lockfile diff --git a/.github/actions/releases/setup-ios/action.yml b/.github/actions/releases/setup-ios/action.yml index 87434cdb78..982d4f4559 100644 --- a/.github/actions/releases/setup-ios/action.yml +++ b/.github/actions/releases/setup-ios/action.yml @@ -50,6 +50,12 @@ runs: xcodebuild -downloadPlatform iOS flutter pub get --enforce-lockfile + export GITHUB_API_PUBLIC_READONLY_TOKEN="${{ github.token }}" + # The first bundle run may update transformer-managed inputs and exit + # non-zero. Re-running picks up those updates, matching CI asset builds. + flutter build bundle --release --no-pub >/dev/null 2>&1 || true + flutter build bundle --release --no-pub + cd ios pod install diff --git a/.github/actions/releases/setup-macos/action.yml b/.github/actions/releases/setup-macos/action.yml index 157d6e73e8..0cc54f2a64 100644 --- a/.github/actions/releases/setup-macos/action.yml +++ b/.github/actions/releases/setup-macos/action.yml @@ -53,6 +53,12 @@ runs: run: | flutter pub get --enforce-lockfile + export GITHUB_API_PUBLIC_READONLY_TOKEN="${{ github.token }}" + # The first bundle run may update transformer-managed inputs and exit + # non-zero. Re-running picks up those updates, matching CI asset builds. + flutter build bundle --release --no-pub >/dev/null 2>&1 || true + flutter build bundle --release --no-pub + cd macos pod install diff --git a/.github/workflows/desktop-builds.yml b/.github/workflows/desktop-builds.yml index b13506949c..ac6ec48c46 100644 --- a/.github/workflows/desktop-builds.yml +++ b/.github/workflows/desktop-builds.yml @@ -80,10 +80,13 @@ jobs: id: build env: GITHUB_API_PUBLIC_READONLY_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Disable code signing for macOS PR builds - CODE_SIGNING_ALLOWED: ${{ matrix.platform == 'macos' && github.event_name == 'pull_request' && 'NO' || '' }} - CODE_SIGNING_REQUIRED: ${{ matrix.platform == 'macos' && github.event_name == 'pull_request' && 'NO' || '' }} - EXPANDED_CODE_SIGN_IDENTITY: ${{ matrix.platform == 'macos' && github.event_name == 'pull_request' && '-' || '' }} + # Flutter only forwards Xcode overrides with the FLUTTER_XCODE_ prefix. + FLUTTER_XCODE_CODE_SIGNING_ALLOWED: ${{ matrix.platform == 'macos' && github.event_name == 'pull_request' && 'NO' || '' }} + FLUTTER_XCODE_CODE_SIGNING_REQUIRED: ${{ matrix.platform == 'macos' && github.event_name == 'pull_request' && 'NO' || '' }} + FLUTTER_XCODE_CODE_SIGN_IDENTITY: ${{ matrix.platform == 'macos' && github.event_name == 'pull_request' && '' || '' }} + FLUTTER_XCODE_CODE_SIGN_STYLE: ${{ matrix.platform == 'macos' && github.event_name == 'pull_request' && 'Manual' || '' }} + FLUTTER_XCODE_DEVELOPMENT_TEAM: ${{ matrix.platform == 'macos' && github.event_name == 'pull_request' && '' || '' }} + FLUTTER_XCODE_PROVISIONING_PROFILE_SPECIFIER: ${{ matrix.platform == 'macos' && github.event_name == 'pull_request' && '' || '' }} uses: ./.github/actions/generate-assets with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/roll-sdk-packages.yml b/.github/workflows/roll-sdk-packages.yml index 32af130c89..5a7a015083 100644 --- a/.github/workflows/roll-sdk-packages.yml +++ b/.github/workflows/roll-sdk-packages.yml @@ -48,7 +48,7 @@ jobs: uses: subosito/flutter-action@v2 with: # NB! Keep up-to-date with the flutter version used for development - flutter-version: "3.35.3" + flutter-version: "3.41.4" channel: "stable" - name: Determine configuration diff --git a/.github/workflows/ui-tests-on-pr.yml b/.github/workflows/ui-tests-on-pr.yml index 1196638222..854539ef9b 100644 --- a/.github/workflows/ui-tests-on-pr.yml +++ b/.github/workflows/ui-tests-on-pr.yml @@ -50,12 +50,21 @@ jobs: - name: Install Chrome and chromedriver id: setup_chrome if: ${{ matrix.browser == 'chrome' }} - uses: browser-actions/setup-chrome@v1 + uses: browser-actions/setup-chrome@v2 with: - chrome-version: 116.0.5845.96 + chrome-version: stable install-chromedriver: true install-dependencies: true + - name: Print Chrome setup + id: print_chrome_setup + if: ${{ matrix.browser == 'chrome' }} + run: | + echo "Chrome path: ${{ steps.setup_chrome.outputs.chrome-path }}" + "${{ steps.setup_chrome.outputs.chrome-path }}" --version + echo "ChromeDriver path: ${{ steps.setup_chrome.outputs.chromedriver-path }}" + "${{ steps.setup_chrome.outputs.chromedriver-path }}" --version + - name: Enable safaridriver (sudo) (MacOS) id: enable_safari if: ${{ matrix.browser == 'safari' }} @@ -85,6 +94,7 @@ jobs: continue-on-error: true env: GITHUB_API_PUBLIC_READONLY_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CHROME_EXECUTABLE: ${{ matrix.browser == 'chrome' && steps.setup_chrome.outputs.chrome-path || '' }} run: | dart run_integration_tests.dart \ -d ${{ matrix.display }} \ diff --git a/.github/workflows/validate-code-guidelines.yml b/.github/workflows/validate-code-guidelines.yml index c4c3c90e4c..185cca64f6 100644 --- a/.github/workflows/validate-code-guidelines.yml +++ b/.github/workflows/validate-code-guidelines.yml @@ -29,9 +29,18 @@ jobs: with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Resolve SDK workspace dependencies + id: sdk_pub_get + run: | + if [ -f sdk/pubspec.lock ]; then + dart pub get --enforce-lockfile -C sdk + else + dart pub get -C sdk + fi + - name: Validate dart code id: validate_dart run: | - flutter analyze + flutter analyze --no-fatal-warnings --no-fatal-infos # Currently skipped due to many changes. Will be enabled in the future after doing full sweep of the codebase # dart format --set-exit-if-changed . diff --git a/.gitignore b/.gitignore index f8f9a9994d..4fe963fc70 100644 --- a/.gitignore +++ b/.gitignore @@ -86,4 +86,11 @@ AGENTS_1.md dist/ # KDF generated binaries -web/kdf/kdf/bin/ \ No newline at end of file +web/kdf/kdf/bin/ + +# AI tool local skill bundles; keep project-scoped .claude/.cursor config trackable. +/.claude/skills/ +/.cursor/skills/ +/.nano-banana-config.json +/generated_imgs +/.1code diff --git a/.gitmodules b/.gitmodules index 8973da54d1..d5c3298f77 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "sdk"] path = sdk url = https://github.com/GLEECBTC/komodo-defi-sdk-flutter.git - branch = dev + branch = main update = checkout fetchRecurseSubmodules = on-demand ignore = dirty diff --git a/.metadata b/.metadata index d661586e18..53c60d981b 100644 --- a/.metadata +++ b/.metadata @@ -4,8 +4,8 @@ # This file should be version controlled. version: - revision: b06b8b2710955028a6b562f5aa6fe62941d6febf - channel: unknown + revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 + channel: stable project_type: app diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cd23b4009..cc6733947d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,146 @@ +# Gleec Wallet v0.9.4 Release Notes + +This release packages the current `dev` branch work for the next `main` update with broader asset support, a stronger web runtime, and a much larger polish pass across the wallet. Highlights include TRON and SIA flows on top of `komodo-defi-sdk v0.5.0`, Flutter Web WASM support, runtime-loaded legal documents, refreshed wallet and trading surfaces, and the QA/release infrastructure that came out of the documented polish program. + +## 🚀 New Features + +- **TRON and TRC20 Wallet Support** ([@CharlVS], #3446) - Add TRON activation and withdrawal flows in the wallet on top of the latest SDK roll. +- **Custom Token Import and TRON Fiat Coverage** ([@CharlVS], #3446) - Expand custom-token import handling and map TRON assets cleanly into the supported fiat-provider flows. +- **SIA Activation and Withdrawal Support** ([@CharlVS], #3449) - Complete SIA activation and withdrawal flows and align the app with the latest SDK behavior. +- **Flutter Web WASM Runtime** ([@CharlVS], #3439) - Enable the full Flutter WASM runtime path for web builds with the required platform, persistence, and interop updates. +- **Smarter Withdrawal Validation and Error Guidance** ([@CharlVS], #3434) - Improve bridge, trade, and withdrawal validation with clearer KDF/RPC error surfacing, memo handling, and send-state feedback. +- **Wallet Import Mode Selection** ([@CharlVS], #3442) - Refine wallet creation and import with clearer import-type selection and persisted HD wallet mode preferences. + +### SDK Updates (komodo-defi-sdk-flutter) + +This release integrates [komodo-defi-sdk v0.5.0](https://github.com/GLEECBTC/komodo-defi-sdk-flutter) with the audited SDK release changes bringing: + +- **TRON and TRC20 Asset Support** ([SDK#316](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/316)) - Add TRON/TRC20 coin models and activation support across the SDK stack. +- **SIA Activation and Withdrawal Support** ([SDK#320](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/320)) - Add first-class SIA activation and withdrawal handling. +- **High-Level Managers and Typed Errors** ([SDK#312](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/312), [SDK#314](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/314)) - Introduce new balance, transaction, and withdrawal manager layers with richer typed error handling and trading stream plumbing. +- **Token Safety, Fee Helpers, and Cleanup Hooks** ([SDK#319](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/319), [SDK#321](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/321)) - Add token safety checks, fee helpers, and custom-token cleanup support. +- **Startup and Withdrawal Hardening** ([SDK#318](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/318), [SDK#322](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/322)) - Tighten startup fallback handling and remove duplicate withdrawal-path behavior. +- **Stream-First RPC and Caching** ([SDK#262](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/262)) - Reduce redundant RPC traffic through managed orderbook and swap-status streams, cached preimage and volume requests, bridge depth dedupe, and slower background polling when trading views are idle. +- **Market Data and Derived Asset Coverage** ([SDK#215](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/215), [SDK#254](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/254)) - Add CoinPaprika fallback quotes and broaden derived-asset protocol coverage in the SDK stack. +- **WASM, Auth, and Streaming Hardening** ([SDK#315](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/315), [SDK#328](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/328), [SDK#329](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/329), [SDK#330](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/330), [SDK#332](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/332)) - Tighten browser interop, metadata safety, test coverage, and streaming startup behavior. + +See the [full SDK changelog](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/releases) for complete details. + +## 🎨 UI/UX Improvements + +- **Wallet Surface Refresh** ([@CharlVS], #3436) - Polish the wallet overview, grouped asset list, charts, coin addresses, faucet view, and coins manager interactions. +- **Coin Detail and Address View Cleanup** ([@CharlVS], #3434, #3436) - Refresh coin detail cards, price charts, address lists, faucet interactions, and transaction presentation. +- **Wallet Workflow Cleanup** ([@CharlVS], #3442) - Streamline wallet creation, import, login, account switching, logout, and hardware-wallet selection flows. +- **Settings and Version Surface Cleanup** ([@CharlVS], #3442) - Expand settings presentation with clearer app-version details, balance-visibility preferences, and trading-bot controls. +- **Responsive Trading Polish** ([@CharlVS], #3434, #3443) - Refine trading details, swap history, maker and taker forms, coin selectors, and available-balance displays. +- **Market Maker and NFT Mobile Controls** ([@CharlVS], #3443) - Improve market-maker configuration screens and mobile NFT transaction filters. +- **Search and Balance Visibility Improvements** ([@CharlVS], #3451, #3453) - Preserve search focus across rebuilds and move the hide-balance control into the summary cards. +- **Main Navigation Reorder** ([@CharlVS], #3456) - Place the Swap tab before Buy/Sell in the primary navigation. + +
More polish work... + +- **Address and Key Layout Polish** ([@CharlVS], #3436) - Resolve mobile address alignment, full-address dialog sizing, grouped asset readability, and safer private-key list presentation. +- **Activation and Input Ergonomics** ([@CharlVS], #3434, #3436) - Add keyboard-aware spacing in activation flows, tighten memo/input typography, and keep blocked-state trading actions readable on smaller screens. +- **Coin Page Density Improvements** ([@CharlVS], #3436) - Rebalance charts, history placement, grouped assets, and wallet overview spacing so higher-value data stays above the fold. +- **Chart Shortcuts and First-Load Cleanup** ([@CharlVS], #3436) - Let price surfaces open charts more directly and reduce confusing first-load chart values and statistics-card presentation glitches. +- **Portfolio List and Selector Cleanup** ([@CharlVS], #3436, #3443) - Improve pinned parent assets, searchable selectors, table sorting, dropdown alignment, and buy/sell tab consistency. +- **Responsive Auth and Navigation Controls** ([@CharlVS], #3442, #3456) - Keep logout and account controls anchored across resize, move auth actions into steadier navigation surfaces, and stabilize overlay behavior on scroll and orientation changes. +- **Settings Metadata and Quick Actions** ([@CharlVS], #3442, #3453) - Add richer version/build metadata, copy actions, stealth-mode balance hiding, and cleaner settings affordances. +- **Swap Export and Row Actions** ([@CharlVS], #3443) - Add compact copy/export actions to mobile swap history and in-progress rows. +- **Tab and Responsive Navigation Cleanup** ([@CharlVS], #3443, #3456) - Stabilize mobile tab overflow, DEX header behavior, and the final primary-nav ordering. +- **Market Maker Mobile Parity** ([@CharlVS], #3442, #3443) - Bring more desktop controls, update cadence settings, and cleaner form structure into the mobile maker flow. +- **Search and Scrollbar Reliability** ([@CharlVS], #3451, #3453) - Preserve search focus across rebuilds and keep scrollbars from disappearing or stealing focus in wallet and filter views. +- **Theme, Toggle, and Icon Consistency** ([@CharlVS], #3436, #3453) - Replace lingering hard-coded styles, unify toggle treatments, fix statistics and NFT/icon tint contrast, and refresh summary cards, chart tooltips, and balance-visibility controls. +- **Faucet and Wait-State Feedback** ([@CharlVS], #3436) - Improve faucet waiting states and reduce confusing first-load chart feedback. +- **Wallet Import and Auth Flow Cleanup** ([@CharlVS], #3442) - Tighten import-type selection, wallet list behavior, logout routing, account switching, hardware-wallet selection, wallet tags, BIP39 suggestions, custom-seed flows, and multi-address guidance. +- **Coins Manager and Activation Controls** ([@CharlVS], #3436, #3442) - Consolidate bulk-enable and bulk-disable flows, correct select-all behavior, and make activation controls clearer in wallet management screens. +- **Trezor Wallet-Mode Guidance** ([@CharlVS], #3442, #3449) - Clarify wallet-mode-only limitations and surface better guidance around hardware-wallet interactions. +- **Transaction and Withdraw Flow Polish** ([@CharlVS], #3434) - Improve validation, memo handling, fee-priority controls, custom-fee defaults, multi-address messaging, confirm/send states, and transaction detail presentation in the wallet flow. + +
+ +## ⚡ Performance & Responsiveness + +- **Stream-First Trading and History Refresh** ([@CharlVS], #3434, #3444) - Reduce trading-detail polling pressure, improve swap-history loading, and keep order/swap data fresher in active views. +- **Address and Activation Loading Improvements** ([@CharlVS], #3434, #3444, #3449, #3454) - Improve address prefetching, reduce competing activation work, and prevent activation-state races during wallet use. +- **Transaction Refresh and Accuracy Cleanup** ([@CharlVS], #3434, #3444) - Refresh transaction state more reliably after broadcasts and reduce duplicate, stale, or misleading history rows. +- **Web Responsiveness Hardening** ([@CharlVS], #3439, #3445) - Keep web RPC paths, WASM bindings, and browser-side caches from degrading UI responsiveness. +- **SDK-Led RPC Reduction** ([@CharlVS], #3446) - Pull in the newer SDK caching and streaming model to cut redundant wallet and trading RPC activity. + +
Completed responsiveness work... + +- **Swap and Orderbook Polling Cleanup** ([@CharlVS], #3434) - Replace aggressive DEX polling with stream-first and cached request behavior where possible. +- **Preimage, Volume, and Depth Request Dedupe** ([@CharlVS], #3434, #3446) - Cache `trade_preimage`, maker and taker volume calls, and bridge orderbook depth checks so rapid form edits and validations stop hammering RPC. +- **History Loading States and Accurate Swap Data** ([@CharlVS], #3434, #3444) - Add clearer long-history loading behavior and respect recoverable/fractional swap data in the UI. +- **Adaptive Background Polling** ([@CharlVS], #3434, #3446) - Reduce recurring `my_recent_swaps` payload pressure, slow swaps and orders refresh outside active DEX routes, and keep balance sweeps as fallback only when live watchers are unavailable. +- **Address Discovery and HD Balance Freshness** ([@CharlVS], #3434, #3444) - Improve address loading after import, tab switches, and HD wallet first-use flows. +- **Activation Retry and Cancel Guardrails** ([@CharlVS], #3449, #3454) - Reduce fruitless activation loops, rollback canceled activation config state, and avoid overlapping activation work. +- **Zero-Value and Duplicate Transaction Regressions** ([@CharlVS], #3434, #3444) - Cut transient zeroes, duplicate rows, and post-broadcast staleness in wallet history. +- **Web Console and wasm-bindgen Error Containment** ([@CharlVS], #3445) - Guard malformed web responses and transport failures so browser sessions stay responsive. + +
+ +## 🐛 Bug Fixes + +- **SDK Manager Alignment** ([@CharlVS], #3444) - Resolve open review findings and align wallet flows with the SDK's newer manager interfaces. +- **Search and Scrollbar Stability** ([@CharlVS], #3451, #3453) - Stabilize list search rebuild behavior and scrollbar focus handling across wallet and filter surfaces. +- **Concurrent Activation Metadata Race** ([@CharlVS], #3454) - Fix activated-coin metadata races during simultaneous coin activations. +- **Web Cache Isolation** ([@CharlVS], #3445) - Separate cache adapters and update JS interop to prevent browser-side state bleed. +- **Desktop Release Asset Preservation** ([@CharlVS], #3448) - Keep generated desktop assets intact during native release builds. +- **SIA and Trezor Flow Guardrails** ([@CharlVS], #3449) - Tighten hardware-wallet and activation edge-case handling around the new SIA flow. + +
Completed validation and workflow fixes... + +- **Localized Error Messaging and Recovery Guidance** ([@CharlVS], #3434, #3444) - Normalize KDF/RPC error families into clearer user-facing guidance for connection, gas, and recoverable-failure cases. +- **Password, Fee, and Send-Max Validation** ([@CharlVS], #3434, #3442) - Improve password length messaging, custom-fee defaults/units, send-max handling, and withdraw-fee priority controls. +- **Activation-State Safety** ([@CharlVS], #3444, #3449, #3454) - Prevent failed or inactive assets from appearing active and disable action buttons when activation has not completed successfully. +- **Sensitive Data Visibility Controls** ([@CharlVS], #3436, #3442) - Keep seed and private-key access behind explicit visibility toggles with safer copy/QR behavior. +- **Modal and Login Flow Guarding** ([@CharlVS], #3442, #3444) - Prevent stray login prompts, accidental modal dismissal, and orientation-related dialog loss. +- **Market Maker Workflow Coverage** ([@CharlVS], #3442, #3443) - Broaden sell-list coverage, preserve order-detail state, expose maker configuration in settings, and support maker-order import/export flows. +- **Trezor Cancellation and Passphrase Guardrails** ([@CharlVS], #3442, #3449) - Surface clearer user-cancelled and invalid-PIN states and require non-empty hidden-wallet passphrases before continuing. +- **Transaction and Swap State Accuracy** ([@CharlVS], #3434, #3444) - Keep confirmation labels honest, stop false unrecoverable swap states, and avoid stale inactive assets lingering in primary wallet views. +- **Wallet Naming, Tags, and Session Memory** ([@CharlVS], #3442) - Add wallet naming guardrails, persisted HD mode, wallet metadata tags, and better remembered-session handling. +- **Patience and Progress Feedback** ([@CharlVS], #3436, #3442) - Replace bare wait states with clearer progress messaging in longer wallet and activation flows. + +
+ +## 🔒 Security & Compliance + +- **Runtime Legal Documents** ([@CharlVS], #3427) - Load the EULA, privacy notice, terms of service, and KYC policy from GitHub-backed content inside the app. +- **In-App Policy Pages** ([@CharlVS], #3427) - Add routed privacy notice and KYC policy views directly inside the settings experience. + +## 💻 Platform-Specific Changes + +### Web + +- **Flutter WASM Runtime Path** ([@CharlVS], #3439) - Ship the platform, persistence, and browser interop changes needed for Flutter's full WASM runtime. +- **Browser Cache and File-Loader Isolation** ([@CharlVS], #3445) - Separate browser cache adapters and refresh web file-loading behavior for more predictable state handling. + +### Native Platforms + +- **Flutter 3.41.3 Host Upgrade** ([@CharlVS], #3441) - Upgrade the Android, iOS, Linux, macOS, and Windows host projects to Flutter 3.41.3. +- **Desktop Build Asset Preservation** ([@CharlVS], #3448) - Keep native desktop asset bundles intact during release builds. + +## 🔧 Technical Improvements + +- **Automated QA Runner** ([@CharlVS], #3440) - Add a dedicated automated test runner, manual companion, Docker support, and build matrix for release validation. +- **Release Planning and QA Docs** ([@CharlVS], #3438) - Add the polish plan, audit matrix, PRD, UX briefs, QA prompts, and release support documentation. +- **Generated Legacy RPC Deprecation Notices** ([@CharlVS], #3432) - Sync the app-side legacy RPC error mapping with generated deprecation notices from the newer SDK surface. + +
More technical work... + +- **159-Issue Polish Audit** ([@CharlVS], #3438) - Capture and re-validate the open polish backlog in a dedicated game plan to guide release cleanup. +- **Unified Product Planning Artifacts** ([@CharlVS], #3438) - Add the executive brief, unified app plan, service audit matrix, PRD, UX spec, and wireframe/reference documents used for release planning. +- **Manual QA Artifact Set** ([@CharlVS], #3440) - Add manual test cases, matrix definitions, environment templates, and companion configs for the automated runner. +- **Runner Architecture and Automation Support** ([@CharlVS], #3440) - Add preflight checks, retry/reporting helpers, OS automation, Playwright support, and monitoring/reporting modules behind the QA runner. +- **Review and QA Prompt Pack** ([@CharlVS], #3438) - Add the full-diff review prompt and QA-generation guidance used to repeatedly audit the release branch. + +
+ +**Full Changelog**: [e50aa370...bc0058a](https://github.com/GLEECBTC/gleec-wallet/compare/e50aa370c476ec9410e4e97b53876eb8753bb351...bc0058a41c4ccfdfbe829d743b3ba8bae673b767) + +--- + # Gleec Wallet v0.9.3 Release Notes This release delivers significant performance improvements, enhanced analytics capabilities, and a comprehensive overhaul of authentication and wallet management. Key highlights include real-time portfolio streaming, a dual analytics pipeline with persistent queueing, one-click sign-in, Z-HTLC support, and extensive optimisations that reduce RPC usage while improving responsiveness across all platforms. @@ -18,13 +161,13 @@ This release delivers significant performance improvements, enhanced analytics c - **Realtime Portfolio Streaming** ([@CharlVS], #3253) - Live balance updates throughout the app via `CoinsBloc` streaming, eliminating the need for manual refreshes - **One-Click "Remember Me" Sign-In** ([@CharlVS], #3041) - Securely cache wallet metadata for instant access with improved post-login routing - **Dual Analytics Pipeline** ([@CharlVS], #2932) - Firebase and Matomo integration with persistent event queueing, CI toggles, and comprehensive event tracking -- **Z-HTLC Support** ([@Francois], #3158) - Full support for privacy-preserving Hash Time Locked Contracts with configurable activation toggles and optional sync parameters +- **ZHTLC Support** ([@takenagain], #3158) - Full support for privacy-preserving Hash Time Locked Contracts with configurable activation toggles and optional sync parameters - **Enhanced Feedback System** ([@CharlVS], #3017) - Comprehensive feedback portal overhaul with provider plugins, opt-out contact handling, screenshot scrubbing, and analytics integration - **Geo-blocking Bouncer** ([@CharlVS], #3150) - Privacy coin restrictions with regulated build overrides for compliance - **Transaction Broadcast Details** ([@smk762], #3308) - View transaction details immediately after broadcasting withdrawals -- **Market Maker Mobile Improvements** ([@Francois], #3220) - Status indicators and start/stop controls now available in mobile view +- **Market Maker Mobile Improvements** ([@takenagain], #3220) - Status indicators and start/stop controls now available in mobile view - **Swap Data Export** ([@Kadan], #3220) - Copy and export swap data for reference and debugging -- **Tendermint Faucet Support** ([@Francois], #3206) - Request test coins for Tendermint-based assets with activation guardrails +- **Tendermint Faucet Support** ([@takenagain], #3206) - Request test coins for Tendermint-based assets with activation guardrails - **Optional Verbose Logging** ([@Kadan], #3332) - Configurable logging levels for development and troubleshooting - **SDK Log Integration** ([@CharlVS], #3159) - SDK logs now route through the app logger for unified log management @@ -43,17 +186,17 @@ See the [full SDK changelog](https://github.com/GLEECBTC/komodo-defi-sdk-flutter ## 🎨 UI/UX Improvements -- **Fiat Value Display** ([@Francois], #3049) - Coin detail pages now show fiat balance for individual addresses -- **Withdraw Form Enhancements** ([@Francois], #3274) - Vertical responsive layout, fiat value previews for amount and fee, and alignment improvements -- **Loading State Placeholders** ([@Francois], #3134) - Hide asset lists and show placeholders until fiat prices are available for better UX +- **Fiat Value Display** ([@takenagain], #3049) - Coin detail pages now show fiat balance for individual addresses +- **Withdraw Form Enhancements** ([@takenagain], #3274) - Vertical responsive layout, fiat value previews for amount and fee, and alignment improvements +- **Loading State Placeholders** ([@takenagain], #3134) - Hide asset lists and show placeholders until fiat prices are available for better UX - **Transaction History Ordering** ([@CharlVS], #9900372) - Unconfirmed transactions now appear first in the list - **Token Parent Labelling** ([@dragonhound], #2988) - Parent coins now tagged as "native" for clearer asset hierarchy - **Trezor Visibility Toggles** ([@smk762], #3214) - Password and PIN visibility controls for Trezor authentication - **Market Maker Value Display** ([@smk762], #3215) - Fixed bot maker order values display - **Activation Filter Compatibility** ([@smk762], #3249) - Only show compatible activation filter options to prevent errors - **Buy Coin List Sorting** ([@smk762], #3328) - Market maker buy coin list now sorted with price filters and "add assets" footer -- **Keyboard Dismissal** ([@Francois], #3225) - Dismiss keyboard on scroll for fiat and swap inputs -- **Mobile Seed Backup Banner** ([@Francois], #3225) - Seed backup banner now visible in mobile view +- **Keyboard Dismissal** ([@takenagain], #3225) - Dismiss keyboard on scroll for fiat and swap inputs +- **Mobile Seed Backup Banner** ([@takenagain], #3225) - Seed backup banner now visible in mobile view - **Post-Login Navigation** ([@smk762], #3262) - Consistent routing to wallet page after login or logout with delayed navigation for Trezor PIN/passphrase entry - **Custom Seed Toggle** ([@smk762], #3260) - Hide custom seed toggle unless BIP39 validation fails - **NFT Withdraw QR Scanning** ([@smk762], #3243) - QR code scan button added to NFT withdrawal address input @@ -63,7 +206,7 @@ See the [full SDK changelog](https://github.com/GLEECBTC/komodo-defi-sdk-flutter ## ⚡ Performance Enhancements - **RPC Spam Reduction** ([@CharlVS], #3253) - Comprehensive SDK-side caching and streaming support drastically reduces redundant RPC calls -- **Fiat On-Ramp Debouncing** ([@Francois], #3125) - Reduced API calls on user input changes for smoother fiat amount entry +- **Fiat On-Ramp Debouncing** ([@takenagain], #3125) - Reduced API calls on user input changes for smoother fiat amount entry - **Balance Watch Streams** (SDK, #178) - Realtime balance updates from SDK eliminate polling - **Pubkey Caching** ([@CharlVS], #3251) - Prefer cached pubkeys before RPC across the app with post-swap fetch delays - **Best Orders Optimization** ([@smk762], #3328) - Avoid best_orders calls unless on DEX/bridge; fail gracefully and retry @@ -80,17 +223,17 @@ See the [full SDK changelog](https://github.com/GLEECBTC/komodo-defi-sdk-flutter - **Transaction Sorting** ([@CharlVS], #3253) - Fixed transaction history list sorting logic - **Dropdown Null Safety** ([@Cursor Agent], #3050) - Fixed null safety issues in `UiDropdown` widget, preventing app freeze on logout - **Legacy Wallet Migration** ([@CharlVS], #3207) - Preserve legacy flag, sanitise wallet names, ensure uniqueness, and avoid duplicate imports during migration -- **Wallet Coin Restoration** ([@Francois], #3126) - Restore wallet coins for legacy wallet migrations and seed file imports +- **Wallet Coin Restoration** ([@takenagain], #3126) - Restore wallet coins for legacy wallet migrations and seed file imports - **Password Length Validation** ([@CharlVS], #3141, #3149) - Consistent 128-character password handling across all flows with hardened validation -- **Custom Token Import** ([@Francois], #3129) - Check platform in deduplication and correctly update fields; refresh asset list on import +- **Custom Token Import** ([@takenagain], #3129) - Check platform in deduplication and correctly update fields; refresh asset list on import - **Precision Loss in Wallet** ([@CharlVS], #3123) - Resolved DEX precision regression with comprehensive tests -- **Withdraw Form Fixes** ([@Francois], #3274) - Fixed fiat alignment, max value detection, and use signed hex from preview for broadcast +- **Withdraw Form Fixes** ([@takenagain], #3274) - Fixed fiat alignment, max value detection, and use signed hex from preview for broadcast - **ZHTLC Activation Toggle** ([@smk762], #3283) - Revert toggle on ZHTLC activation config cancel - **Coin Variant Sum** ([@smk762], #3317) - Fixed coin variant sum display in dropdowns - **Decimals Precision** ([@Kadan], #3297) - Added unit tests and fixed decimal handling with proper fiat amount input refactoring -- **Trading Bot Improvements** ([@Francois], #3223, #3328) - Remove price URL parameter to default to KDF URL list; add guard against swap button spamming; use `max_maker_vol` for spendable balance -- **Market Maker Dropdown** ([@Francois], #3187) - Fixed sell coin dropdown reverting to previous coin with occasional flickering -- **ARRR Reactivation** ([@Francois], #3184) - Fixed ARRR not reappearing in coins list after deactivation and reactivation +- **Trading Bot Improvements** ([@takenagain], #3223, #3328) - Remove price URL parameter to default to KDF URL list; add guard against swap button spamming; use `max_maker_vol` for spendable balance +- **Market Maker Dropdown** ([@takenagain], #3187) - Fixed sell coin dropdown reverting to previous coin with occasional flickering +- **ARRR Reactivation** ([@takenagain], #3184) - Fixed ARRR not reappearing in coins list after deactivation and reactivation - **Pubkey Clearing** ([@CharlVS], #3144) - Clear pubkeys on wallet change or logout to prevent cross-wallet contamination - **Unban Pubkeys Null Check** ([@smk762], #3276) - Avoid null check error on unban_pubkey button press - **Timer Leaks** ([@Kadan], #3305) - Fixed timer leaks preventing proper cleanup @@ -98,11 +241,11 @@ See the [full SDK changelog](https://github.com/GLEECBTC/komodo-defi-sdk-flutter - **iOS/macOS KDF Reinitialization** ([@Kadan], #3286) - Proper KDF health check and reinitialisation on iOS/macOS - **Withdrawal Form** ([@Kadan], #3288) - Fixed withdrawal regression - **KDF Disposal Crash** ([@DeckerSU], #3117) - Fixed crash when `KomodoDefiSdk` is disposed during periodic fetch -- **Fiat On-Ramp CSP** ([@Francois], #3225) - Disable overly restrictive CSP with limited platform support; add Komodo and sandbox domains to allowlist -- **NFT IPFS Loading** ([@Francois], #3020) - Add IPFS gateway resolution, retry, and fallback to improve NFT image loading +- **Fiat On-Ramp CSP** ([@takenagain], #3225) - Disable overly restrictive CSP with limited platform support; add Komodo and sandbox domains to allowlist +- **NFT IPFS Loading** ([@takenagain], #3020) - Add IPFS gateway resolution, retry, and fallback to improve NFT image loading - **macOS File Picker** ([@CharlVS], #3111) - Show file picker by adding user-selected read-only entitlement - **Settings Version Isolation** ([@smk762], #3324) - Isolate version settings in shared_preferences.json for backwards compatibility -- **Unconfirmed Transaction Detection** ([@Francois], #3328) - Only consider empty timestamps and confirmations as unconfirmed +- **Unconfirmed Transaction Detection** ([@takenagain], #3328) - Only consider empty timestamps and confirmations as unconfirmed ## 🔒 Security & Compliance @@ -116,7 +259,7 @@ See the [full SDK changelog](https://github.com/GLEECBTC/komodo-defi-sdk-flutter ### All Platforms - **Flutter 3.35.1 Upgrade** ([@CharlVS], #3108) - Updated Flutter SDK with dependency roll and improved roll script -- **SDK Submodule Integration** ([@Francois], #3110) - SDK adopted as a git submodule with path overrides and deterministic roll script +- **SDK Submodule Integration** ([@takenagain], #3110) - SDK adopted as a git submodule with path overrides and deterministic roll script ### macOS @@ -141,7 +284,7 @@ See the [full SDK changelog](https://github.com/GLEECBTC/komodo-defi-sdk-flutter - **Health Check Integration** ([@Kadan], #3257) - Added KDF health check with reinitialization support - **Xcode Configuration** ([@DeckerSU], #3324) - Added FdMonitor.swift to Xcode project configuration and updated DEVELOPMENT_TEAM identifier - **Build Artifact Cleanup** ([@DeckerSU], #3058) - Removed .dgph build artifacts from iOS project -- **Ruby Installation Guide** ([@Francois], #3128) - Added Ruby installation step for iOS builds +- **Ruby Installation Guide** ([@takenagain], #3128) - Added Ruby installation step for iOS builds ### Web @@ -189,14 +332,14 @@ See the [full SDK changelog](https://github.com/GLEECBTC/komodo-defi-sdk-flutter - **CI Analytics Toggles** ([@CharlVS], #2932, #3165) - Disable analytics in CI builds with Matomo validation - **NFT Analytics Integration** ([@dragonhound], #3202) - Use AnalyticsRepo to enqueue NFT analytics events - **Updated Events** ([@CharlVS], #3194) - Update completed events and remove scroll attempt tracking -- **Settings Logging** ([@Francois], #3324) - Add logging and avoid silent skipping in settings +- **Settings Logging** ([@takenagain], #3324) - Add logging and avoid silent skipping in settings ### Developer Experience - **PR Body Template** ([@CharlVS], #3207) - Add PR_BODY.md helper file for CLI editing -- **SDK Submodule Management** ([@Francois], #3110) - Deterministic SDK roll script with path overrides +- **SDK Submodule Management** ([@takenagain], #3110) - Deterministic SDK roll script with path overrides - **API Commit Hash Display** ([@DeckerSU], #3115) - Fix logging of apiCommitHash to output actual value instead of closure -- **Dependency Documentation** ([@Francois], #3128) - Ruby installation guide for iOS/macOS builds +- **Dependency Documentation** ([@takenagain], #3128) - Ruby installation guide for iOS/macOS builds - **Optional Verbose Logging** ([@Kadan], #3332, SDK #278) - Configurable logging levels for debugging ### Code Quality @@ -204,14 +347,14 @@ See the [full SDK changelog](https://github.com/GLEECBTC/komodo-defi-sdk-flutter - **Null Safety Improvements** ([@Cursor Agent], #3050) - Fixed null safety issues in UiDropdown widget - **Type Safety** ([@Kadan], #3279, #3280) - Bound checking, non-nullable type tweaks, explicit enum mapping, defensive array access guards, cast num to int - **Error Propagation** ([@smk762], #3328) - Propagate best_orders failures, avoid masking as no liquidity -- **Unused Code Cleanup** ([@Francois], #3225) - Remove unused widgets and update enum docs +- **Unused Code Cleanup** ([@takenagain], #3225) - Remove unused widgets and update enum docs - **Code Formatting** ([@CharlVS], #3251) - Run dart format on pubkey cache call-sites and taker delay -- **Logging Improvements** ([@Francois], #3328) - Add logging for errors not propagated to UI layer +- **Logging Improvements** ([@takenagain], #3328) - Add logging for errors not propagated to UI layer ## 📚 Documentation - **SDK Changelog Cross-Linking** ([@CharlVS], #3172) - Link SDK PRs with short labels and mark SDK items in wallet changelog -- **Ruby Installation Guide** ([@Francois], #3128) - Added Ruby installation step for iOS and macOS builds +- **Ruby Installation Guide** ([@takenagain], #3128) - Added Ruby installation step for iOS and macOS builds - **SDK Documentation** ([SDK#201](https://github.com/GLEECBTC/komodo-defi-sdk-flutter/pull/201)) - Document project and packages for pub.dev release ## ⚠️ Known Issues diff --git a/android/app/build.gradle b/android/app/build.gradle index d177384bcb..373ac0e6bb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -45,6 +45,8 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } +def supportedReleaseAbis = ['armeabi-v7a', 'arm64-v8a'] + android { namespace 'com.gleec.gleecdex' @@ -66,17 +68,18 @@ android { targetSdkVersion 36 versionCode flutterVersionCode.toInteger() versionName flutterVersionName - - ndk { - //noinspection ChromeOsAbiSupport - abiFilters 'armeabi-v7a', 'arm64-v8a' - } } - // Android NDK releases page: https://developer.android.com/ndk/downloads - ndkVersion = android.ndkVersion + ndkVersion = "28.2.13676358" buildTypes { + all { buildType -> + if (!buildType.debuggable) { + // Override Flutter's default ABI list for non-debuggable builds. + buildType.ndk.abiFilters.clear() + buildType.ndk.abiFilters.addAll(supportedReleaseAbis) + } + } release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. @@ -88,4 +91,3 @@ android { flutter { source '../..' } - diff --git a/android/app/src/main/java/com/komodoplatform/atomicdex/MainActivity.java b/android/app/src/main/java/com/komodoplatform/atomicdex/MainActivity.java index 00efcf06f2..7238f2b3b5 100644 --- a/android/app/src/main/java/com/komodoplatform/atomicdex/MainActivity.java +++ b/android/app/src/main/java/com/komodoplatform/atomicdex/MainActivity.java @@ -21,7 +21,6 @@ import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.MethodChannel; -import io.flutter.plugins.GeneratedPluginRegistrant; public class MainActivity extends FlutterActivity { @@ -121,7 +120,7 @@ public void onActivityResult(int requestCode, int resultCode, @Override public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { - GeneratedPluginRegistrant.registerWith(flutterEngine); + super.configureFlutterEngine(flutterEngine); setupSaf(flutterEngine); } } diff --git a/android/settings.gradle b/android/settings.gradle index d9923f9381..aad76375ef 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -26,7 +26,7 @@ plugins { id "com.google.gms.google-services" version "4.3.15" apply false // END: FlutterFire Configuration // Kotlin release with JVM 21 support: https://kotlinlang.org/docs/releases.html#release-details - id "org.jetbrains.kotlin.android" version "2.2.10" apply false -} + id "org.jetbrains.kotlin.android" version "2.2.20" apply false +} -include ":app" \ No newline at end of file +include ":app" diff --git a/app_theme/lib/app_theme.dart b/app_theme/lib/app_theme.dart index 39b2fff382..e17ed28486 100644 --- a/app_theme/lib/app_theme.dart +++ b/app_theme/lib/app_theme.dart @@ -9,6 +9,8 @@ import 'src/new_theme/new_theme_dark.dart'; import 'src/new_theme/new_theme_light.dart'; import 'src/theme_global.dart'; +export 'src/dark/theme_custom_dark.dart'; +export 'src/light/theme_custom_light.dart'; export 'src/new_theme/extensions/color_scheme_extension.dart'; export 'src/new_theme/extensions/text_theme_extension.dart'; diff --git a/app_theme/pubspec.yaml b/app_theme/pubspec.yaml index c4e2bd83bb..28495f489a 100644 --- a/app_theme/pubspec.yaml +++ b/app_theme/pubspec.yaml @@ -7,7 +7,7 @@ resolution: workspace environment: sdk: ">=3.8.1 <4.0.0" - flutter: ">=3.35.3 <4.0.0" + flutter: ">=3.41.4 <4.0.0" dependencies: flutter: diff --git a/assets/legal/eula.md b/assets/legal/eula.md new file mode 100644 index 0000000000..a34507cab4 --- /dev/null +++ b/assets/legal/eula.md @@ -0,0 +1,87 @@ +# End User License Agreement (EULA) | GLEEC Wallet + +This End User License Agreement (“**EULA**”) is a legally binding agreement between you (“**User**,” “**you**,” or “**your**”) and **GLEEC** (“**GLEEC**,” “**we**,” “**us**,” or “**our**”). + +This EULA governs your access to and use of the GLEEC Wallet software, including any associated web applications, mobile applications, desktop applications, features, content, updates, and related services (collectively, the “**Software**”), whether obtained directly from GLEEC or through an authorized distributor, reseller, or partner (“**Distributor**”). + +By clicking “Accept,” downloading, installing, accessing, or using the Software, you acknowledge that you have read, understood, and agree to be bound by the terms of this EULA. If you do not agree to these terms, you must not download, install, access, or use the Software. + +If you are entering into this EULA on behalf of a company or other legal entity, you represent and warrant that you have the authority to bind such entity and its affiliates to this EULA. If you do not have such authority, you must not accept this EULA or use the Software. + +If you participate in any beta, trial, or early-access program, this EULA also governs such use unless separate terms are provided. + +This EULA applies solely to the Software provided by GLEEC, regardless of whether other software or services are referenced. Any updates, supplements, support services, or internet-based services provided by GLEEC are governed by this EULA unless accompanied by separate terms. + +## 1. License Grant + +Subject to your compliance with this EULA, GLEEC grants you a limited, personal, non-exclusive, non-transferable, non-sublicensable, and revocable license to download, install, and use the Software on devices owned or controlled by you solely for its intended purposes. + +You are responsible for ensuring that your device meets the minimum system, security, and compatibility requirements necessary to use the Software securely and effectively. + +The Software is licensed, not sold. No ownership rights are transferred to you under this EULA. + +## 2. Restrictions + +You agree that you will not, and will not permit any third party to: + +* Modify, adapt, translate, alter, or create derivative works of the Software. +* Combine or incorporate the Software into other software without authorization. +* Reverse engineer, decompile, disassemble, or otherwise attempt to derive the source code of the Software, except to the extent such restriction is prohibited by applicable law. +* Reproduce, copy, distribute, lease, sublicense, sell, assign, or otherwise commercially exploit the Software. +* Use the Software in violation of any applicable local, provincial, national, or international law or regulation. +* Use the Software for unlawful, fraudulent, abusive, or harmful activities. +* Circumvent, disable, or interfere with security-related features of the Software. + +GLEEC reserves the right to suspend or restrict access to the Software if it reasonably believes you are in breach of this EULA. + +## 3. Intellectual Property + +The Software, including all content, features, functionality, trademarks, service marks, logos, and intellectual property rights therein, are and shall remain the exclusive property of GLEEC and its licensors. + +All rights not expressly granted to you under this EULA are reserved by GLEEC. + +GLEEC may grant licenses to third parties at its sole discretion. + +## 4. Updates and Modifications + +GLEEC may, from time to time, provide updates, enhancements, patches, or modifications to the Software. Such updates may be automatic and may modify or remove certain features. + +Continued use of the Software following updates constitutes acceptance of such updates. + +## 5. Disclaimer of Warranties + +To the maximum extent permitted by applicable law, the Software is provided on an “AS IS” and “AS AVAILABLE” basis without warranties of any kind, whether express, implied, statutory, or otherwise. + +GLEEC expressly disclaims all implied warranties, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, accuracy, reliability, and uninterrupted availability. + +GLEEC does not warrant that the Software will be error-free, secure, or free from viruses or other harmful components. + +## 6. Limitation of Liability + +To the maximum extent permitted by applicable law, GLEEC shall not be liable for any indirect, incidental, consequential, special, punitive, or exemplary damages, including but not limited to loss of profits, data, digital assets, goodwill, or business interruption arising out of or related to your use of the Software. + +GLEEC’s total aggregate liability under this EULA shall not exceed the amount (if any) paid by you for the Software. + +Some jurisdictions may not allow certain limitations of liability; in such cases, liability will be limited to the fullest extent permitted by law. + +## 7. Termination + +This EULA becomes effective upon your first use of the Software and remains in effect until terminated. + +You may terminate this EULA at any time by discontinuing use of the Software and uninstalling it from your devices. + +GLEEC may suspend or terminate this EULA immediately if you breach any provision of this EULA. + +Upon termination: + +* All rights granted to you under this EULA shall immediately cease. +* You must discontinue all use of the Software. +* Any provisions which by their nature should survive termination shall remain in effect, including intellectual property rights, disclaimers, and limitations of liability. + +## 8. Governing Law and Jurisdiction + +This EULA and any dispute, claim, or controversy arising out of or relating to this EULA or the use of the Software shall be governed by and construed in accordance with the laws of **Canada**, without regard to its conflict of law principles. + +The parties agree that the courts located in Canada shall have exclusive jurisdiction over any disputes arising under this EULA. + +**Last updated: February 24, 2026** diff --git a/assets/legal/kyc-due-diligence-policy.md b/assets/legal/kyc-due-diligence-policy.md new file mode 100644 index 0000000000..db624b97a2 --- /dev/null +++ b/assets/legal/kyc-due-diligence-policy.md @@ -0,0 +1,112 @@ +## **KYC AND DUE DILIGENCE POLICY** + +**Gleec Pay LTD and its Affiliates** +(“Gleec”, “Gleec.com”, “The Company”, “we”, “us”, “our”) + +### **1\. Purpose, Scope and Regulatory Context** + +This Know Your Customer (“KYC”) and Due Diligence Policy (the “Policy”) establishes the principles, standards, governance framework, and operational controls adopted by Gleec Pay LTD and all affiliated entities within the Gleec Group to prevent, detect, and mitigate the risks of money laundering, terrorist financing, sanctions violations, fraud, corruption, and other forms of financial and economic crime. + +This Policy applies to all natural persons and legal entities, including users, customers, clients, merchants, counterparties, partners, and beneficial owners who access, register for, or use any of the Company’s products, services, platforms, infrastructure, or applications, including but not limited to Gleec Pay, Gleec Card, Gleec Chat, Gleec BTC Exchange, and Gleec DEX (collectively, the “Services” or the “Platform”). + +The Policy is designed to ensure compliance with all applicable anti-money laundering (“AML”), counter-terrorist financing (“CTF”), sanctions, and financial crime prevention laws and regulations in the jurisdictions in which the Company operates or provides Services. It aligns with internationally recognised standards and best practices, including the recommendations of the Financial Action Task Force (“FATF”), relevant EU Directives, and applicable national regulatory frameworks. + +### **2\. Governance and Responsibility** + +Ultimate responsibility for the implementation, oversight, and effectiveness of this Policy rests with the Board of Directors and Senior Management of the Company. The Board ensures that the Company maintains adequate systems, controls, and resources to manage financial crime risks proportionate to its business model, geographic footprint, and risk exposure. + +Day-to-day responsibility for implementing and maintaining this Policy is delegated to the Money Laundering Reporting Officer (“MLRO”), who operates with sufficient authority, independence, and access to information. The MLRO is supported by the Risk and Compliance function and relevant operational teams. + +All employees, officers, contractors, and relevant third parties involved in onboarding, customer interaction, transaction processing, or monitoring activities are required to comply with this Policy and supporting procedures. + +### **3\. Risk-Based Approach** + +#### **3.1 General Principles** + +The Company applies a comprehensive risk-based approach (“RBA”) to KYC, customer due diligence, enhanced due diligence, and ongoing monitoring. The level and intensity of controls are proportionate to the level of money laundering, terrorist financing, sanctions, and fraud risk identified. + +The RBA recognises that risks vary based on customer type, jurisdiction, product and service usage, transaction behaviour, and delivery channels. Enhanced scrutiny is applied to higher-risk relationships, while baseline controls are maintained across all Services. This approach ensures flexibility in responding to emerging risks, regulatory developments, and evolving typologies. + +#### **3.2 Risk Assessment Methodology** + +The Company identifies, assesses, and manages risks using a combination of qualitative analysis and quantitative indicators. Factors considered include service complexity, client profiles, geographic exposure, transaction size and frequency, funding methods, asset types, source and destination of funds or virtual assets, and behavioural indicators. + +Risk assessments are formally documented, subject to review, and retained in an auditable manner. + +### **4\. Client Risk Assessment and Categorisation** + +#### **4.1 Initial Risk Assessment** + +Before establishing a business relationship or granting access to the Services, the Company conducts an initial client risk assessment. Each relationship is assigned a Low, Medium, or High Risk classification, which determines due diligence requirements, approval thresholds, escalation procedures, and monitoring frequency. + +Risk classifications are dynamic and are reviewed throughout the lifecycle of the relationship whenever new or material information becomes available. + +#### **4.2 Risk Factors Considered** + +The Company assesses multiple risk factors, including sanctions exposure, reputational risk, Politically Exposed Person (“PEP”) status, geographic exposure, industry and business activity, financial transparency, products and services used, and transactional and behavioural patterns. + +#### **4.3 High-Risk Indicators** + +Certain indicators may result in immediate classification as high-risk, including complex or opaque ownership structures, offshore arrangements, PEP involvement, exposure to high-risk or sanctioned jurisdictions, unexplained or inconsistent transaction behaviour, difficulty verifying source of wealth or funds, or reluctance to provide required KYC information. + +Risk scoring is not influenced by commercial considerations. The MLRO may override automated risk outcomes where justified, provided that the rationale is documented and approved. + +### **5\. Business-Wide Risk Assessment** + +The MLRO conducts a Business-Wide Risk Assessment (“BWRA”) covering all Gleec entities, Services, customer segments, products, and delivery channels. The BWRA identifies inherent and residual risks, evaluates the effectiveness of controls, and informs policy updates, system enhancements, and monitoring priorities. + +The BWRA is reviewed at least annually and whenever material changes occur and is reported to Senior Management and the Board of Directors. + +### **6\. Client Acceptance Policy** + +#### **6.1 General Acceptance Principles** + +The Company only establishes relationships with clients who can be properly identified and verified through KYC procedures, demonstrate a legitimate purpose for using the Services, pass sanctions, PEP, and adverse media screening, and meet the acceptance criteria defined in this Policy. + +Account activation and continued access to the Services are conditional upon the successful completion of applicable KYC and due diligence measures. + +#### **6.2 Prohibited Clients** + +The Company does not onboard or maintain relationships with sanctioned persons or entities, clients linked to prohibited jurisdictions or industries, individuals involved in serious criminal activity, clients with unverifiable identity or ownership, clients providing false or misleading information, or entities with anonymous or unidentifiable beneficial owners. + +### **7\. Politically Exposed Persons (PEPs)** + +All relationships involving PEPs, their family members, or close associates are subject to Enhanced Due Diligence (“EDD”). This includes identification and verification of PEP status, MLRO and senior management approval, verification of source of wealth and source of funds, enhanced transaction monitoring, and more frequent reviews. + +Former PEPs remain subject to enhanced measures for a minimum of 12 months, extendable based on risk. + +### **8\. Customer Due Diligence (CDD)** + +#### **8.1 CDD Measures** + +Customer Due Diligence measures include the identification and verification of clients, beneficial owners, and controlling persons; understanding the purpose and intended nature of the business relationship; and ongoing monitoring of transactions and customer behaviour. + +CDD is performed prior to onboarding, when trigger events occur, or when doubts arise regarding previously obtained KYC information. + +#### **8.2 Use of Third-Party KYC Platform Provider** + +To support the effective execution of its Know Your Customer (“KYC”) obligations, the Company utilises reputable third-party service providers for KYC and identity verification activities. **Gleec uses Sumsub (Sum & Substance) as its primary KYC platform provider** to perform and support customer identification and verification, document authentication, biometric and liveness checks, sanctions screening, Politically Exposed Person (“PEP”) screening, and adverse media monitoring. + +Sumsub is fully integrated into the Company’s onboarding, risk assessment, and ongoing KYC monitoring processes and is used across the Services to ensure consistent, scalable, and risk-based KYC controls in line with regulatory requirements and industry best practices. + +The use of Sumsub does not transfer regulatory responsibility. **Ultimate responsibility for KYC compliance, due diligence decisions, risk classifications, and regulatory adherence remains with the Company at all times**. The Company maintains appropriate oversight, governance, and validation of all KYC activities performed through Sumsub and ensures that the KYC platform provider meets required standards of security, data protection, reliability, and regulatory compliance. + +### **9\. Enhanced Due Diligence (EDD)** + +Enhanced Due Diligence is applied where higher risk is identified and may include additional identity and ownership verification, detailed analysis of source of wealth and source of funds, enhanced transaction scrutiny, justification of activity, and senior management approval prior to onboarding or continuation of the relationship. + +### **10\. Source of Wealth and Source of Funds** + +The Company verifies the legitimacy of source of wealth and source of funds using a risk-based approach and may request documentary evidence appropriate to the client’s risk profile, jurisdiction, and activity. All documentation is reviewed for authenticity, consistency, and plausibility. + +### **11\. Ongoing Monitoring and Periodic Reviews** + +All client relationships are subject to continuous transaction monitoring and periodic review based on risk classification. Reviews occur at least every 36 months for low-risk clients, 24 months for medium-risk clients, and 12 months for high-risk clients, or earlier where triggered by material changes, sanctions updates, PEP status changes, or suspicious activity. + +### **12\. Record Keeping and Audit Trail** + +The Company maintains complete, accurate, and retrievable records of KYC data, client risk assessments, monitoring activities, decisions, approvals, and escalations. Records are retained in accordance with applicable legal and regulatory requirements and are made available for internal audit and regulatory inspection. + +### **13\. Policy Review and Updates** + +This Policy is reviewed at least annually and updated as necessary to reflect regulatory developments, emerging risks, changes in business activities, and industry best practices. diff --git a/assets/legal/privacy-notice.md b/assets/legal/privacy-notice.md new file mode 100644 index 0000000000..a189ecc623 --- /dev/null +++ b/assets/legal/privacy-notice.md @@ -0,0 +1,411 @@ +# Gleec Global Privacy Notice + +Welcome to the Gleec Global Privacy Notice (“Privacy Notice”). Please spend a few minutes reading it carefully before you provide us with any information about you or any other person. + +## 1\. Introduction + +We truly respect your privacy and are committed to protecting your personal data. This Privacy Notice applies to the processing of personal data by Gleec Pay LTD. with its principal place of business located at 5811 Cooney Road, Suite 305, South Tower, Richmond, British Columbia, V6X 3M1, Canada and its affiliates companies (“Gleec”, “Gleec.com”, “The Company”, “we”, “us”, “our”) in connection with: + +* use of any of our products, services or applications – including but no limited to Gleec Pay, Gleec Card, Gleec Chat, Gleec Exchange, Gleec DEX and Gleec Wallet (collectively the “Services” or the “Platform”) +* visit or use of our website Gleec.com (“Site”) or any of GLEEC’s ecosystem mobile application (“App”) + +Please note that our Services, Site and Apps are not intended for minors below the age of 18 years and we do not knowingly collect data relating to minors. + +## 2\. Purpose + +This Privacy Notice aims to give you information on why and how we collect and process your personal data. This Privacy Notice informs you about your privacy rights and how the data protection principles set out in the EU General Data Protection Regulation (“GDPR”) and the post-Brexit privacy law publicly known as the UK GDPR protect you. + +It is important that you read this Privacy Notice together with any other notice or policy we may provide on specific occasions when we are collecting or processing personal data about you so that you are fully aware of why and how we are using your data. This Privacy Notice supplements other notices and policies and is not intended to override them. + +For your easy reference, you may find below the privacy terms for other products or services provided by our corporate family, as well as the privacy terms for other jurisdictions where our business operates. Please note that all or part of our Services may not be available in your region. + +* Brazil + +It is important that you read this Privacy Notice together with any other notice or policy we may provide on specific occasions when we are collecting or processing personal data about you so that you are fully aware of why and how we are using your data. This Privacy Notice supplements other notices and policies and is not intended to override them. + +## 3\. Who we are + +### Data Controllers + +The controller of your personal data is the legal entity that determines the “means” and the “purposes” of any processing activities that it carries out. When you engage us to provide services for you, we will be the “data controller” for your personal data. + +### Data Protection Officer + +We have appointed a Data Protection Officer (“DPO”) who is responsible for overseeing questions in relation to this Privacy Notice. If you have any questions or complaints related to this Privacy Notice or our privacy practices, or if you want to exercise your legal rights, please contact our DPO at legal@gleec.com. + +### Complaints + +You have the right to make a complaint about the way we process your personal data to a supervisory authority. If you reside in an EEA Member State, you have the right to make a complaint about the way we process your personal data to the supervisory authority in the EEA Member State of your habitual residence, place of work or place of the alleged infringement. Information about your supervisory authority could be found here. + +We would, however, appreciate the chance to deal with your concerns before you approach a data protection regulatory authority, so please feel free to contact us in the first instance. + +### Our duties and your duties in case of changes + +We keep our Privacy Notice under regular review. This version was last updated on the date above written. Please check from time to time for new versions of the Privacy Notice. We will also additionally inform you of material changes to this Privacy Notice in a manner that will effectively bring the changes to your attention. It is important that the personal data we hold about you is accurate and up-to-date. Please keep us informed if your personal data changes during your relationship with us. + +## 4\. What data we collect about you + +Personal data, or personal information means any information that relates to an identified or identifiable living individual. This is a broad definition that includes the specific pieces of personal data which we have described below. It does not include data that cannot be used to identify an individual person, such as a company registration number. A “data subject” is an individual who can be identified, directly or indirectly, by personal data. This is usually by reference to an identifier such as a name, identification number, location data, an online identifier or to one or more factors specific to the physical, physiological, genetic, mental, economic, cultural or social identity of that natural person. Different pieces of information, which are collected together can lead to the identification of a particular person, also constitute personal data. It does not include data where the identity has been removed (anonymous data). More information could be found here. Depending on whether and how you use our Services, Site or App, we will collect, use, store and transfer different kinds of personal data about you which we have grouped in categories as follows: + +* Identity Data: first name, maiden name, last name, username or similar identifier, title, date of birth and gender, biometric information, including a visual image of your face, national identity cards, passports, driving licenses or other forms of identification documents. +* Social Identity Data: your group/company data, information on referrals related to you, political background, close connections, behavioral data, risk assessment, and compliance assessment. +* Contact Data: residence details, billing address, delivery address, home address, work address, email address and telephone numbers, proof of address documentation. +* Financial Data: bank account, payment card details, virtual currency accounts, stored value accounts, amounts associated with accounts, external account details. +* Transactional Data: details about payments to and from you, other details of any transactions you enter into using the Services, Site or App. +* Investment Data: information about your investment objectives, investment experience, prior investments. +* Technical Data: internet connectivity data, internet protocol (IP) address, operator and carrier data, login data, browser type and version, device type, category and model, time zone setting and location data, language data, application version and SDK version, browser plug-in types and versions, operating system and platform, diagnostics data such as crash logs and any other data we collect for the purposes of measuring technical diagnostics, and other information stored on or available regarding the devices you allow us access to when you visit the Site, or use the Services or the App. +* Profile Data: your username and password, your identification number as our user, information on whether you have Gleec’s Apps account and the email associated with your accounts, requests by you for products or services, your interests, preferences and feedback other information generated by you when you communicate with us, for example when you address a request to our customer support. +* Usage Data: information about how you use the Site, the Services, mobile applications and other offerings made available by us, including device download time, install time, interaction type and time, event time, name and source. +* Marketing and Communications Data: your preferences in receiving marketing from us or third parties, your communication preferences, your survey responses. + +As explained above under Identity Data, we will also collect a visual image of your face which we will use, in conjunction with our sub-contractors, to check your identity for onboarding purposes. This data falls within the scope of special categories of data. + +If you refuse to provide personal data + +Where we need to collect personal data by law, or under the terms of a contract we have with you, and you refuse to provide that data when requested, we may not be able to perform the contract we have or are trying to enter into with you – for example, to provide you Services. In this case, we may have to cancel a product or stop providing you services related to it, but we will notify you if this is the case at the time. + +## 5\. How we collect your data + +We use different methods to collect information from and about you, including through: + +### Direct interactions + +You may give us your Identity Data, Social Identity Data, Contact Data, Financial Data, Profile Data and Marketing and Communications Data by directly interacting with us, including by filling in forms, providing a visual image of yourself via the Service or a third partner, by email or otherwise. + +This includes personal data you provide when you: + +* visit our Site or Apps; +* apply for our Services; +* create an account; +* make use of any of our Services; +* request marketing to be sent to you, for example by subscribing to our newsletters; +* enter a competition, promotion or survey, including through social media channels; +* give us feedback or contact us. + +### Automated technologies or interactions + +As you interact with us via our Site or App, we will automatically collect Technical Data about your equipment, browsing actions and patterns. We collect this personal data by using cookies, server logs and other similar technologies. We will also collect Transactional Data, Investment Data and Usage Data. + +We may also receive Technical Data and Marketing and Communications Data about you if you visit other websites employing our cookies. + +### Third parties or publicly available sources + +We also obtain information about you, including Social Identity Data, from third parties or publicly available sources. These sources may include: + +* fraud and crime prevention agencies, +* a customer referring you, +* public blockchain, +* publicly available information on the Internet (websites, articles etc.) + +## 6\. How we use your data + +### Lawful basis + +We will only use your personal data when the applicable legislation allows us to. In other words, we have to ensure that we have a lawful basis for such use. Most commonly, we will use your personal data in the following circumstances: + +* Performance of a contract: means processing your data where it is necessary for the performance of a contract to which you are a party or to take steps at your request before entering into such a contract; we use this basis for provision of our Services; +* Legitimate interests: means our interests (or those of a third party), where we make sure we use this basis as far as your interests and individual rights do not override those interests; +* Compliance with a legal obligation: means processing your personal data where we need to comply with a legal obligation we are subject to; +* Consent: means freely given, specific, informed, and unambiguous indication of your wishes by which you, by a statement or by clear affirmative action, signify agreement to the processing of personal data relating to you; under specific circumstances, this consent should be explicit – if this is the case, we will ask for it properly. + +### Purposes for which we will use your personal data + +We have set out below, in a table format, a description of all the ways we plan to use your personal data, and which of the legal bases we rely on to do so. We have also identified what our legitimate interests are where appropriate. + +Note that we may process your personal data for more than one lawful ground depending on the specific purpose for which we are using your data. Please contact us if you need details about the specific legal ground we are relying on to process your personal data where more than one ground has been set out in the table below. + +#### **1.6. To register you as a new customer:** + +* a. Categories of personal data: + * Identity Data + * Social Identity Data + * Contact Data + * Financial Data +* b. Lawful basis for processing: Performance of a contract + +#### **2.6. To carry out and comply with anti-money laundering requirements:** + +* a. Categories of personal data: + * Identity Data + * Social Identity Data + * Contact Data + * Financial Data +* b. Lawful basis for processing: Compliance with a legal obligation + +#### **3.6. To process and deliver our Services and any App features to you:** + +* a. Categories of personal data: + * Identity Data + * Contact Data + * Financial Data + * Transactional Data + * Technical Data + * Financial Data +* b. Lawful basis for processing: Performance of a contract + +#### **4.6. To prevent abuse of our Services and promotions:** + +* a. Categories of personal data: + * Identity Data + * Contact Data + * Financial Data + * Transactional Data + * Technical Data + * Marketing and Communications Data +* b. Lawful basis for processing: Legitimate interests + +#### **5.6. To manage our relationship with you:** + +* a. Categories of personal data: + * Identity Data + * Contact Data + * Profile Data + * Transactional Data + * Marketing and Communications Data +* b. Lawful basis for processing: Performance of a contract. Consent, if required + +#### **6.6. To keep our records updated and to study how customers use our products/services:** + +* a. Categories of personal data: + * Identity Data + * Contact Data + * Profile Data + * Transactional Data + * Marketing and Communications Data +* b. Lawful basis for processing: Legitimate interests. Consent, if required + +#### **7.6. To manage, process, collect and transfer payments, fees and charges, and to collect and recover payments owed to us:** + +* a. Categories of personal data: + * Identity Data + * Contact Data + * Financial Data +* b. Lawful basis for processing: Performance of a contract + +#### **8.6. To ensure good management of our payments, fees and charges and collection and recovery of payments owed to us:** + +* a. Categories of personal data: + * Identity Data + * Contact Data + * Financial Data +* b. Lawful basis for processing: Legitimate interests + +#### **9.6. To manage risk and crime prevention including performing anti-money laundering, counter-terrorism, sanction screening, fraud and other background checks, detect, investigate, report and prevent financial crime in broad sense, obey laws and regulations which apply to us and response to complaints and resolving them:** + +* a. Categories of personal data: + * Identity Data + * Social Identity Data + * Contact Data + * Financial Data + * Technical Data + * Transactional Data + * Investment Data + * Sensitive Data (a.k.a. Special Categories Data): + * data that you give us directly or that we receive from third parties and/or publicly available sources; + * data which might be revealed by KYC or other background checks (for example, because it has been reported in the press or is available in public registers); + * data that is incidentally revealed by photographic ID although we do not intentionally process this personal data +* b. Lawful basis for processing: Compliance with a legal obligation + +We may also process such data in connection with these purposes if it is necessary for the performance of our contract with you. + +In addition to our legal obligations, we may process this personal data based on our legitimate interest in ensuring that we are not involved in dealing with the proceeds of criminal activities and do not assist in any other unlawful or fraudulent activities, as well as to develop and improve our internal systems for dealing with financial crime and to ensure effective dealing with complaints. + +#### **10.6. To enable you to partake in a prize draw, competition or complete a survey:** + +* a. Categories of personal data: + * Identity Data + * Contact Data + * Profile Data + * Usage Data + * Marketing and Communications Data +* b. Lawful basis for processing: Performance of a contract. Consent, if required. + +#### **11.6. To gather market data for studying customers' behavior including their preference, interest and how they use our products/services, determining our marketing campaigns and growing our business:** + +* a. Categories of personal data: + * Identity Data + * Contact Data + * Profile Data + * Usage Data + * Marketing and Communications Data +* b. Lawful basis for processing: Legitimate interests (understanding our customers and improving our products and services) + +#### **12.6. To administer and protect our business, our Site, App(s) and social media channels including bans, troubleshooting, data analysis, testing, system maintenance, support, reporting, hosting of data:** + +* a. Categories of personal data: + * Identity Data + * Contact Data + * Financial Data + * Technical Data + * Transactional Data + * Investment Data +* b. Lawful basis for processing: Legitimate interests to run our business, provision of administration and IT services, network security, to prevent fraud and in the context of a business reorganization or group restructuring exercise + +#### **13.6. To deliver relevant website content and advertisements to you and measure or understand the effectiveness of the advertising we serve to you:** + +* a. Categories of personal data: + * Identity Data + * Contact Data + * Profile Data + * Usage Data + * Technical Data + * Marketing and Communications Data +* b. Lawful basis for processing: Legitimate interests (to study how customers use our products/services, to develop them, to grow our business and to form our marketing strategy). Consent, if required. + +#### **14.6. To use data analytics to improve our website, products/services, marketing, customer relationships and experiences:** + +* a. Categories of personal data: + * Technical Data + * Usage Data +* b. Lawful basis for processing: to define types of customers for our products and services, to keep our website updated and relevant, to develop our business and to form our marketing strategy. Consent, if required. + +#### **15.6. To make suggestions and recommendations to you about goods or services that may be of interest to you:** + +* a. Categories of personal data: + * Identity Data + * Contact Data + * Technical Data + * Usage Data + * Profile Data + * Investment Data + * Marketing and Communications Data +* b. Lawful basis for processing: Legitimate interests: to develop our products/services and grow our business. Consent, if required. + +#### **16.6. To use the services of social media platforms or advertising platforms some of which will use the personal data they receive for their own purposes, including marketing purposes:** + +* a. Categories of personal data: + * Technical Data + * Usage Data +* b. Lawful basis for processing: Consent + +#### **17.6. To use the services of financial institutions, crime and fraud prevention companies, risk measuring companies, which will use the personal data they receive for their own purposes in their capacity of independent controllers:** + +* a. Categories of personal data: + * Identity Data + * Social Identity Data + * Contact Data + * Financial Data + * Transactional Data + * Investment Data + * Technical Data + * Usage Data +* b. Lawful basis for processing: Legitimate interests (to conduct our business activities on the market of financial services, to participate actively in the prevention of crime and fraud). + +#### **18.6. To record voice calls for compliance, quality assurance and training purposes:** + +* a. Categories of personal data: + * Identity Data + * Social Identity Data + * Contact Data + * Financial Data + * Transactional Data +* b. Lawful basis for processing: Legitimate interests (to comply with the industry standards and requirements in payments services, to ensure quality of our service, including by proper training of our personnel). + +### Marketing + +We may use your Identity Data, Contact Data, Technical Data, Transactional Data, Investment Data, Usage Data, and Profile Data to form a view of what we think you may want or need, or what may be of interest to you. This is how we decide which products, services, and offers may be relevant for you (we call this marketing). + +You will receive marketing communications from us if you have requested information from us and consented to receive marketing communications, or if you have purchased from us and you have not opted out of receiving such communications. We will use your Marketing and Communications Data for our respective activities. + +### Opting out + +You can ask us to stop sending you marketing messages at any time by following the “unsubscribe” links on any marketing message sent to you. + +Further, you can let us know directly that you prefer not to receive any marketing messages by emailing legal@gleec.com. + +Where you opt out of receiving marketing messages, this will not apply to service messages which are directly related to the use of our Services (e.g. maintenance, change in the terms and conditions and so forth). + +### Cookies + +We use cookies to monitor and observe your use of our websites, compile aggregate data about that use, and provide you with a more effective service (which may include customizing parts of our websites based on your preferences and past activities on those websites). "Cookies" are small text files created and stored on your hard drive by your internet browser software, in order to hold relevant information about the web page you are currently viewing. + +Most internet browsers have a facility that will allow you to disable cookies altogether – please refer to your browser’s help menu to find out how to do this. While you will still be able to browse our websites with cookies disabled on your internet browser, some website functionality may not be available or may not function correctly. + +### Google Analytics + +Our website uses Google Analytics, a web analytics service provided by Google, Inc. (“Google”). Google Analytics uses cookies to help the website analyze how users use our website. + +The information generated by the cookie about your use of our website (including your IP address) will be transmitted to and stored by Google on servers in the United States. Google will use this information for the purpose of evaluating your use of the website, compiling reports on website activity for website operators, and providing other services relating to website activity and internet usage. Google may also transfer this information to third parties where required to do so by law, or where such third parties process the information on Google’s behalf. Google will not associate your IP address with any other data held by Google. + +You may refuse the use of cookies by selecting the appropriate settings on your browser, however, please note that if you do this you may not be able to use the full functionality of our website. By using our website, you consent to the processing of data about you by Google in the manner and for the purposes set out above. + +### Change of purpose + +We will only use your personal data for the purposes for which we collected it, unless we reasonably consider that we need to use it for another reason and that reason is compatible with the original purpose. If you wish to get an explanation as to how the processing for the new purpose is compatible with the original purpose, please contact us. + +If we need to use your personal data for an unrelated purpose, we will notify you and we will explain the legal basis which allows us to do so. + +### 7\. Disclosures of Your Data + +We share your personal data with our third-party service providers, agents, subcontractors and other associated organizations, our group companies, and affiliates (as described below) in order to complete tasks and provide the Services and use of any of Gleec Apps to you on our behalf. When using third-party service providers, they are required to respect the security of your personal data and to treat it in accordance with the law. + +We may pass your personal data to the following entities: + +* companies and organizations that assist us in processing, verifying or refunding transactions/orders you make and in providing any of the Services that you have requested; +* identity verification agencies to undertake required verification checks; +* fraud or crime prevention agencies to help fight against crimes including fraud, money-laundering, and terrorist financing; +* anyone to whom we lawfully transfer or may transfer our rights and duties under the relevant terms and conditions governing the use of any of the Services; +* any third party because of any restructure, sale or acquisition of our group or any affiliates, provided that any recipient uses your information for the same purposes as it was originally supplied to us and/or used by us; and +* regulatory and law enforcement authorities, whether they are outside or inside of the EEA, where the law allows or requires us to do so. + +## 8\. International Transfers + +We share your personal data within our group. This will involve transferring your personal data outside the European Economic Area (EEA) or the UK. Many of our external third parties are based outside the EEA so their processing of your personal data will involve a transfer of data outside the EEA. + +Whenever we transfer your personal data out of the EEA, we ensure a similar degree of protection is afforded to it by ensuring at least one of the following safeguards is implemented: + +* the country to which we transfer your personal data has been deemed to provide an adequate level of protection for personal data by the European Commission; +* a specific contract approved by the European Commission which gives safeguards to the processing of personal data, the so-called Standard Contractual Clauses. + +## 9\. Data Security + +While there is an inherent risk in any data being shared over the internet, we have put in place appropriate security measures to prevent your personal data from being accidentally lost, used, damaged, or accessed in an unauthorized or unlawful way, altered, or disclosed. In addition, we limit access to your personal data to those employees, agents, contractors and other third parties who have a legitimate business need to know. They will only process your personal data on our instructions, and they are subject to a duty of confidentiality. + +Depending on the nature of the risks presented by the proposed processing of your personal data, we will have in place the following appropriate security measures: + +* organizational measures (including but not limited to staff training and policy development); +* technical measures (including but not limited to the physical protection of data, pseudonymization and encryption); and +* securing ongoing availability, integrity, and accessibility (including but not limited to ensuring appropriate back-ups of personal data are held). + +## 10\. Data Retention + +To determine the appropriate retention period for personal data, we consider the amount, nature, and sensitivity of the personal data, the potential risk of harm from unauthorized use or disclosure of your personal data, the purposes for which we process your personal data, and whether we can achieve those purposes through other means, and the applicable legal, regulatory, tax, accounting or other requirements. + +Here are some exemplary factors which we usually consider when determining how long we need to retain your personal data: + +* in the event of a complaint; +* if we reasonably believe there is a prospect of litigation in respect to our relationship with you; +* to comply with any applicable legal and/or regulatory requirements with respect to certain types of personal data; +* if information is needed for audit purposes and so forth; +* in accordance with relevant industry standards or guidelines; +* in accordance with our legitimate business need to prevent abuse of the promotions that we launch. We will retain a customer’s personal data for the time of the promotion and for a certain period after its end to prevent the appearance of abusive behavior. + +Please note that under certain condition(s), you can ask us to delete your data: see your legal rights below for further information. We will honor your deletion request ONLY if the condition(s) is met. + +## 11\. Your Legal Rights + +You have rights we need to make you aware of. The rights available to you depend on our reason for processing your personal data. If you need more detailed information or wish to exercise any of the rights set out below, please contact us. + +* request access to your personal data; +* request rectification of your personal data; +* request erasure of your personal data; +* object to the processing of your personal data; +* require that decisions be reconsidered if they are made solely by automated means; +* request restriction of processing your personal data; +* request the transfer of your personal data to you or to a third party; +* withdraw consent at any time; +* complain to the IDPC, ICO, DPC or any relevant authority about any perceived violation. + +### No fee usually required + +You will not have to pay a fee to access your personal data (or to exercise any of the other rights). However, we may charge a reasonable fee if your request is manifestly unfounded or excessive. Alternatively, we could refuse to comply with your request in these circumstances. + +### Period for replying to a legitimate request + +The statutory period under GDPR for us to reply to a legitimate request is one month. That period may be extended by two further months where necessary, taking into account the complexity and number of the requests. + +Please note that we may request that you provide some details necessary to verify your identity when you request to exercise a legal right regarding your personal data. + +### Contact details + +If you want any further information from us on privacy matters, please contact our privacy compliance team at legal@gleec.com. diff --git a/assets/legal/terms-of-service.md b/assets/legal/terms-of-service.md new file mode 100644 index 0000000000..8922a38ea6 --- /dev/null +++ b/assets/legal/terms-of-service.md @@ -0,0 +1,31 @@ +# Terms of Service + +These Terms of Service and any terms incorporated herein (the “Terms”) apply to your (“User”) use of the “Technology platform”, including [https://exchange.gleec.com](https://exchange.gleec.com/), the technology and the platform integrated therein and any applications associated therewith, which are operated and maintained by **Gleec Pay LTD and its affiliates companies (“Gleec”, “Gleec.com”, “The Company”, “we”, “us”, “our”)**. + +These Terms apply in connection with the use of any of our products, services or applications – including but not limited to **Gleec Pay, Gleec Card, Gleec Chat, Gleec Exchange and Gleec DEX (collectively the “Services” or the “Platform”)** and your visit or use of our website **Gleec.com (“Site”)** or any of **GLEEC’s ecosystem mobile application (“App”)**. We provide you the possibility to use Our Technology Platform on the following terms and conditions. + +**I. GENERAL PROVISIONS** + +**1. DEFINITIONS** 1.1. “Communications” shall mean all and any communication, agreement, document, receipt, notice and disclosure, which may be from time to time addressed to User by Gleec. 1.2. “Crypto assets” shall mean such type of assets which can only and exclusively be transmitted by means of block-chain technology, including but not limited to digital coins and digital tokens and any other type of digital mediums of exchange, such as Bitcoin, Ethereum, Ripple, etc, to the full and absolute exempt of the securities of any kind. 1.3. “Deposit/Withdrawal” of crypto assets shall mean remittance of crypto assets to/from Gleec Account from/to external third-party service accordingly. 1.4. “Feedback” is any feedback, suggestion, idea or other information or material regarding Gleec or our Services that you provide, whether by email, posting through our Services or otherwise. 1.5. "Force Majeure Event" shall be understood as any event beyond Gleec's reasonable control, including but not limited to the flood, extraordinary weather conditions, earthquake, or other act of God, fire, war, insurrection, riot, labor dispute, accident, action of government, suspension of bank accounts of any kind, extraordinary leaps of the course of crypto asset, communications, network or power failure, or equipment or software malfunction or any other cause beyond Gleec's reasonable control. 1.6. “Gleec Account” is a User account accessible after the registration process and via the Services where crypto assets may be stored and operated by **GLEEC PAY LTD** on behalf of a User. 1.7. “Gleec IP” shall mean all and any copyright and other intellectual property rights in all content and other materials contained on the Technology Platform or provided in connection with the Services, including, without limitation, the Gleec name, trademark, Gleec logo and all designs, text, graphics, pictures, information, data, software, technologies, know-hows, sound and video files, other files and the selection and arrangement thereof. 1.8. "Third-Party Content" is the content provided by third parties, including without limitation links to web pages of such parties, which may be represented on the Technology Platform. 1.9. "Third-party service" is any platform or network in which crypto assets belong to you or where you are the beneficial owner of crypto assets; and this platform is maintained by a third party outside of the Services; including, but not limited to third-party accounts. 1.10. “Trade” shall be understood as an exchange of the crypto asset of one type, owned by one Gleec Account User, to the crypto asset of another type, owned by the same or another Gleec account User, at the terms and conditions set forth by such exchange parties, and which is executed solely and exclusively within the Technology Platform with crypto assets deposited to those Users’ Gleec Accounts. In no case shall the Trade be deemed or construed to be a marginal trade. 1.11. “Transfer” for the purposes herein shall mean a record of Deposit, Withdrawal and/or Trade transaction of crypto asset into, out from or at User’s Gleec Account, which is technically executed by Gleec in accordance with User’s Deposit/Withdrawal request or Trade order. + +**2. WARRANTIES, REPRESENTATIONS AND COVENANTS** 2.1. It is a pre-condition that our Services are only provided to those who are permitted to enter in legally binding relationships. Therefore, if there is any reason why you are not able to enter into legally binding relationships with Us, for whatever reason - do not use our Services. 2.2. You further represent and warrant that you: a. are at least 18 years old or of other legal age, according to your relevant jurisdiction; b. have not previously been suspended or removed from our Services; c. have full power and authority to enter into this legal relationship and by doing so will not violate any other legal relationships; d. use our Technology Platform with your own e-mail and for your own benefit and do not act on behalf and/or to the interest of any other person; e. guarantee that your crypto assets, which you transfer to the Technology Platform are not sold, encumbered, not in contention, or under seizure, and that neither exist any rights of third parties to your crypto assets; f. are not Politically Exposed Person (PEP) or family member or close associate of the PEP. PEPs (as well as family members or close associates of the PEPs) are not allowed to use Gleec's Technology Platform. In case Gleec will establish, that the User of the Technology Platform is PEP (as well as family member or close associate of the PEP), Gleec reserves the right to terminate Gleec Account of such User. For the purpose of this Terms, the definitions "Politically Exposed Person", "family member" and "close associate" have the meaning, as it defines in (i) the FATF Recommendations (International Standards on Combating Money Laundering and the Financing of Terrorism and Proliferation) – available via [http://www.fatf-gafi.org/media/fatf/documents/recommendations/pdfs/FATF](http://www.fatf-gafi.org/media/fatf/documents/recommendations/pdfs/FATF) Recommendations 2012.pdf and (ii) FATF Guidance on Politically Exposed Persons (Recommendations 12 and 22\) – available via [http://www.fatf-gafi.org/media/fatf/documents/recommendations/Guidance-PEP-Rec12-22.pdf](http://www.fatf-gafi.org/media/fatf/documents/recommendations/Guidance-PEP-Rec12-22.pdf); g. will not use our Services or will immediately cease using those if you are a resident or become a resident at any time of the state or region (in accordance with such state or region definition of residency), where the crypto assets transactions you are going to execute are prohibited or require special approval, permit and/or authorization of any kind, which Gleec has not obtained in this state or region. Notwithstanding the above, you are not in any case allowed to access and use our Services if you are located, incorporated or otherwise established, or a citizen or resident of: Afghanistan, Albania, American Samoa, Anguilla, Antigua and Barbuda, Aruba, Bahamas, Barbados, Belarus, Bermuda, Burkina Faso, Burundi, Cambodia, Cayman Islands, Central African Republic, Congo (Democratic Republic of the), Cook Islands, Cuba, Curaçao, Crimea and Sevastopol, Dominica, Fiji, Guam, Guinea, Guinea-Bissau, Haiti, Iran (Islamic Republic of), Iraq, Jamaica, Jordan, Korea (Democratic People's Republic of), Libya, Mali, Marshall Islands, Morocco, Myanmar (Burma), Nicaragua, Pakistan, Palau, Palestine (State of), Panama, Philippines, Russian Federation, Saint Kitts and Nevis, Saint Lucia, Saint Vincent and the Grenadines, Samoa, Senegal, Seychelles, Somalia, South Sudan, Sudan, Syrian Arab Republic, Trinidad and Tobago, Turks and Caicos Islands, Uganda, United States Of America, Vanuatu, Venezuela (Bolivarian Republic of), Virgin Islands (British), Virgin Islands (U.S.), Yemen, Zimbabwe or if you are a resident of the United Kingdom or any state, country or other jurisdiction that is embargoed by the United States of America, or a jurisdiction where the local Applicable Law prohibits or will prohibit you at any time (by reason of your nationality, domicile, citizenship, residence or otherwise) to access or use the Services or the Technology Platform (hereinafter, together “The Restricted Jurisdictions”). Gleec reserves the right to close any of your Gleec Accounts, to liquidate any open trade positions, and to force you to withdraw all the crypto assets from the Technology Platform in case if: (i) Gleec determines that you are accessing the Services or the Technology Platform from any Restricted Jurisdiction, or (ii) you have given false representations as to your location of incorporation, establishment, citizenship or place of residence. For the purpose of this clause "Applicable Law" refers to all applicable laws of any governmental authority, including, without limitation, federal, state and foreign securities laws, tax laws, tariff and trade laws, ordinances, judgments, decrees, injunctions, writs and orders or like actions of any governmental authority and rules, regulations, orders, interpretations, licenses, and permits of any federal, regional, state, county, municipal or other governmental authority. + +2.3. When accessing or using the Technology Platform, you further represent, agree and warrant, that you will not violate any law, contract, intellectual property or other third-party right or commit a tort, and that you are solely responsible for your conduct while using our Technology Platform. Without prejudice to the generality of the foregoing, you represent, agree and warrant, that you will not: a. Use the Technology Platform in any manner that could interfere with, disrupt, negatively affect or inhibit other users from using our Technology Platform with full functionality, or that could damage, disable, overburden or impair the functioning of Technology Platform in any manner; b. Use the Technology Platform to pay for, support or otherwise engage in any illegal gambling activities; fraud; money-laundering; or terrorist activities; or any other illegal activities; c. Use any robot, spider, crawler, scraper or other automated means or interface not provided by Us to access the Technology Platform or to extract data; d. Use or attempt to use another User account without authorization; e. Attempt to circumvent any content filtering techniques We employ, or attempt to access any service or area of our Technology Platform that you are not authorized to access; f. Develop any third-party applications that interact with our Technology Platform without our prior written consent; g. Provide false, inaccurate, or misleading information;h. Encourage or induce any third party to engage in any of the activities prohibited under this Section. 2.4. YOU INDEMNIFY AND HOLD **GLEEC PAY LTD** HARMLESS AGAINST ANY CLAIMS, DEMANDS AND DAMAGES, WHETHER DIRECT, INDIRECT, CONSEQUENTIAL OR SPECIAL, OR ANY OTHER DAMAGES OF ANY KIND, INCLUDING, BUT NOT LIMITED TO, LOSS OF USE, LOSS OF PROFITS OR LOSS OF DATA, WHETHER IN AN ACTION IN CONTRACT, TORT (INCLUDING BUT NOT LIMITED TO NEGLIGENCE) OR OTHERWISE, ORIGINATED FROM OR IN ANY WAY CONNECTED WITH INVALIDITY OR BREACH OF ANY OF THE WARRANTIES, REPRESENTATIONS AND COVENANTS OF THIS SECTION AND THE ENTIRE TERMS. + +**3. RISK DISCLOSURE** 3.1. Due to our internal policies, We only provide the Services to users with sufficient experience, knowledge and understanding of the work principles of our Technology Platform, and those who fully understand the associated risks. You acknowledge and agree that you shall access and use the Technology Platform at your own risk. The risk of loss in Trading crypto assets can be substantial. You should, therefore, carefully consider whether such Trading is appropriate for you in light of your circumstances and resources. You acknowledge and agree the possibility of the following: a. You may sustain a total loss of the crypto assets in your Gleec Account, and, in some cases, you may incur losses beyond such crypto assets. b. Under certain market conditions, you may find it difficult or impossible to liquidate a position. This can occur, for example, when the market reaches a daily price fluctuation limit ("limit move") and there is insufficient liquidity in the market. c. Placing contingent orders, such as "stop-loss" or "stop-limit" orders, will not necessarily limit your losses to the intended amounts, since market conditions may make it impossible to execute such orders. d. All crypto assets positions involve risk, and a "spread" position may not be less risky than an outright "long" or "short" position. e. The use of leverage can work against you as well as for you and can lead to large losses as well as gains. f. All of the points noted above apply to all crypto assets. This brief statement cannot, however, disclose all the risks and other aspects associated with the Trade of crypto assets and shall not be considered as any professional advice. 3.2. Risks Associated with the Internet-based Trading System. You acknowledge that there are risks associated with utilizing an Internet-based trading system including, but not limited to, the failure of hardware, software, and Internet connections. You acknowledge that Gleec shall not be responsible for any communication failures, disruptions, errors, distortions or delays you may experience when using the Technology Platform, howsoever caused.3.3. Risks Associated with the Blockchain Protocol. The Technology Platform and its related Services are based on the Blockchain protocol. As such, any malfunction, unintended function, unexpected functioning of or attack on the Blockchain protocol may cause the Technology Platform to malfunction or function in an unexpected or unintended manner. 3.4. Risks Associated with Blockchains and Crypto Assets. You acknowledge and accept that the Gleec has no control over any cryptocurrency network and you understand all risks associated with utilizing any crypto assets network, including, but not limited to the risk of unknown vulnerabilities in or unanticipated changes to any network protocol. We will not be responsible for any harm occurring as a result of such risks. 3.5. No Control Over Your Own Actions. As defined in the foregoing cl. 4.3 (g) and other provisions herein YOU AGREE TO INDEMNIFY AND HOLD GLEEC HARMLESS AGAINST ANY CLAIMS, DEMANDS AND DAMAGES, WHETHER DIRECT, INDIRECT, CONSEQUENTIAL OR SPECIAL, OR ANY OTHER DAMAGES OF ANY KIND, INCLUDING, BUT NOT LIMITED TO, LOSS OF USE, LOSS OF PROFITS OR LOSS OF DATA, WHETHER IN AN ACTION, IN CONTRACT, TORT (INCLUDING BUT NOT LIMITED TO NEGLIGENCE) OR OTHERWISE, ORIGINATED FROM OR IN ANY WAY CONNECTED WITH YOUR USE OF OUR TECHNOLOGY PLATFORM, INCLUDING, BUT NOT LIMITED TO THOSE ARISING FROM YOUR PERSONAL ERROR AND MISBEHAVIOR SUCH AS FORGOTTEN PASSWORDS, INCORRECTLY CONSTRUCTED TRANSACTIONS, LOSS OF YOUR ACCESSES ETC. + +**4. CRYPTO ASSETS PROTECTION** 4.1. We strive to protect your crypto assets from unauthorized access, use, or spending. We use a variety of physical and technical measures designed to protect our systems and your crypto assets. By remitting your crypto assets to Gleec Account you entrust and entitle Us to ultimately take decisions on the safety and security of your crypto assets. 4.2. We reserve the right to take different measures of protection, which include, but are not limited to a diversification of crypto assets in different allocations whether on a segregate record (account) or not. 4.3. Nothing herein shall be deemed or construed as a willingness to a seizure of your crypto assets. The main purpose of this section is to notify you about different measures of protection, that We use to keep your crypto assets safe. + +**5. ELECTRONIC NOTICES** 5.1. Consent to Electronic Delivery. You agree and consent to receive electronically all Communications, that Gleec may be willing to communicate to you in connection with your Gleec Account and/or use of the Gleec Services. You agree that Gleec may provide these Communications to you by posting them on the Technology Platform. You may contact Us through our Support Center ([https://support.exchange.gleec.com/hc/en-us](https://support.exchange.gleec.com/hc/en-us)) to request electronic copies of Communications. 5.2. Withdrawal of Consent. You may withdraw your consent to receive electronic Communications by sending a withdrawal notice to support. If this is a case you waive your right to plead ignorance. If you decline or withdraw consent to receive electronic Communications, Gleec may suspend or terminate your use of the Technology Platform. 5.3. Updating Contact Information. It is your responsibility to keep your email address on file with Gleec up to date so that Gleec can communicate with you electronically. You understand and agree that if Gleec sends you an electronic Communication but you do not receive it because your email address on file is incorrect, out of date, blocked by your service provider, or you are otherwise unable to receive electronic Communications, Gleec will be deemed to have provided the Communication to you. You waive your right to plead ignorance. Please note that if you use a spam filter that blocks or re-routes emails from senders not listed in your email address book, you must add Gleec to your email address book so that you will be able to receive the Communications We send you. If your email address becomes invalid in a such way that electronic Communications sent to you by Gleec are returned, Gleec may deem your account being inactive, and you may be not able to complete any transaction via the Technology Platform. + +**6. SPECIAL CONDITIONS** 6.1. Transfer confirmation. Once your Deposit/Withdrawal request or Trade order is executed, a confirmation will be electronically made available via Technology Platform detailing the particulars of the Transfer. You acknowledge and agree that the failure of the Technology Platform to provide such confirmation shall not prejudice or invalidate the terms of such transaction. 6.2. Conditions and Restrictions. We may, at any time and in our sole discretion, refuse to perform any Transfer requested via the Technology Platform, impose limits on the Transfer amount permitted via the Technology Platform or impose any other conditions or restrictions upon your use of the Technology Platform without prior notice. 6.3. Access to the Services. We may, in our sole discretion and without liability to you, with or without prior notice and at any time, modify or discontinue, temporarily or permanently, any portion of our Services. 6.4. Cancellations. You may only cancel a Transfer request initiated via the Technology Platform if such cancellation occurs before Gleec executes the Transfer. Once your Transfer request has been executed, you may not change, withdraw or cancel your authorization for Gleec to complete such Transfer. If a Trade order has been partially filled, you may cancel the unfilled remainder unless the order relates to a market rate Trade. We reserve the right to refuse any cancellation request associated with a market rate Trade order once you have submitted such order. While We may, at our sole discretion, reverse a Trade under certain extraordinary conditions, a customer does not have a right to a reversal of a Trade. 6.5. Insufficient Crypto Assets. If you have an insufficient amount of crypto assets in your Gleec Account to complete a Transfer via the Technology Platform, We may cancel the entire order or may fulfill a partial order using the amount of crypto assets currently available in your Gleec Account. 6.6. Taxes. It is your responsibility to determine what, if any, taxes apply to the Transfers you complete via the Technology Platform, and it is your responsibility to report and remit the correct tax to the appropriate tax authority. You agree that Gleec is not responsible for determining whether taxes apply to your transfers or for collecting, reporting, withholding or remitting any taxes arising from any Trades and Transfers and does not act as your tax agent. 6.7. Feedbacks. We own exclusive rights, including all intellectual property rights, to Feedback. Any Feedback you submit is non-confidential and shall become the sole property of Gleec. We will be entitled to the unrestricted use and dissemination of such Feedback for any purpose, commercial or otherwise, without acknowledgment or compensation to you. You waive any rights you may have to the Feedback (including inter alia any copyrights). Do not provide Feedback if you expect to be paid or want to continue to own or claim rights on it; your idea might be great, but We may have already had the same or a similar idea and We do not want disputes. We also have the right to disclose your identity to any third party who is claiming that any content posted by you constitutes a violation of their intellectual property rights, or of their right to privacy. We have the right to remove any posting you make on our website if, in our opinion, your post does not comply with the content standards set out therein. + +**7. SUSPENSION AND TERMINATION OF YOUR GLEEC ACCOUNT** 7.1. In case of your breach of the Terms, or any other event as We may deem necessary, including without limitation market disruption and/or Force Majeure event We may, in our sole discretion and without liability to you, with or without prior notice: a. suspend your access to all or a portion of our Services; or b. prevent you from completing any actions via the Technology Platform, including closing any open Trade orders. In case the transferring resumes, you acknowledge that prevailing market rates may differ significantly from the rates available prior to such event; or c. terminate your access to the Services, delete or deactivate your Gleec Account and all related information and files in such account. 7.2. In the event of termination, Gleec will return any crypto assets stored in your Gleec Account and not owed to Gleec, unless Gleec believes you have committed fraud, negligence or other misconduct. + +**8. DISCLAIMER OF WARRANTIES. LIMITATION OF LIABILITIES** 8.1. Except as expressly provided to the contrary in a writing by Us, our services are provided on an "As is" and "As available" basis. WE EXPRESSLY DISCLAIM, AND YOU WAIVE, ALL WARRANTIES OF ANY KIND, WHETHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT AS TO OUR SERVICES, INCLUDING THE INFORMATION, CONTENT AND MATERIALS CONTAINED THEREIN. 8.2. You acknowledge that information you store or transfer through our services may become irretrievably lost or corrupted or temporarily unavailable due to a variety of causes, including but not limited to software failures, protocol changes by third party providers, internet outages, Force Majeure event or other disasters including third party DDOS attacks, scheduled or unscheduled maintenance, or other causes either within or outside our control. You are solely responsible for backing up and maintaining duplicate copies of any information you store or transfer through our services. 8.3. Except as otherwise required by law, IN NO EVENT SHALL GLEEC, OUR DIRECTORS, OFFICERS, MEMBERS, EMPLOYEES OR AGENTS BE LIABLE FOR ANY DIRECT, INDIRECT, CONSEQUENTIAL OR SPECIAL DAMAGES, OR ANY OTHER DAMAGES OF ANY KIND, INCLUDING, BUT NOT LIMITED TO, LOSS OF USE, LOSS OF PROFITS OR LOSS OF DATA, WHETHER IN AN ACTION IN CONTRACT, TORT (INCLUDING BUT NOT LIMITED TO NEGLIGENCE) OR OTHERWISE, ARISING OUT OF OR IN ANY WAY CONNECTED WITH THE USE OF OR INABILITY TO USE OUR SERVICES OR THE GLEEC IP, INCLUDING WITHOUT LIMITATION ANY DAMAGES CAUSED BY OR RESULTING FROM RELIANCE BY ANY USER ON ANY INFORMATION OBTAINED FROM GLEEC, OR THAT RESULT FROM MISTAKES, OMISSIONS, INTERRUPTIONS, DELETION OF FILES OR EMAIL, ERRORS, DEFECTS, VIRUSES, DELAYS IN OPERATION OR TRANSMISSION OR ANY FAILURE OF PERFORMANCE, WHETHER OR NOT RESULTING FROM A FORCE MAJEURE EVENT, COMMUNICATIONS FAILURE, THEFT, DESTRUCTION OR UNAUTHORIZED ACCESS TO GLEEC'S RECORDS, PROGRAMS OR SERVICES. 8.4. We resume the right, in our sole discretion, to control any action or proceeding (at our expense) to which We are a participant and determine whether We wish to settle it. 8.5. To the maximum extent permitted by applicable law, IN NO EVENT SHALL THE AGGREGATE LIABILITY OF GLEEC (INCLUDING OUR DIRECTORS, OFFICERS, MEMBERS, EMPLOYEES AND AGENTS), WHETHER IN CONTRACT, WARRANTY, TORT (INCLUDING NEGLIGENCE, WHETHER ACTIVE, PASSIVE OR IMPUTED), PRODUCT LIABILITY, STRICT LIABILITY OR OTHER THEORY, ARISING OUT OF OR RELATING TO THE USE OF, OR INABILITY TO USE, GLEEC OR TO THESE TERMS EXCEED THE EQUIVALENT OF $100 USD. 8.6. Gleec shall not be liable for: a. any inaccuracy, error, delay in, or omission of (i) any information, or (ii) the transmission or delivery of information; b. any loss or damage arising from a Force Majeure Event. 8.7. We strive to protect our users from fraudulent and scam activities in crypto assets sphere. It is possible that some crypto assets are purposed for unlawful seizure of the property, or are construed as a fraud, scam or any other activity, recognized by the laws as illegal and/or non-compliant with legal requirements. We reserve the right to prohibit and discontinue any transactions on our Technology Platform with such crypto asset at our sole discretion, without any prior notice to you and without publication of the reason for such decision, whenever this comes to our knowledge. YOU INDEMNIFY AND HOLD **GLEEC PAY LTD** HARMLESS AGAINST ANY CLAIMS, DEMANDS AND DAMAGES, WHETHER DIRECT, INDIRECT, CONSEQUENTIAL OR SPECIAL, OR ANY OTHER DAMAGES OF ANY KIND, INCLUDING, BUT NOT LIMITED TO, LOSS OF USE, LOSS OF PROFITS OR LOSS OF DATA, WHETHER IN AN ACTION IN CONTRACT, TORT (INCLUDING BUT NOT LIMITED TO NEGLIGENCE) OR OTHERWISE, ORIGINATED FROM OR IN ANY WAY CONNECTED WITH PROHIBITION AND DISCONTINUATION OF TRANSACTIONS IN OUR TECHNOLOGY PLATFORM WITH ANY CRYPTO ASSET. 8.8. To be able to access to our Technology Platform, the User may use without limitation different mobile devices, such as mobile internet devices, tablets/smartphones and wearable computers ("Mobile Devices"). In no way, we make any guarantee that each particular Mobile Device is compatible with our Technology Platform. YOU AGREE TO INDEMNIFY AND HOLD GLEEC HARMLESS AGAINST ANY CLAIMS, DEMANDS AND DAMAGES, WHETHER DIRECT, INDIRECT, CONSEQUENTIAL OR SPECIAL, OR ANY OTHER DAMAGES OF ANY KIND, INCLUDING, BUT NOT LIMITED TO, LOSS OF USE, LOSS OF PROFITS OR LOSS OF DATA, WHETHER IN AN ACTION, IN CONTRACT, TORT (INCLUDING BUT NOT LIMITED TO NEGLIGENCE) OR OTHERWISE, ORIGINATED FROM OR IN ANY WAY CONNECTED WITH YOUR USE OF OUR TECHNOLOGY PLATFORM VIA MOBILE DEVICES, INCLUDING, BUT NOT LIMITED TO THOSE ARISING FROM YOUR INABILITY TO CONNECT TO OUR TECHNOLOGY PLATFORM FROM PARTICULAR MOBILE DEVICE AND YOUR PERSONAL ERROR AND MISBEHAVIOR SUCH AS YOUR LOSS/DAMAGE/DEFECT OF SUCH MOBILE DEVICE. + +**9. NO OFFER OF SECURITIES** 9.1. Gleec endeavors all possible measures to be sure that crypto assets that are available via the Technology Platform cannot be classified as "security" by SEC and/or other competent national authorities. Moreover, Gleec represents that it never intended or desired to make tokens and/or coins that can be classified as "security" available via Technology Platform. 9.2. The responsibility for the fact that the instrument cannot be treated as “security” lies with the owner of token and/or coin. If there is any risk or speculations that token and/or coin can be treated as “security”, the Technology Platform reserves the right to prohibit and discontinue any transactions on our Technology Platform with such tokens and/or coins at its sole discretion. 9.3. We follow the best practices to decide whether crypto asset is security or not. However, We give no warranty and/or investment, financial, legal or any other professional advice, that any crypto asset available via our Technology Platform is not a security. + +**10. APPLICABLE LAW; ARBITRATION** 10.1. You and Gleec agree to arbitrate any dispute arising from these Terms or your use of the Services, except for disputes in which either party seeks equitable and other relief for the alleged unlawful use of copyrights, trademarks, trade names, logos, trade secrets or patents. ARBITRATION PREVENTS YOU FROM SUING IN COURT OR FROM HAVING A JURY TRIAL. 10.2. You and Gleec agree to notify each other in writing of any dispute within thirty (30) days of when it arises. Notice to Gleec shall be sent to legal@exchange.gleec.com. 10.3. Any dispute, controversy, difference or claim arising out of or relating to the Terms, including the existence, validity, interpretation, performance, breach or termination thereof or any dispute regarding non-contractual obligations arising out of or relating to it shall be referred to and finally resolved by arbitration administered by the Estonian Government under the the regulations of the Civil Code of Procedure in Estonia in force when the Notice of Arbitration is submitted. 10.4. The law of this arbitration clause shall be Estonia law. 10.5. The seat of arbitration shall be in Estonia. 10.6. The number of arbitrators shall be three. The arbitration proceedings shall be conducted in English language. 10.7. Other than class procedures and remedies discussed below, the arbitrator has the authority to grant any remedy that would otherwise be available in court. Any dispute between the parties will be governed by these Terms and the laws of Hong Kong, without giving effect to any conflict of laws principles that may provide for the application of the law of another jurisdiction. 10.8. Whether the dispute is heard in arbitration or in court, you will not commence against Gleec a class action, class arbitration or representative action or proceeding. + +**11. MISCELLANEOUS** 11.1. Entire Agreement. These Terms contain the entire agreement, and supersede all prior and contemporaneous understandings between the parties regarding the Services. 11.2. Order of Precedence. In the event of any conflict between these Terms and any other agreement you may have with Gleec, the terms of that other agreement will prevail only if these Terms are specifically identified and declared to be overridden by such other agreement. 11.3. Amendments. We reserve the right to make changes or modifications to these Terms from time to time, in our sole discretion. If We make changes to these Terms, We will provide you with notice of such changes, such as by sending an e-mail, providing notice on the homepage of the Site and/or by posting the amended Terms via the applicable Gleec websites and mobile applications and updating the "Last Updated" date at the top of these Terms. The amended Terms will be deemed effective immediately upon posting for any new users of the Services. In all other cases, the amended Terms will become effective for preexisting users upon the earlier of either: a. the date User click or press a button to accept such changes or; b. the date User continues use of our Services after Gleec provides notice of such changes or publishes new version of the Terms on the Website. 11.4. Any amended Terms will apply prospectively to use of the Services after such changes become effective. If you do not agree to any amended Terms, you must discontinue using our Services and contact us to terminate your account. 11.5. No Waiver. Our failure or delay in exercising any right, power or privilege under these Terms shall not operate as a waiver thereof. 11.6. Severability. The invalidity or unenforceability of any of these Terms shall not affect the validity or enforceability of any other of these Terms, all of which shall remain in full force and effect. 11.7. Assignment. You may not assign or transfer any of your rights or obligations under these Terms without prior written consent from Gleec, including by operation of law or in connection with any change of control. Gleec may assign or transfer any or all of its rights under these Terms, in whole or in part, without obtaining your consent or approval. diff --git a/assets/translations/en.json b/assets/translations/en.json index 906ef6f0d3..2f145fe3f7 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -82,6 +82,8 @@ "portfolio": "Portfolio", "editList": "Edit list", "withBalance": "Hide 0 balance assets", + "hideBalancesTitle": "Hide balances", + "hideBalancesSubtitle": "Mask balances and fiat values", "balance": "Balance", "transactions": "Transactions", "send": "Send", @@ -146,6 +148,8 @@ "settingsMenuGeneral": "General", "settingsMenuLanguage": "Change language", "settingsMenuSecurity": "Security", + "settingsMenuPrivacy": "Privacy Notice", + "settingsMenuKycPolicy": "KYC Policy", "settingsMenuAbout": "About", "seedPhraseSettingControlsViewSeed": "View seed phrase", "seedPhraseSettingControlsDownloadSeed": "Download as file", @@ -365,7 +369,8 @@ "seedIntroWarning": "This phrase is the main access to your\nassets, save and never share this phrase", "seedSettings": "Seed phrase", "errorDescription": "Error description", - "tryAgain": "Oops! Something went wrong. \nPlease try again. \nIf it didn't help - contact us.", + "tryAgain": "Try again", + "errorTryAgainSupportHint": "Something went wrong. Try again, or contact us if the problem continues.", "customFeesWarning": "Only use custom fees if you know what you are doing!", "fiatExchange": "Exchange", "bridgeExchange": "Exchange", @@ -380,6 +385,8 @@ "trezorErrorInvalidPin": "Invalid PIN code", "trezorSelectTitle": "Connect a hardware wallet", "trezorSelectSubTitle": "Select a hardware wallet you'd like to use with Gleec Wallet", + "trezorWalletOnlyTooltip": "Trezor currently supports wallet-only mode. Trading features are disabled.", + "trezorWalletOnlyNotice": "Trezor currently supports wallet-only mode. Trading and swaps are unavailable for now.", "trezorBrowserUnsupported": "Trezor is not supported on this browser.\nPlease use Chrome for Trezor functionality.", "trezorTransactionInProgressMessage": "Please confirm transaction on your Trezor device", "trezorInitializingMessage": "Initializing Trezor device...", @@ -413,6 +420,7 @@ "setMin": "Set Min", "timeout": "Timeout", "notEnoughBalanceForGasError": "Not enough balance to pay gas.", + "cannotSendToSelf": "Cannot send funds to the same address you are sending from. Please use a different recipient address.", "notEnoughFundsError": "Not enough funds to perform a trade", "dexErrorMessage": "Something went wrong!", "dexUnableToStartSwap": "Unable to start swap. Refresh the quote and try again.", @@ -420,7 +428,7 @@ "seedConfirmIncorrectText": "Incorrect seed phrase", "mnemonicInvalidWordError": "Your seed phrase contains an unknown word. Please check spelling and try again.", "mnemonicInvalidChecksumError": "Your seed phrase appears valid but the checksum doesn't match. Check the word order and spacing.", - "mnemonicInvalidLengthError": "Seed phrase must contain between {} and {} words.", + "mnemonicInvalidLengthError": "Seed phrase must contain 12, 15, 18, 21, or 24 words.", "usedSamePassword": "This password matches your current one. Please create a different password.", "passwordNotAccepted": "Password not accepted", "confirmNewPassword": "Confirm new password", @@ -430,6 +438,7 @@ "passwordIsEmpty": "Password is empty", "passwordContainsTheWordPassword": "Password cannot contain the word 'password'", "passwordTooShort": "Password must be at least 8 characters long", + "passwordTooLong": "Password must be at most 128 characters long", "passwordMissingDigit": "Password must contain at least 1 digit", "passwordMissingLowercase": "Password must contain at least 1 lowercase character", "passwordMissingUppercase": "Password must contain at least 1 uppercase character", @@ -475,6 +484,29 @@ "withdrawPreview": "Preview Withdrawal", "withdrawPreviewZhtlcNote": "ZHTLC transactions can take a while to generate.\nPlease stay on this page until the preview is ready, otherwise you will need to start over.", "withdrawPreviewError": "Error occurred while fetching withdrawal preview", + "withdrawDestination": "Destination", + "withdrawNetworkDetails": "Network details", + "withdrawHighFee": "High fee", + "withdrawPreviewExpiresIn": "Preview expires in {}s", + "withdrawPreviewRefreshing": "Refreshing preview...", + "withdrawTronBandwidthUsed": "Bandwidth (NET) used", + "withdrawTronBandwidthFee": "Bandwidth fee", + "withdrawTronBandwidthSource": "Bandwidth source", + "withdrawTronEnergyUsed": "Energy used", + "withdrawTronEnergyFee": "Energy fee", + "withdrawTronEnergySource": "Energy source", + "withdrawTronAccountActivationFee": "Account activation fee", + "withdrawTronFeeSummary": "TRON fee summary", + "withdrawTronFeePaidIn": "Paid in {}", + "withdrawTronBandwidthCovered": "Covered by free NET bandwidth", + "withdrawTronEnergyCovered": "Covered by free energy", + "withdrawTronResourceNotUsed": "Not used", + "withdrawTronFeeSummaryCharged": "Network will charge {} {}.", + "withdrawTronFeeSummaryCovered": "No {} fee will be charged (covered by resources).", + "withdrawTronPreviewExpired": "This TRON transaction preview expired. Regenerate it to continue.", + "withdrawTronPreviewRefreshFailed": "This TRON transaction preview expired and could not be refreshed.", + "withdrawTronPreviewRegenerate": "Regenerate", + "withdrawAwaitingConfirmations": "Awaiting confirmations", "txHistoryFetchError": "Error fetching tx history from the endpoint. Unsupported type: {}", "txHistoryNoTransactions": "Transactions are not available", "maxGapLimitReached": "Maximum gap limit reached - please use existing unused addresses first", @@ -485,6 +517,8 @@ "hdWalletModeSwitchTitle": "Multi-address Wallet?", "hdWalletModeSwitchSubtitle": "WARNING: Your addresses and balances will be completely different in HD mode - coins are not lost, just in different addresses.", "hdWalletModeSwitchTooltip": "HD wallets require a valid BIP39 seed phrase.", + "multiAddressWalletNoticeTitle": "Multi-address mode is active", + "multiAddressWalletNoticeDescription": "Balances and addresses differ from single-address mode. If funds appear missing, switch back to Legacy (single-address) or resync to view the correct address set.", "noActiveWallet": "No active wallet - please sign in first", "memo": "Memo", "gasPriceGwei": "Gas price [Gwei]", @@ -555,8 +589,11 @@ "useCustomSeedOrWif": "Use custom seed phrase / private key (WIF)", "cancelOrder": "Cancel Order", "version": "version", + "buildDate": "Build date", "copyAddressToClipboard": "Copy {} address to clipboard", "copiedAddressToClipboard": "{} address copied to clipboard", + "copyUuid": "Copy UUID", + "copiedUuidToClipboard": "UUID copied to clipboard", "createdAt": "Created at", "coin": "Coin", "token": "Token", @@ -594,10 +631,17 @@ "accessHiddenWallet": "Access hidden wallet", "passphraseIsEmpty": "Passphrase is empty", "selectWalletType": "Select wallet type", + "walletImportTypeHdLabel": "Multi-address (HD)", + "walletImportTypeHdDescription": "Recommended for BIP39 seed phrases", + "walletImportTypeLegacyLabel": "Single-address (Legacy)", + "walletImportTypeLegacyDescription": "Use for custom seeds/private keys (non-BIP39)", + "walletImportTypeHdDisabledHint": "HD mode is unavailable because this wallet seed is not BIP39 compatible.", "trezorNoAddresses": "Please generate an address", "trezorImportFailed": "Failed to import {}", "faucetFailureTitle": "Failure", "faucetLoadingTitle": "Loading...", + "faucetLoadingMessage": "Requesting faucet funds", + "faucetLoadingSubtitle": "This can take a minute. Keep this window open.", "faucetInitialTitle": "Starting...", "faucetUnknownErrorMessage": "Service is unavailable. We bring you our apologies. Please try again later", "faucetLinkToTransaction": "Link to the transaction", @@ -613,6 +657,7 @@ "transactionsEmptyDescription": "Try to send or receive the first NFT to this wallet", "transactionsNoLoginCAT": "There's nothing here yet, please connect your wallet", "loadingError": "Loading Error", + "legalDocumentLoadError": "Failed to load this legal document.", "tryAgainButton": "Try again", "contractAddress": "Contract address", "tokenID": "Token ID", @@ -698,6 +743,15 @@ "expertMode": "Expert mode", "testCoins": "Test coins", "enableTradingBot": "Enable Trading Bot", + "saveOrders": "Save orders", + "saveOrdersRestartHint": "When disabled, saved maker-order configurations are cleared on next launch.", + "exportMakerOrders": "Export maker orders", + "importMakerOrders": "Import maker orders", + "noMakerOrdersToExport": "No saved maker orders to export.", + "makerOrdersExportSuccess": "Exported {} maker order configurations.", + "makerOrdersExportFailed": "Failed to export maker orders: {}", + "makerOrdersImportSuccess": "Imported {} maker order configurations.", + "makerOrdersImportFailed": "Failed to import maker orders: {}", "enableTestCoins": "Enable Test Coins", "diagnosticLogging": "Diagnostic Logging", "enableDiagnosticLogging": "Enable Diagnostic Logging", @@ -734,6 +788,8 @@ "creating": "Creating", "createAddress": "Create Address", "hideZeroBalanceAddresses": "Hide 0 balance addresses", + "showAllAddresses": "Show all addresses", + "showLessAddresses": "Show fewer addresses", "important": "Important", "trend": "Trend", "growth": "Growth", @@ -818,5 +874,109 @@ "zhtlcActivationWarning": "This may take from a few minutes to a few hours, depending on your sync params and how long since your last sync.", "zhtlcAdvancedConfiguration": "Advanced configuration", "zhtlcAdvancedConfigurationHint": "Faster intervals (lower milliseconds) and higher blocks per iteration result in higher memory and CPU usage.", - "zhtlcConfigButton": "Config" + "zhtlcConfigButton": "Config", + + "kdfErrorGeneric": "An unexpected error occurred. Please try again.", + "kdfErrorNotSufficientBalance": "Insufficient balance for this transaction.", + "kdfErrorNotSufficientPlatformBalanceForFee": "Insufficient balance to pay network fees.", + "kdfErrorZeroBalanceToWithdrawMax": "Cannot withdraw: your balance is zero.", + "kdfErrorAmountTooLow": "The amount is too low for this transaction.", + "kdfErrorNotEnoughNftsAmount": "You do not have enough NFTs for this transaction.", + "kdfErrorInvalidAddress": "The provided address is invalid.", + "kdfErrorFromAddressNotFound": "Source address not found.", + "kdfErrorUnexpectedFromAddress": "Unexpected source address.", + "kdfErrorMyAddressNotNftOwner": "You are not the owner of this NFT.", + "kdfErrorNoSuchCoin": "Asset not found or not activated.", + "kdfErrorCoinNotFound": "Asset not found.", + "kdfErrorCoinNotSupported": "This asset is not supported.", + "kdfErrorCoinIsNotActive": "Please activate the asset first.", + "kdfErrorCoinDoesntSupportWithdraw": "This asset does not support withdrawals.", + "kdfErrorCoinDoesntSupportNftWithdraw": "This asset does not support NFT withdrawals.", + "kdfErrorNftProtocolNotSupported": "NFT protocol is not supported for this asset.", + "kdfErrorContractTypeDoesntSupportNft": "This contract type does not support NFT withdrawals.", + "kdfErrorTransport": "Network error. Please check your connection.", + "kdfErrorTimeout": "Request timed out. Please try again.", + "kdfErrorTaskTimedOut": "Operation timed out. Please try again.", + "kdfErrorInvalidResponse": "Received an invalid response from the server.", + "kdfErrorUnreachableNodes": "Unable to connect to network nodes.", + "kdfErrorAtLeastOneNodeRequired": "At least one network node is required.", + "kdfErrorClientConnectionFailed": "Failed to connect to the server.", + "kdfErrorConnectToNodeError": "Failed to connect to network node.", + "kdfErrorActivationFailed": "Failed to activate the asset.", + "kdfErrorCouldNotFetchBalance": "Could not fetch balance. Please try again.", + "kdfErrorUnsupportedChain": "This blockchain is not supported.", + "kdfErrorChainIdNotSet": "Chain ID is not configured.", + "kdfErrorNoChainIdSet": "Chain ID is not set.", + "kdfErrorInvalidFeePolicy": "Invalid fee configuration.", + "kdfErrorInvalidFee": "The specified fee is invalid.", + "kdfErrorInvalidGasApiConfig": "Invalid gas API configuration.", + "kdfErrorNameTooLong": "The name is too long.", + "kdfErrorDescriptionTooLong": "The description is too long.", + "kdfErrorNoSuchAccount": "Account not found.", + "kdfErrorNoEnabledAccount": "No account is enabled.", + "kdfErrorAccountExistsAlready": "An account with this name already exists.", + "kdfErrorUnknownAccount": "Unknown account.", + "kdfErrorLoadingAccount": "Failed to load account.", + "kdfErrorSavingAccount": "Failed to save account.", + "kdfErrorHwError": "Hardware wallet error occurred.", + "kdfErrorHwContextNotInitialized": "Hardware wallet is not initialized.", + "kdfErrorCoinDoesntSupportTrezor": "This asset is not supported on Trezor.", + "kdfErrorInvalidHardwareWalletCall": "Invalid hardware wallet operation.", + "kdfErrorNotSupported": "This operation is not supported.", + "kdfErrorVolumeTooLow": "Trade volume is too low.", + "kdfErrorMyRecentSwapsError": "Failed to fetch recent swaps.", + "kdfErrorSwapInfoNotAvailable": "Swap information is not available.", + "kdfErrorInvalidRequest": "Invalid request.", + "kdfErrorInvalidPayload": "Invalid data provided.", + "kdfErrorInvalidMemo": "Invalid memo/tag provided.", + "kdfErrorInvalidConfiguration": "Invalid configuration.", + "kdfErrorPrivKeyPolicyNotAllowed": "This operation is not allowed with the current wallet type.", + "kdfErrorUnexpectedDerivationMethod": "Unexpected wallet derivation method.", + "kdfErrorActionNotAllowed": "This action is not allowed.", + "kdfErrorUnexpectedUserAction": "Unexpected user action.", + "kdfErrorBroadcastExpected": "Transaction broadcast was expected.", + "kdfErrorDbError": "Database error occurred.", + "kdfErrorWalletStorageError": "Wallet storage error occurred.", + "kdfErrorHDWalletStorageError": "HD wallet storage error occurred.", + "kdfErrorInternal": "An internal error occurred.", + "kdfErrorInternalError": "An internal error occurred.", + "kdfErrorUnsupportedError": "Unsupported operation.", + "kdfErrorSigningError": "Failed to sign the transaction.", + "kdfErrorSystemTimeError": "System time error. Please check your device time.", + "kdfErrorNumConversError": "Number conversion error.", + "kdfErrorIOError": "Input/output error occurred.", + "kdfErrorRpcError": "RPC communication error.", + "kdfErrorRpcTaskError": "RPC task error.", + "kdfErrorInvalidBip44Chain": "Invalid BIP44 derivation chain.", + "kdfErrorBip32Error": "Key derivation error.", + "kdfErrorInvalidPath": "Invalid derivation path.", + "kdfErrorInvalidPathToAddress": "Invalid path to address.", + "kdfErrorDeserializingDerivationPath": "Failed to parse derivation path.", + "kdfErrorInvalidSwapContractAddr": "Invalid swap contract address.", + "kdfErrorInvalidFallbackSwapContract": "Invalid fallback swap contract.", + "kdfErrorCustomTokenError": "Custom token error.", + "kdfErrorGetNftInfoError": "Failed to get NFT information.", + "kdfErrorMetamaskError": "MetaMask error occurred.", + "kdfErrorWalletConnectError": "WalletConnect error occurred.", + "sdk_errors": { + "network_unavailable": "Network error. Please check your connection and try again.{}", + "timeout": "The request timed out. Please try again.{}", + "invalid_response": "Unexpected response from the network. Please try again.{}", + "insufficient_funds": "Insufficient balance to complete this action.{}", + "insufficient_gas": "Insufficient balance to pay network fees.{}", + "zero_balance": "Your balance is zero. Please deposit funds and try again.{}", + "amount_too_low": "The amount is too low. Please increase the amount and try again.{}", + "invalid_address": "The address is invalid. Please check and try again.{}", + "invalid_fee": "The fee value is invalid. Please review and try again.{}", + "invalid_memo": "The memo is invalid. Please review and try again.{}", + "asset_not_activated": "The asset is not activated or is unavailable. Please enable it and try again.{}", + "activation_failed": "Activation failed. Please try again.{}", + "user_cancelled": "Action cancelled by user.{}", + "hardware_failure": "Hardware wallet operation failed. Please try again.{}", + "not_supported": "This action is not supported for the selected asset.{}", + "auth_invalid_credentials": "Invalid credentials. Please check your password.{}", + "auth_unauthorized": "Authorization failed. Please sign in again.{}", + "auth_wallet_not_found": "Wallet not found. Please verify the wallet name.{}", + "general": "Something went wrong. Please try again.{}" + } } diff --git a/automated_testing/.env.example b/automated_testing/.env.example new file mode 100644 index 0000000000..d6d52337c9 --- /dev/null +++ b/automated_testing/.env.example @@ -0,0 +1,41 @@ +# ============================================================================= +# Gleec QA Automation — Environment Configuration +# ============================================================================= +# Copy this file to .env and fill in values for your environment. +# +# PLATFORM NOTES: +# Linux: Ollama runs natively. Use http://host.docker.internal:11434 +# Windows: Run Ollama natively on Windows (best GPU perf). +# Run this runner + Docker inside WSL2. +# Ollama URL from WSL2/Docker: http://host.docker.internal:11434 +# (Docker Desktop shares ports between Windows and WSL2 automatically) +# ============================================================================= + +# --- Skyvern + Ollama integration --- +ENV=local +ENABLE_OLLAMA=true +LLM_KEY=OLLAMA +OLLAMA_SERVER_URL=http://host.docker.internal:11434 +OLLAMA_MODEL=qwen2.5-vl:32b +OLLAMA_SUPPORTS_VISION=true + +# --- Database (managed by Docker Compose) --- +DATABASE_STRING=postgresql+psycopg://skyvern:skyvern@postgres:5432/skyvern + +# --- Browser --- +BROWSER_TYPE=chromium-headful +VIDEO_PATH=/app/videos +BROWSER_ACTION_TIMEOUT_MS=10000 +MAX_STEPS_PER_RUN=50 + +# --- Skyvern server --- +LOG_LEVEL=INFO +PORT=8000 + +# --- Runner configuration (read by runner.py, not by Docker) --- +# APP_BASE_URL=https://app.gleecwallet.com +# OLLAMA_URL=http://localhost:11434 +# SKYVERN_URL=http://localhost:8000 +# VRAM_MIN_GB=15 +# DEFAULT_RETRIES=3 +# CRITICAL_RETRIES=5 diff --git a/automated_testing/GLEEC_WALLET_MANUAL_TEST_CASES.md b/automated_testing/GLEEC_WALLET_MANUAL_TEST_CASES.md new file mode 100644 index 0000000000..f8546e23a3 --- /dev/null +++ b/automated_testing/GLEEC_WALLET_MANUAL_TEST_CASES.md @@ -0,0 +1,2110 @@ +# Gleec Wallet Manual Test Case Document (Complete) + +## 1. Test Strategy Summary + +### Objective + +Validate end-to-end manual quality of Gleec Wallet across Web, Android, iOS, macOS, Linux, and Windows for wallet lifecycle, money movement, DEX/bridge/NFT operations, settings, bot features, routing, responsiveness, accessibility, security/privacy, recovery, and localization using **testnet/faucet assets only** (DOC/MARTY). + +### In-Scope + +- All 18 requested scope areas (auth through localization). +- Additional implemented modules discovered during audit: Fiat on-ramp, support/help + feedback, advanced security controls, advanced settings tooling, custom token import, wallet advanced metrics/multi-address controls, rewards, feature-gating, quick-login remembered wallet, and conditional Bitrefill/ZHTLC/system-health warning behavior. +- Functional, negative, boundary, recovery, compatibility, accessibility, security/privacy checks. +- Faucet behavior through the in-app coin-page faucet action for DOC/MARTY (backed by endpoint pattern `https://faucet.gleec.com/faucet/{COIN}/{ADDRESS}`) including success, cooldown/denied, and network/error handling. + +### Out-of-Scope + +- Automation scripts/framework implementation. +- Smart-contract/protocol source code audit. +- Performance benchmarking with instrumentation tooling (manual perception checks only). +- Production/mainnet value transfers. + +### Assumptions + +- Test environment has reachable testnet backend, in-app faucet availability on faucet-coin pages, and DEX/bridge services. +- Testers have at least one valid seed for import flow. +- At least one memo/tag-required asset and one NFT test asset are available in test environment. +- Trezor flows are executed only on supported platforms/configurations. + +### Risks + +- External service instability (faucet/DEX/bridge nodes) can produce flaky outcomes. +- Cross-platform behavior differences (permissions, deep links, background lifecycle). +- Security/privacy regressions in seed handling, clipboard, session-lock behavior. +- Feature-flag and policy-gated modules (NFT, trading, optional integrations) may differ by environment and require variant-specific execution. +- UI truncation/localization regressions on smaller breakpoints and scaled text. + +### Entry Criteria + +- QA build installed and launchable on each target platform. +- Access to DOC/MARTY test coins with test-coins toggle available. +- Test wallets and addresses prepared per Section 3. +- Known failing automated tests acknowledged; manual/static-analysis focus accepted. + +### Exit Criteria + +- All `P0` and `P1` tests executed; no open `S1`/`S2` defects for release candidate. +- Regression pack execution complete with documented evidence. +- Critical recovery/security/accessibility checks executed and signed off. +- Defect triage complete with disposition for all opened issues. + +--- + +## 2. Test Environment Matrix + +| Platform | OS / Device / Browser | Build Type | Network Condition Notes | Required Test Accounts / Wallet Setup | +| -------- | ----------------------------------------------------------------- | --------------------------------- | ---------------------------------------------- | ------------------------------------- | +| Web | Chrome latest, Firefox latest, Safari latest (macOS), Edge latest | QA/Staging web build | Normal broadband, throttled 3G, offline toggle | WP-01, WP-02, WP-03 | +| Android | Android 13/14 on mid-range + flagship devices | QA APK/AAB (debuggable QA flavor) | Wi-Fi, LTE, airplane on/off transitions | WP-01, WP-02, WP-03 | +| iOS | iOS 17/18 on iPhone + iPad | QA/TestFlight build | Wi-Fi, cellular, Low Data Mode | WP-01, WP-02, WP-03 | +| macOS | macOS 14/15 Intel + Apple Silicon | QA desktop build | Ethernet/Wi-Fi, VPN on/off | WP-01, WP-02, WP-03, WP-04 | +| Linux | Ubuntu 22.04+ desktop | QA desktop build | Ethernet/Wi-Fi, packet loss simulation | WP-01, WP-02, WP-03, WP-04 | +| Windows | Windows 11 desktop | QA desktop build | Ethernet/Wi-Fi, firewall-restricted scenario | WP-01, WP-02, WP-03, WP-04 | + +--- + +## 3. Test Data Strategy + +### Wallet Profiles + +| ID | Profile | Purpose | +| ----- | ------------------------------------------ | --------------------------------------------------- | +| WP-01 | New wallet (freshly created) | Onboarding, auth, seed backup, default state checks | +| WP-02 | Imported wallet (valid 12/24-word seed) | Restore, sync, returning-user behavior | +| WP-03 | Funded faucet wallet (DOC + MARTY balance) | Send/DEX/bridge/NFT transactions | +| WP-04 | Hardware wallet (Trezor where supported) | Hardware connect/sign/reject paths | + +### Coin Sets + +| ID | Coin Set | Purpose | +| ----- | ------------------------------------------------------------------ | ------------------------------------ | +| CS-01 | Test coins enabled; DOC and MARTY visible/activated | Primary blockchain activity tests | +| CS-02 | Test coins disabled | Visibility and settings gate checks | +| CS-03 | Mixed activation (DOC active, MARTY inactive, another coin active) | Filter/search/activation state tests | + +### Address Sets + +| ID | Address Type | Example Use | +| ----- | --------------------------------------------------------- | ------------------------------------- | +| AS-01 | Valid DOC recipient | Happy-path send/faucet | +| AS-02 | Valid MARTY recipient | Happy-path send/faucet | +| AS-03 | Invalid format/checksum | Negative send/bridge/NFT | +| AS-04 | Valid-looking wrong-network address | Unsupported format/network validation | +| AS-05 | Unsupported asset address | Error handling | +| AS-06 | Self address | Self-send restrictions/warnings | +| AS-07 | Valid memo/tag coin address with missing tag vs valid tag | Memo/tag validation | + +### Amount Sets + +| ID | Amount Pattern | Purpose | +| ----- | ---------------------------- | ----------------------------------- | +| AM-01 | `0` | Required positive amount validation | +| AM-02 | Below minimum transfer | Boundary negative | +| AM-03 | Minimum valid transfer | Boundary positive | +| AM-04 | Excess precision decimals | Decimal precision handling | +| AM-05 | Spendable balance minus fee | Max-send boundary | +| AM-06 | Amount + fee > balance | Insufficient funds | +| AM-07 | Large valid amount | Upper-range acceptance | +| AM-08 | Bridge below min / above max | Bridge validation | + +### Faucet Outcomes + +| ID | Faucet Condition | Expected | +| ----- | --------------------------- | ---------------------------------------------- | +| FD-01 | First valid request | Success response + incoming tx/pending state | +| FD-02 | Rapid repeat request | Denied/cooldown message shown and handled | +| FD-03 | Network failure/500/timeout | Error state + retry guidance without app crash | + +--- + +## 4. Requirement/Feature Traceability Matrix (RTM) + +| Feature ID | Feature Name | Risk | Mapped Test Case IDs | Platform Coverage | +| ---------- | --------------------------------- | ------ | --------------------------------------------------- | --------------------------------- | +| F01 | Authentication & wallet lifecycle | High | GW-MAN-AUTH-001..005 | All; Trezor subset on desktop/web | +| F02 | Wallet manager | High | GW-MAN-WAL-001..003 | All | +| F03 | Coin manager | Medium | GW-MAN-COIN-001..003 | All | +| F04 | Dashboard & balances | Medium | GW-MAN-DASH-001..003 | All | +| F05 | Coin details | Medium | GW-MAN-CDET-001..003 | All | +| F06 | Withdraw/Send | High | GW-MAN-SEND-001..006 | All | +| F07 | DEX flows | High | GW-MAN-DEX-001..006 | All | +| F08 | Bridge flows | High | GW-MAN-BRDG-001..004 | All | +| F09 | NFT flows | Medium | GW-MAN-NFT-001..003 | All | +| F10 | Settings & persistence | Medium | GW-MAN-SET-001..004 | All | +| F11 | Market maker bot | Medium | GW-MAN-BOT-001..003 | All | +| F12 | Navigation & routing | Medium | GW-MAN-NAV-001..003 | All | +| F13 | Responsive behavior | Medium | GW-MAN-RESP-001..002 | Web + mobile/tablet + desktop | +| F14 | Cross-platform differences | High | GW-MAN-XPLAT-001..002 | All | +| F15 | Accessibility (WCAG 2.2 AA) | High | GW-MAN-A11Y-001..003 | All | +| F16 | Security & privacy | High | GW-MAN-SEC-001..003 | All | +| F17 | Error handling & recovery | High | GW-MAN-ERR-001..003 + SEND-006 + DEX-006 + BRDG-004 | All | +| F18 | Localization/readability | Medium | GW-MAN-L10N-001..003 + SET-001 | All | +| F19 | Fiat on-ramp | High | GW-MAN-FIAT-001..005 | All | +| F20 | Support/help and feedback | Medium | GW-MAN-SUP-001 + GW-MAN-FEED-001 | All | +| F21 | Advanced security controls | High | GW-MAN-SECX-001..004 | All | +| F22 | Advanced settings tooling | Medium | GW-MAN-SETX-001..007 | All | +| F23 | Wallet advanced address/portfolio | Medium | GW-MAN-WALX-001..002 + GW-MAN-WADDR-001..002 | All | +| F24 | Custom token import | High | GW-MAN-CTOK-001..003 | All | +| F25 | Rewards and optional integrations | Medium | GW-MAN-RWD-001 + GW-MAN-BREF-001 + GW-MAN-ZHTL-001 | All (conditional as flagged) | +| F26 | Feature gating and quick-login | High | GW-MAN-GATE-001..003 + GW-MAN-QLOG-001 + GW-MAN-WARN-001 | All | + +--- + +## 5. Detailed Manual Test Cases (Core) + +### Authentication & Wallet Lifecycle + +#### GW-MAN-AUTH-001 + +**Module:** Authentication and Wallet Lifecycle +**Title:** Create wallet with mandatory seed backup confirmation +**Priority/Severity/Type:** P0 / S1 / Smoke, Functional, Security +**Platform(s):** Web, Android, iOS, macOS, Linux, Windows +**Preconditions:** Fresh install; app on welcome screen. +**Test Data:** WP-01 +**Steps:** + +1. Tap `Create Wallet`. +2. Enter a valid password and confirm it. +3. Continue to seed phrase screen and attempt to proceed without confirming backup. +4. Complete required seed confirmation challenge. +5. Finish onboarding. + **Expected Result:** Progress is blocked until seed confirmation is completed; wallet is created and user lands on dashboard. + **Post-conditions:** Authenticated session exists for newly created wallet. + **Dependencies/Notes:** Seed must not be exposed outside explicit seed screens. + +#### GW-MAN-AUTH-002 + +**Module:** Authentication and Wallet Lifecycle +**Title:** Login/logout with remember-session behavior +**Priority/Severity/Type:** P0 / S1 / Smoke, Functional, Regression +**Platform(s):** All +**Preconditions:** Existing wallet and password. +**Test Data:** WP-01 +**Steps:** + +1. Log out from settings/account menu. +2. Log in with correct password and enable remember option (if available). +3. Close and relaunch app. +4. Verify whether session follows remember option behavior. +5. Log out again and relaunch. + **Expected Result:** Login works with valid credentials; remember option persists correctly; logout always clears active session. + **Post-conditions:** User logged out after final step. + **Dependencies/Notes:** Validate identical behavior across platforms. + +#### GW-MAN-AUTH-003 + +**Module:** Authentication and Wallet Lifecycle +**Title:** Import wallet from valid seed and sync balances +**Priority/Severity/Type:** P0 / S1 / Functional, Regression +**Platform(s):** All +**Preconditions:** User is on onboarding/import screen. +**Test Data:** WP-02 seed; CS-01 +**Steps:** + +1. Choose `Import Wallet`. +2. Enter valid seed phrase and set password. +3. Complete import. +4. Enable test coins if disabled. +5. Open DOC and MARTY balances/history. + **Expected Result:** Import succeeds; wallet addresses match expected profile; balances/history synchronize. + **Post-conditions:** Imported wallet available in wallet manager. + **Dependencies/Notes:** Use only testnet-derived seed. + +#### GW-MAN-AUTH-004 + +**Module:** Authentication and Wallet Lifecycle +**Title:** Invalid password attempts and cooldown/lock handling +**Priority/Severity/Type:** P0 / S1 / Negative, Security +**Platform(s):** All +**Preconditions:** Wallet locked at login screen. +**Test Data:** Wrong password variants +**Steps:** + +1. Enter incorrect password repeatedly until limit is reached. +2. Observe lockout/cooldown feedback. +3. Attempt login during cooldown. +4. Wait for cooldown expiry and login with correct password. + **Expected Result:** Invalid attempts are rejected; cooldown is enforced and messaged; valid login works after cooldown. + **Post-conditions:** Session active after successful login. + **Dependencies/Notes:** No sensitive hints about correct password should appear. + +#### GW-MAN-AUTH-005 + +**Module:** Authentication and Wallet Lifecycle +**Title:** Trezor connect/disconnect and signing availability (where supported) +**Priority/Severity/Type:** P0 / S1 / Functional, Compatibility, Security +**Platform(s):** Web (supported browsers), macOS, Linux, Windows +**Preconditions:** Trezor connected and recognized by OS; supported build. +**Test Data:** WP-04 +**Steps:** + +1. Open hardware wallet flow and connect Trezor. +2. Authorize access and import/select hardware account. +3. Start a sign-required action (e.g., send preview) and confirm on device. +4. Disconnect device and retry action. + **Expected Result:** Device-based flow succeeds when connected; app blocks or prompts reconnection when disconnected; no crash. + **Post-conditions:** Hardware account state is consistent. + **Dependencies/Notes:** Skip on unsupported platform and mark N/A. + +--- + +### Wallet Manager + +#### GW-MAN-WAL-001 + +**Module:** Wallet Manager +**Title:** Create, rename, and select among multiple wallets +**Priority/Severity/Type:** P0 / S2 / Functional, Regression +**Platform(s):** All +**Preconditions:** Logged in with one wallet. +**Test Data:** WP-01 + additional created wallet +**Steps:** + +1. Open wallet manager and create second wallet. +2. Rename both wallets with unique names. +3. Switch active wallet multiple times. +4. Verify dashboard content changes with selected wallet. + **Expected Result:** Wallet list updates immediately; rename persists; selected wallet context switches correctly. + **Post-conditions:** Two wallets exist with distinct names. + **Dependencies/Notes:** Names should reject invalid-only-whitespace input. + +#### GW-MAN-WAL-002 + +**Module:** Wallet Manager +**Title:** Delete wallet with confirmation and active wallet safety +**Priority/Severity/Type:** P0 / S1 / Functional, Negative, Security +**Platform(s):** All +**Preconditions:** At least two wallets exist. +**Test Data:** Wallet to delete contains no critical unsaved operation +**Steps:** + +1. Open wallet manager and choose a non-active wallet to delete. +2. Cancel deletion at confirmation prompt. +3. Repeat and confirm deletion. +4. Attempt deleting final remaining wallet (if app prevents it). + **Expected Result:** Cancel keeps wallet unchanged; confirm removes target wallet; safety rule for last wallet is enforced as designed. + **Post-conditions:** Only intended wallet removed. + **Dependencies/Notes:** Verify no orphaned data remains visible. + +#### GW-MAN-WAL-003 + +**Module:** Wallet Manager +**Title:** Selected wallet persistence across restart/login +**Priority/Severity/Type:** P1 / S2 / Functional, Recovery +**Platform(s):** All +**Preconditions:** Multiple wallets available; remember option known state. +**Test Data:** WP-01 + WP-02 +**Steps:** + +1. Select wallet B as active. +2. Close and relaunch app. +3. Log in if prompted. +4. Check active wallet selection. + **Expected Result:** Active wallet persistence follows app design and remains consistent after relaunch/login. + **Post-conditions:** Wallet selection state stable. + **Dependencies/Notes:** Validate same behavior on mobile and desktop. + +--- + +### Coin Manager + +#### GW-MAN-COIN-001 + +**Module:** Coin Manager +**Title:** Test coin visibility gate for DOC and MARTY +**Priority/Severity/Type:** P1 / S2 / Functional, Smoke +**Platform(s):** All +**Preconditions:** Logged in; coin manager accessible. +**Test Data:** CS-01, CS-02 +**Steps:** + +1. Disable test coin setting; search for DOC/MARTY. +2. Enable test coin setting. +3. Search for DOC and MARTY again. +4. Activate both coins. + **Expected Result:** DOC/MARTY hidden when test coins disabled; visible and activatable when enabled. + **Post-conditions:** DOC/MARTY active for subsequent tests. + **Dependencies/Notes:** Mandatory pre-step for blockchain test cases. + +#### GW-MAN-COIN-002 + +**Module:** Coin Manager +**Title:** Activate/deactivate coin with search and filter behavior +**Priority/Severity/Type:** P1 / S2 / Functional, Regression +**Platform(s):** All +**Preconditions:** Test coins enabled. +**Test Data:** CS-03 +**Steps:** + +1. Use search to find MARTY and activate it. +2. Apply active-only filter and verify MARTY listed. +3. Deactivate MARTY. +4. Clear filters and verify list updates. + **Expected Result:** Activation state changes immediately and filter/search reflect current status correctly. + **Post-conditions:** Coin states match final user actions. + **Dependencies/Notes:** Confirm no duplicated entries. + +#### GW-MAN-COIN-003 + +**Module:** Coin Manager +**Title:** Deactivate coin with balance/history and restore state +**Priority/Severity/Type:** P1 / S2 / Functional, Recovery +**Platform(s):** All +**Preconditions:** DOC has non-zero balance/history. +**Test Data:** WP-03, DOC funded +**Steps:** + +1. Attempt to deactivate DOC with non-zero balance. +2. Confirm warning dialog behavior. +3. Complete deactivation if allowed. +4. Reactivate DOC and open its details. + **Expected Result:** Warning is shown; deactivation policy enforced; reactivation restores historical data and address correctly. + **Post-conditions:** DOC active for money-flow tests. + **Dependencies/Notes:** No loss of transaction history after reactivation. + +--- + +### Dashboard & Balances + +#### GW-MAN-DASH-001 + +**Module:** Wallet Dashboard +**Title:** Hide balances and hide zero balances toggles +**Priority/Severity/Type:** P1 / S2 / Functional, Security +**Platform(s):** All +**Preconditions:** Multiple coins with zero and non-zero balances. +**Test Data:** WP-03 +**Steps:** + +1. Enable `Hide Balances`. +2. Verify amounts are masked on dashboard and coin list. +3. Enable `Hide Zero Balances`. +4. Verify zero-balance assets are hidden while non-zero remain. + **Expected Result:** Masking and zero-balance filtering apply consistently across applicable views. + **Post-conditions:** Restore preferred toggle states for later tests. + **Dependencies/Notes:** Sensitive values must not flash during transitions. + +#### GW-MAN-DASH-002 + +**Module:** Wallet Dashboard +**Title:** Balance refresh, loading states, and offline indicator +**Priority/Severity/Type:** P1 / S2 / Functional, Recovery +**Platform(s):** All +**Preconditions:** Dashboard loaded with active DOC/MARTY. +**Test Data:** WP-03 +**Steps:** + +1. Trigger manual refresh. +2. Observe loading indicator behavior. +3. Disable network and refresh again. +4. Re-enable network and retry refresh. + **Expected Result:** Loading and failure states are clear; offline state shown without crash; data refresh recovers after network returns. + **Post-conditions:** Fresh balance state loaded. + **Dependencies/Notes:** Stale timestamp/last-updated marker should update. + +#### GW-MAN-DASH-003 + +**Module:** Wallet Dashboard +**Title:** Dashboard state persistence for returning user +**Priority/Severity/Type:** P2 / S3 / Regression +**Platform(s):** All +**Preconditions:** User has customized dashboard toggles/order (if supported). +**Test Data:** WP-03 +**Steps:** + +1. Set balance visibility and preferred sorting options. +2. Log out and log back in. +3. Relaunch app. +4. Re-open dashboard. + **Expected Result:** User preferences persist according to product rules and remain consistent after relaunch. + **Post-conditions:** User returns to preferred dashboard state. + **Dependencies/Notes:** Validate no preference reset on minor restart. + +--- + +### Coin Details + +#### GW-MAN-CDET-001 + +**Module:** Coin Details +**Title:** Address display, copy, QR, and explorer launch +**Priority/Severity/Type:** P1 / S2 / Functional, Compatibility +**Platform(s):** All +**Preconditions:** DOC active. +**Test Data:** AS-01 +**Steps:** + +1. Open DOC coin details. +2. Copy receive address. +3. Open QR view and verify address matches. +4. Tap explorer link. + **Expected Result:** Address is consistent across text/QR/copy; explorer opens correct network/address URL. + **Post-conditions:** Address copied to clipboard. + **Dependencies/Notes:** Validate clipboard handling with SEC tests. + +#### GW-MAN-CDET-002 + +**Module:** Coin Details +**Title:** Transaction list, detail view, and status progression +**Priority/Severity/Type:** P1 / S2 / Functional, Regression +**Platform(s):** All +**Preconditions:** DOC or MARTY has pending and confirmed tx history. +**Test Data:** WP-03 +**Steps:** + +1. Open transaction list for DOC. +2. Open a pending transaction detail. +3. Refresh after confirmation period. +4. Open confirmed transaction detail. + **Expected Result:** Correct fields shown (hash, amount, fee, status, timestamp, addresses); pending transitions to confirmed accurately. + **Post-conditions:** Tx detail views validated. + **Dependencies/Notes:** Timestamp/date formatting also checked in L10N suite. + +#### GW-MAN-CDET-003 + +**Module:** Coin Details +**Title:** Price chart visibility and no-data/network fallback +**Priority/Severity/Type:** P2 / S3 / Functional, Recovery +**Platform(s):** All +**Preconditions:** Coin details screen available. +**Test Data:** DOC/MARTY with possible missing chart feed +**Steps:** + +1. Open chart tab for coin. +2. Switch time ranges. +3. Disable network and retry chart load. +4. Re-enable network and reload. + **Expected Result:** Chart renders when data exists; graceful placeholder/message shown on no-data/offline states; recovery works. + **Post-conditions:** Chart state restored. + **Dependencies/Notes:** No UI overlap/truncation on small screens. + +--- + +### Send / Withdraw + +#### GW-MAN-SEND-001 + +**Module:** Send/Withdraw +**Title:** Faucet funding success for DOC/MARTY +**Priority/Severity/Type:** P0 / S1 / Smoke, Functional +**Platform(s):** All +**Preconditions:** CS-01 enabled; receive addresses available. +**Test Data:** AS-01, AS-02, FD-01 +**Steps:** + +1. Copy DOC receive address from app. +2. Open DOC coin page and trigger the in-app faucet action. +3. Open MARTY coin page and trigger the in-app faucet action. +4. Refresh balances/history from within the app. + **Expected Result:** Faucet success response is shown; incoming transactions appear as pending then confirmed; balances increase accordingly. + **Post-conditions:** Wallet funded with DOC/MARTY for transaction tests. + **Dependencies/Notes:** Testnet assets only; no real-value funds. + +#### GW-MAN-SEND-002 + +**Module:** Send/Withdraw +**Title:** Faucet cooldown/denied and network/error handling +**Priority/Severity/Type:** P0 / S1 / Negative, Recovery +**Platform(s):** All +**Preconditions:** At least one successful faucet request already made recently. +**Test Data:** FD-02, FD-03 +**Steps:** + +1. From the faucet coin page, re-submit in-app faucet request immediately for the same coin/address. +2. Observe cooldown/denied response. +3. Trigger network failure (offline or unreachable endpoint) and retry the in-app faucet request. +4. Restore network and retry later. + **Expected Result:** Cooldown/denied responses are surfaced clearly; network failure shows retriable error; app remains stable. + **Post-conditions:** Faucet retry policy understood and logged. + **Dependencies/Notes:** Capture response message text for defect reporting if inconsistent. + +#### GW-MAN-SEND-003 + +**Module:** Send/Withdraw +**Title:** DOC send happy path with fee and confirmation +**Priority/Severity/Type:** P0 / S1 / Smoke, Functional, Regression +**Platform(s):** All +**Preconditions:** WP-03 funded; destination AS-01 available. +**Test Data:** AM-03/AM-07 +**Steps:** + +1. Open DOC send screen. +2. Enter valid recipient and amount. +3. Review fee and confirmation summary. +4. Confirm transaction. +5. Track status in history until confirmed. + **Expected Result:** Transaction submitted once; fee/amount match summary; history shows pending then confirmed with correct net balance change. + **Post-conditions:** Outgoing transaction recorded. + **Dependencies/Notes:** Validate tx hash opens correct explorer page. + +#### GW-MAN-SEND-004 + +**Module:** Send/Withdraw +**Title:** Address validation and memo/tag-required enforcement +**Priority/Severity/Type:** P0 / S1 / Negative, Boundary +**Platform(s):** All +**Preconditions:** Send screen open. +**Test Data:** AS-03, AS-04, AS-05, AS-07 +**Steps:** + +1. Enter invalid address and valid amount; attempt continue. +2. Enter wrong-network/unsupported address; attempt continue. +3. For memo/tag-required asset, enter valid address without memo/tag. +4. Add valid memo/tag and continue. + **Expected Result:** Invalid/unsupported addresses blocked with clear error; memo/tag-required transfer blocked until valid memo/tag provided. + **Post-conditions:** No invalid transaction broadcast. + **Dependencies/Notes:** Use any available memo/tag-required test asset. + +#### GW-MAN-SEND-005 + +**Module:** Send/Withdraw +**Title:** Amount boundary, precision, max-send, and insufficient funds +**Priority/Severity/Type:** P0 / S1 / Boundary, Negative +**Platform(s):** All +**Preconditions:** Funded wallet. +**Test Data:** AM-01, AM-02, AM-04, AM-05, AM-06 +**Steps:** + +1. Enter amount `0` and attempt continue. +2. Enter below-minimum amount and attempt continue. +3. Enter overly precise decimal amount. +4. Use max-send/spendable boundary amount. +5. Enter amount that exceeds balance after fees. + **Expected Result:** Invalid amounts rejected with specific messages; max-send computes correctly; insufficient-balance scenario blocked pre-submit. + **Post-conditions:** No failed broadcast from client-side validation cases. + **Dependencies/Notes:** Fee recalculation must be deterministic. + +#### GW-MAN-SEND-006 + +**Module:** Send/Withdraw +**Title:** Interrupted send flow recovery and duplicate-submit prevention +**Priority/Severity/Type:** P0 / S1 / Recovery, Regression +**Platform(s):** All +**Preconditions:** Valid send form prepared. +**Test Data:** AS-01, AM-03 +**Steps:** + +1. Submit send transaction. +2. Immediately disable network or background/close app during pending state. +3. Re-open app and return to history/send screen. +4. Re-enable network and sync. +5. Attempt to resubmit same transaction quickly. + **Expected Result:** Pending transaction state is recovered; app prevents accidental duplicate submits; final state reconciles to confirmed/failed accurately. + **Post-conditions:** Transaction history consistent after recovery. + **Dependencies/Notes:** Critical data-integrity check. + +--- + +### DEX + +#### GW-MAN-DEX-001 + +**Module:** DEX +**Title:** Maker limit order creation and open-order visibility +**Priority/Severity/Type:** P0 / S1 / Functional, Smoke +**Platform(s):** All +**Preconditions:** Funded wallet with tradable pair assets. +**Test Data:** DOC/MARTY pair; AM-03 +**Steps:** + +1. Open DEX and select DOC/MARTY pair. +2. Choose maker/limit order. +3. Enter valid price and amount. +4. Submit order and open `Open Orders`. + **Expected Result:** Order created successfully and appears in open orders with correct pair, price, amount, and status. + **Post-conditions:** One active maker order exists. + **Dependencies/Notes:** Verify locked balance reflects open order. + +#### GW-MAN-DEX-002 + +**Module:** DEX +**Title:** Taker order execution from orderbook +**Priority/Severity/Type:** P0 / S1 / Functional, Regression +**Platform(s):** All +**Preconditions:** Orderbook has available liquidity. +**Test Data:** DOC/MARTY amount in AM-03..AM-07 +**Steps:** + +1. Select an existing orderbook level. +2. Choose taker action and review estimated fill. +3. Confirm trade. +4. Track swap/order status to completion. + **Expected Result:** Taker order executes against orderbook; execution details and final balances match expected trade outcomes. + **Post-conditions:** Completed swap/order appears in history. + **Dependencies/Notes:** If no liquidity, use test environment seeding before run. + +#### GW-MAN-DEX-003 + +**Module:** DEX +**Title:** DEX validation for invalid pair/price/amount/insufficient funds +**Priority/Severity/Type:** P0 / S1 / Negative, Boundary +**Platform(s):** All +**Preconditions:** DEX form open. +**Test Data:** Invalid pair, AM-01, AM-02, AM-06 +**Steps:** + +1. Attempt order with unsupported/disabled pair. +2. Enter zero/negative/below-min amount. +3. Enter price outside allowed precision/range. +4. Attempt submit with insufficient funds. + **Expected Result:** Validation blocks invalid orders with specific guidance; no invalid order enters orderbook. + **Post-conditions:** No new open order created from invalid input. + **Dependencies/Notes:** Decision-table style checks across pair+amount+balance conditions. + +#### GW-MAN-DEX-004 + +**Module:** DEX +**Title:** Order lifecycle: partial fill, cancel, final state consistency +**Priority/Severity/Type:** P0 / S1 / Functional, Recovery +**Platform(s):** All +**Preconditions:** Maker order placed with potential partial fills. +**Test Data:** Existing open order +**Steps:** + +1. Place maker order with moderate size. +2. Wait for partial fill. +3. Cancel remaining quantity. +4. Verify final status in open/history tabs. + **Expected Result:** Lifecycle transitions are accurate (open -> partial -> canceled/filled); balances/locked funds reconcile correctly. + **Post-conditions:** No stale locked balance remains. + **Dependencies/Notes:** High-risk integrity path. + +#### GW-MAN-DEX-005 + +**Module:** DEX +**Title:** Swap/order history filtering and export +**Priority/Severity/Type:** P1 / S2 / Functional, Regression +**Platform(s):** All +**Preconditions:** Multiple historical swaps/orders exist. +**Test Data:** Time ranges, pair filters, status filters +**Steps:** + +1. Open history tab. +2. Filter by pair, date range, and status. +3. Validate filtered rows. +4. Export history (CSV/file) and open exported file. + **Expected Result:** Filtered results are accurate; exported data matches visible records and formatting expectations. + **Post-conditions:** Export artifact generated. + **Dependencies/Notes:** Verify timestamp/decimal localization impact. + +#### GW-MAN-DEX-006 + +**Module:** DEX +**Title:** DEX recovery after app restart/network drop +**Priority/Severity/Type:** P0 / S1 / Recovery +**Platform(s):** All +**Preconditions:** At least one active/pending DEX operation. +**Test Data:** Open order and pending swap +**Steps:** + +1. Start DEX operation and force app closure or temporary offline state. +2. Reopen app and navigate to DEX. +3. Refresh open orders/history. +4. Verify status reconciliation with backend. + **Expected Result:** DEX state is restored without duplication; final statuses are correct; no ghost orders. + **Post-conditions:** DEX state synchronized. + **Dependencies/Notes:** Capture any stale-state mismatch as S1/S2. + +--- + +### Bridge + +#### GW-MAN-BRDG-001 + +**Module:** Bridge +**Title:** Bridge transfer happy path with valid pair/protocol +**Priority/Severity/Type:** P0 / S1 / Functional, Smoke +**Platform(s):** All +**Preconditions:** Bridge-supported test pair available and funded. +**Test Data:** AM-03, valid recipient +**Steps:** + +1. Open Bridge and select supported source/destination pair. +2. Enter valid amount and destination details. +3. Review fees/ETA and confirm. +4. Track bridge status to completion. + **Expected Result:** Bridge transfer is created; status progresses correctly; destination balance/history updates after completion. + **Post-conditions:** Successful bridge record in history. + **Dependencies/Notes:** Testnet routes only. + +#### GW-MAN-BRDG-002 + +**Module:** Bridge +**Title:** Unsupported pair/protocol validation +**Priority/Severity/Type:** P0 / S1 / Negative +**Platform(s):** All +**Preconditions:** Bridge screen available. +**Test Data:** Unsupported pair selection attempts +**Steps:** + +1. Select unsupported token-chain pair (or disable protocol prerequisite). +2. Enter amount and attempt continue. +3. Observe validation messaging. + **Expected Result:** Unsupported combinations are blocked before submission with clear corrective message. + **Post-conditions:** No bridge operation started. + **Dependencies/Notes:** Validate both pair-level and protocol-level constraints. + +#### GW-MAN-BRDG-003 + +**Module:** Bridge +**Title:** Amount boundaries, fees, and insufficient funds checks +**Priority/Severity/Type:** P0 / S1 / Boundary, Negative +**Platform(s):** All +**Preconditions:** Bridge form open with supported pair. +**Test Data:** AM-08, AM-06 +**Steps:** + +1. Enter below-minimum bridge amount. +2. Enter above-maximum amount. +3. Enter amount causing insufficient funds after fees. +4. Enter valid boundary amount and recheck fee preview. + **Expected Result:** Invalid amounts are rejected; fee preview is consistent; valid boundary amount proceeds. + **Post-conditions:** No invalid bridge request submitted. + **Dependencies/Notes:** Confirm displayed min/max source is current. + +#### GW-MAN-BRDG-004 + +**Module:** Bridge +**Title:** Bridge failure/timeout and retry/recovery after restart +**Priority/Severity/Type:** P0 / S1 / Recovery +**Platform(s):** All +**Preconditions:** Active bridge request in progress. +**Test Data:** Simulated timeout/network interruption +**Steps:** + +1. Initiate bridge transfer. +2. Introduce network outage or wait for timeout condition. +3. Observe failed/pending timeout state and retry option. +4. Restart app and reopen bridge history. +5. Retry or resync status. + **Expected Result:** Failure state is explicit; retry/resync is available; final state reconciles correctly after restart. + **Post-conditions:** Bridge history reflects final authoritative status. + **Dependencies/Notes:** Must not duplicate transfer on retry. + +--- + +### NFT + +#### GW-MAN-NFT-001 + +**Module:** NFT +**Title:** NFT list/details/history filtering +**Priority/Severity/Type:** P1 / S2 / Functional +**Platform(s):** All +**Preconditions:** Wallet contains NFT test assets. +**Test Data:** NFT collection with multiple items +**Steps:** + +1. Open NFT module and view list. +2. Open one NFT detail page. +3. Apply filters (collection/status/date if available). +4. Open NFT history. + **Expected Result:** List and details load correctly; filters return expected subset; history shows accurate actions/statuses. + **Post-conditions:** NFT module state stable. + **Dependencies/Notes:** Metadata fallback handled in NFT-003 if needed. + +#### GW-MAN-NFT-002 + +**Module:** NFT +**Title:** NFT send happy path +**Priority/Severity/Type:** P1 / S2 / Functional, Regression +**Platform(s):** All +**Preconditions:** Transferable NFT available; recipient valid. +**Test Data:** Valid recipient address for NFT network +**Steps:** + +1. Open NFT detail and choose `Send`. +2. Enter valid recipient. +3. Review fee/confirmation. +4. Confirm transfer and monitor history. + **Expected Result:** NFT transfer submits successfully; history updates pending -> confirmed; ownership moves accordingly. + **Post-conditions:** Sender no longer owns transferred NFT after confirmation. + **Dependencies/Notes:** Testnet NFT assets only. + +#### GW-MAN-NFT-003 + +**Module:** NFT +**Title:** NFT send failure for invalid recipient/not-owner and recovery +**Priority/Severity/Type:** P1 / S2 / Negative, Recovery +**Platform(s):** All +**Preconditions:** NFT send form available. +**Test Data:** AS-03/AS-04; already transferred NFT +**Steps:** + +1. Attempt NFT send with invalid recipient. +2. Attempt resend from wallet that no longer owns NFT. +3. Correct recipient and retry with owned NFT. + **Expected Result:** Invalid/not-owner actions are blocked with clear errors; valid retry succeeds without UI corruption. + **Post-conditions:** NFT ownership remains correct. + **Dependencies/Notes:** Verify no duplicate pending rows after failed attempts. + +--- + +### Settings + +#### GW-MAN-SET-001 + +**Module:** Settings +**Title:** Theme + language + date/number format persistence +**Priority/Severity/Type:** P1 / S2 / Functional, Regression +**Platform(s):** All +**Preconditions:** Logged in; settings accessible. +**Test Data:** Two themes; two locales +**Steps:** + +1. Change theme. +2. Change app language/locale. +3. Verify dates/numbers on dashboard/history. +4. Restart app and recheck. + **Expected Result:** Theme and locale apply immediately; formats match locale; settings persist after restart. + **Post-conditions:** User preferences retained. + **Dependencies/Notes:** Supports L10N coverage linkage. + +#### GW-MAN-SET-002 + +**Module:** Settings +**Title:** Analytics/privacy/diagnostic toggles behavior +**Priority/Severity/Type:** P1 / S2 / Functional, Security +**Platform(s):** All +**Preconditions:** Settings page loaded. +**Test Data:** Toggle matrix (on/off combinations) +**Steps:** + +1. Toggle analytics off. +2. Toggle privacy/diagnostic options on/off. +3. Navigate across app and relaunch. +4. Re-open settings and verify states. + **Expected Result:** Toggles persist and reflect chosen consent; UI indicates effective state clearly. + **Post-conditions:** Privacy preferences set as configured. + **Dependencies/Notes:** Ensure defaults are explicit for first-time user. + +#### GW-MAN-SET-003 + +**Module:** Settings +**Title:** Test coin toggle immediate impact and persistence +**Priority/Severity/Type:** P1 / S2 / Functional, Smoke +**Platform(s):** All +**Preconditions:** Coin manager accessible. +**Test Data:** CS-01, CS-02 +**Steps:** + +1. Disable test coins in settings. +2. Open coin manager and verify DOC/MARTY hidden. +3. Re-enable test coins. +4. Verify DOC/MARTY visible and previous activation state behavior. +5. Restart app and recheck. + **Expected Result:** Visibility updates immediately; setting persists after restart. + **Post-conditions:** Test coins enabled for blockchain tests. + **Dependencies/Notes:** Mandatory for test policy compliance. + +#### GW-MAN-SET-004 + +**Module:** Settings +**Title:** Settings persistence across logout/login/restart +**Priority/Severity/Type:** P1 / S2 / Regression, Recovery +**Platform(s):** All +**Preconditions:** Multiple settings customized. +**Test Data:** Theme, privacy, test coin toggle, balance masking +**Steps:** + +1. Configure settings. +2. Log out. +3. Log back in and verify settings. +4. Restart app and verify again. + **Expected Result:** Persistent settings retain expected values according to account/device scope. + **Post-conditions:** Stable persisted preferences. + **Dependencies/Notes:** Record any setting that should intentionally reset. + +--- + +### Market Maker Bot + +#### GW-MAN-BOT-001 + +**Module:** Market Maker Bot +**Title:** Create and start market maker bot with valid config +**Priority/Severity/Type:** P1 / S2 / Functional +**Platform(s):** All +**Preconditions:** Bot feature enabled; funded tradable pair. +**Test Data:** Valid pair, spread, volume, frequency +**Steps:** + +1. Open bot module and select `Create Bot`. +2. Enter valid pair and trading parameters. +3. Save and start bot. +4. Verify running status and generated activity entries. + **Expected Result:** Bot is created and enters running state with valid config reflected in UI. + **Post-conditions:** One active bot exists. + **Dependencies/Notes:** Use non-production/test funds only. + +#### GW-MAN-BOT-002 + +**Module:** Market Maker Bot +**Title:** Bot validation for invalid boundaries +**Priority/Severity/Type:** P1 / S2 / Negative, Boundary +**Platform(s):** All +**Preconditions:** Bot creation form open. +**Test Data:** Invalid spread/volume/frequency values +**Steps:** + +1. Enter out-of-range spread. +2. Enter zero/negative volume. +3. Enter unsupported pair. +4. Attempt to save. + **Expected Result:** Invalid configurations are blocked with field-level validation; no bot instance created. + **Post-conditions:** Bot list unchanged by invalid submission. + **Dependencies/Notes:** Validate precision/range messages are actionable. + +#### GW-MAN-BOT-003 + +**Module:** Market Maker Bot +**Title:** Edit, stop, restart bot and persistence after relaunch +**Priority/Severity/Type:** P1 / S2 / Functional, Recovery +**Platform(s):** All +**Preconditions:** Active bot exists. +**Test Data:** Existing bot from BOT-001 +**Steps:** + +1. Edit bot parameters and save. +2. Stop bot and confirm status change. +3. Restart bot. +4. Relaunch app and verify bot config/status. + **Expected Result:** Edits persist; start/stop actions work; state survives relaunch. + **Post-conditions:** Bot returns to intended final state. + **Dependencies/Notes:** Check no duplicate bot instances after restart. + +--- + +### Navigation & Routing + +#### GW-MAN-NAV-001 + +**Module:** Navigation and Routing +**Title:** Main menu route integrity and back navigation +**Priority/Severity/Type:** P1 / S2 / Functional, Regression +**Platform(s):** All +**Preconditions:** Logged in. +**Test Data:** N/A +**Steps:** + +1. Navigate via menu to Dashboard, Coin Manager, DEX, Bridge, NFT, Settings, Bot. +2. Use back navigation from each route. +3. Repeat with hardware back (Android) and browser back (Web). + **Expected Result:** Routes open correct pages; back navigation follows expected stack without exiting unexpectedly or losing critical state. + **Post-conditions:** User returns safely to start screen. + **Dependencies/Notes:** Validate route titles and URL segments on web. + +#### GW-MAN-NAV-002 + +**Module:** Navigation and Routing +**Title:** Deep link handling with auth gating +**Priority/Severity/Type:** P1 / S2 / Functional, Security +**Platform(s):** Web, Android, iOS, macOS, Linux, Windows +**Preconditions:** Deep-link format available for coin/tx/order pages. +**Test Data:** Valid and invalid deep-link targets +**Steps:** + +1. Open deep link while logged out. +2. Complete login. +3. Verify post-login redirect to intended route. +4. Open malformed or unauthorized deep link. + **Expected Result:** Auth gating is enforced; valid deep link resolves after login; invalid links show safe fallback/error page. + **Post-conditions:** App remains on valid route. + **Dependencies/Notes:** No sensitive content exposed pre-auth. + +#### GW-MAN-NAV-003 + +**Module:** Navigation and Routing +**Title:** Unsaved changes prompt on form exit +**Priority/Severity/Type:** P2 / S3 / Functional, Recovery +**Platform(s):** All +**Preconditions:** Open send/order form with entered but unsent data. +**Test Data:** Partial form input +**Steps:** + +1. Enter data into send or DEX form. +2. Attempt to navigate away. +3. Choose `Stay` in confirmation dialog. +4. Navigate away again and choose `Discard`. + **Expected Result:** Unsaved-changes dialog appears; `Stay` preserves form; `Discard` exits and clears pending input as designed. + **Post-conditions:** Form state matches user choice. + **Dependencies/Notes:** Important for interrupted-flow safety. + +--- + +### Responsive Behavior + +#### GW-MAN-RESP-001 + +**Module:** Responsive UI +**Title:** Breakpoint behavior on mobile/tablet/desktop +**Priority/Severity/Type:** P1 / S2 / Compatibility, Usability +**Platform(s):** Web, Android, iOS, desktop +**Preconditions:** App available on devices or emulator/responsive mode. +**Test Data:** Common routes with dense content (DEX, history, settings) +**Steps:** + +1. Open app at mobile width. +2. Verify nav style, card/list readability, and action button visibility. +3. Resize to tablet and desktop widths. +4. Re-check layout adaptation. + **Expected Result:** Layout adapts without overlap/cutoff; primary actions remain visible and reachable at all breakpoints. + **Post-conditions:** No route-specific layout breakage. + **Dependencies/Notes:** Include landscape and portrait checks. + +#### GW-MAN-RESP-002 + +**Module:** Responsive UI +**Title:** Orientation/window resize state retention +**Priority/Severity/Type:** P2 / S3 / Recovery, Compatibility +**Platform(s):** Android, iOS, Web, desktop +**Preconditions:** Active workflow in progress (e.g., send form or DEX order draft). +**Test Data:** Partial form state +**Steps:** + +1. Enter partial data in a transaction form. +2. Rotate device or resize desktop window significantly. +3. Continue workflow. + **Expected Result:** UI reflows cleanly; essential in-progress form data remains or follows defined reset behavior with warning. + **Post-conditions:** Workflow can continue safely. + **Dependencies/Notes:** Validate no accidental submission on rotate/resize. + +--- + +### Cross-Platform + +#### GW-MAN-XPLAT-001 + +**Module:** Cross-Platform +**Title:** Core-flow parity (create -> fund -> send -> history) +**Priority/Severity/Type:** P0 / S1 / Compatibility, Regression +**Platform(s):** Web, Android, iOS, macOS, Linux, Windows +**Preconditions:** QA build installed on all target platforms. +**Test Data:** WP-01, FD-01, AS-01, AM-03 +**Steps:** + +1. Create wallet on each platform. +2. Fund DOC using the in-app faucet action on the DOC coin page. +3. Send DOC to valid recipient. +4. Verify transaction appears in history with correct status. + **Expected Result:** Core user journey succeeds consistently on all platforms with no platform-specific blockers. + **Post-conditions:** Comparable evidence captured per platform. + **Dependencies/Notes:** High release-gate coverage. + +#### GW-MAN-XPLAT-002 + +**Module:** Cross-Platform +**Title:** Platform-specific permissions and input behavior +**Priority/Severity/Type:** P1 / S2 / Compatibility, Security +**Platform(s):** All +**Preconditions:** Permission prompts can be reset per platform. +**Test Data:** Clipboard, camera/QR (if used), notification, filesystem export +**Steps:** + +1. Trigger permission-required actions (QR scan, export, notifications as applicable). +2. Deny permission and retry action. +3. Grant permission and retry. +4. Validate keyboard shortcuts (desktop) and hardware back gestures (mobile/web). + **Expected Result:** Permission denial handled gracefully with guidance; granted permission enables action; platform input conventions work. + **Post-conditions:** Permissions states documented. + **Dependencies/Notes:** Verify no crash on denied permission paths. + +--- + +### Accessibility + +#### GW-MAN-A11Y-001 + +**Module:** Accessibility +**Title:** Keyboard-only navigation and logical focus order +**Priority/Severity/Type:** P1 / S2 / Accessibility +**Platform(s):** Web, macOS, Linux, Windows, tablet with keyboard +**Preconditions:** Keyboard access enabled. +**Test Data:** Critical routes (auth, send, DEX, settings) +**Steps:** + +1. Navigate entire screen using Tab/Shift+Tab only. +2. Activate controls with keyboard keys. +3. Open and close modal dialogs. +4. Verify visible focus indicator is always present. + **Expected Result:** All interactive elements reachable; focus order logical; no keyboard trap; dialogs trap/release focus correctly. + **Post-conditions:** Accessibility keyboard path validated. + **Dependencies/Notes:** WCAG 2.2 AA focus requirements. + +#### GW-MAN-A11Y-002 + +**Module:** Accessibility +**Title:** Screen reader labels, roles, and state announcements +**Priority/Severity/Type:** P1 / S2 / Accessibility, Compatibility +**Platform(s):** iOS (VoiceOver), Android (TalkBack), desktop screen readers, web SR +**Preconditions:** Screen reader enabled. +**Test Data:** Auth, send confirmation, error dialogs +**Steps:** + +1. Navigate key screens with screen reader gestures. +2. Read form fields, toggles, and buttons. +3. Trigger validation errors and confirmation dialogs. +4. Verify announcements for status changes (pending/confirmed). + **Expected Result:** Controls have meaningful labels/roles/states; dynamic changes are announced; no unlabeled actionable controls. + **Post-conditions:** SR usability evidence captured. + **Dependencies/Notes:** Prioritize money-movement screens. + +#### GW-MAN-A11Y-003 + +**Module:** Accessibility +**Title:** Color contrast, touch targets, and text scaling +**Priority/Severity/Type:** P1 / S2 / Accessibility +**Platform(s):** All +**Preconditions:** UI theme available; text scaling controls available on OS/app. +**Test Data:** Normal and large text sizes +**Steps:** + +1. Check contrast of text/icon/button states in light/dark themes. +2. Verify touch targets for primary actions. +3. Increase text size to large accessibility setting. +4. Re-open core flows and verify readability/no clipping. + **Expected Result:** Contrast and target sizes meet usability expectations; scaled text remains readable and functional. + **Post-conditions:** Accessibility visual checks complete. + **Dependencies/Notes:** Track any clipped critical labels as accessibility defects. + +--- + +### Security & Privacy + +#### GW-MAN-SEC-001 + +**Module:** Security and Privacy +**Title:** Seed phrase handling, reveal controls, and confirmation safeguards +**Priority/Severity/Type:** P0 / S1 / Security +**Platform(s):** All +**Preconditions:** Seed-management screen accessible for test wallet. +**Test Data:** WP-01 +**Steps:** + +1. Open seed phrase view from secure settings route. +2. Verify re-auth requirement before reveal (if designed). +3. Attempt navigation/screenshot/background while seed visible. +4. Exit screen and return to app. + **Expected Result:** Seed access is protected; exposure minimized; seed not visible after leaving secure context. + **Post-conditions:** Seed screen closed; session remains secure. + **Dependencies/Notes:** Record platform behavior for screenshot masking policy. + +#### GW-MAN-SEC-002 + +**Module:** Security and Privacy +**Title:** Session auto-lock, logout clearing, and app-switcher privacy +**Priority/Severity/Type:** P0 / S1 / Security, Recovery +**Platform(s):** All (app-switcher check on mobile/desktop as applicable) +**Preconditions:** Logged in with funded wallet. +**Test Data:** Auto-lock timeout setting +**Steps:** + +1. Set short inactivity timeout. +2. Leave app idle until timeout. +3. Confirm re-auth is required. +4. Log out and relaunch app. +5. Check app-switcher/recents snapshot for sensitive data exposure. + **Expected Result:** Auto-lock enforces re-auth; logout clears session; sensitive data is not exposed in recents snapshot where policy applies. + **Post-conditions:** User logged out at end. + **Dependencies/Notes:** Security-critical release gate. + +#### GW-MAN-SEC-003 + +**Module:** Security and Privacy +**Title:** Clipboard exposure risk for address/seed copy actions +**Priority/Severity/Type:** P0 / S2 / Security, Negative +**Platform(s):** All +**Preconditions:** Clipboard-access actions available. +**Test Data:** Address copy and seed-copy scenario (if allowed) +**Steps:** + +1. Copy receive address. +2. Paste into external app and verify exact value. +3. If seed copy is allowed, copy seed and observe warning/guardrails. +4. Wait configured timeout and check clipboard clearing behavior (if implemented). + **Expected Result:** Clipboard actions are explicit and accurate; warnings appear for sensitive data; timeout-clearing behavior matches policy. + **Post-conditions:** Clipboard content follows security design. + **Dependencies/Notes:** Log policy mismatch as security defect. + +--- + +### Error Handling & Recovery + +#### GW-MAN-ERR-001 + +**Module:** Error Handling and Recovery +**Title:** Global network outage messaging and retry pattern +**Priority/Severity/Type:** P0 / S1 / Recovery, Compatibility +**Platform(s):** All +**Preconditions:** App online and synced. +**Test Data:** Simulated offline mode +**Steps:** + +1. Disable network while navigating core screens. +2. Trigger refresh/actions on dashboard, send, DEX. +3. Observe global and module-level error messaging. +4. Re-enable network and retry. + **Expected Result:** Clear outage indicators and retries provided; no crashes; operations recover when network returns. + **Post-conditions:** App returns to healthy synced state. + **Dependencies/Notes:** Ensure no stale loading spinner persists indefinitely. + +#### GW-MAN-ERR-002 + +**Module:** Error Handling and Recovery +**Title:** Partial backend failure isolation (one module fails, app survives) +**Priority/Severity/Type:** P1 / S2 / Recovery, Regression +**Platform(s):** All +**Preconditions:** Ability to simulate/observe endpoint-specific failure. +**Test Data:** Failed chart/DEX/faucet response with other services up +**Steps:** + +1. Trigger failure in one module endpoint. +2. Navigate to unaffected modules. +3. Confirm unaffected modules remain functional. +4. Retry failed module after recovery. + **Expected Result:** Failure is contained to impacted module with actionable error; global app remains usable. + **Post-conditions:** Failed module recovers after service restore. + **Dependencies/Notes:** Critical resilience behavior. + +#### GW-MAN-ERR-003 + +**Module:** Error Handling and Recovery +**Title:** Stale-state reconciliation after offline transaction lifecycle changes +**Priority/Severity/Type:** P0 / S1 / Recovery, Data Integrity +**Platform(s):** All +**Preconditions:** Pending send/DEX/bridge transaction exists. +**Test Data:** In-flight transaction while app closed/offline +**Steps:** + +1. Start transaction and close app before confirmation. +2. Wait until backend confirms/fails transaction. +3. Reopen app and refresh relevant module. +4. Compare local status with authoritative history/explorer. + **Expected Result:** Local state reconciles to final authoritative status with correct balances/history; no duplicate/ghost entries. + **Post-conditions:** State integrity verified post-recovery. + **Dependencies/Notes:** High-priority integrity checkpoint. + +--- + +### Localization & Readability + +#### GW-MAN-L10N-001 + +**Module:** Localization and Readability +**Title:** Translation completeness and fallback behavior +**Priority/Severity/Type:** P2 / S3 / Functional, Compatibility +**Platform(s):** All +**Preconditions:** At least two locales available. +**Test Data:** Locale A and Locale B +**Steps:** + +1. Switch app to Locale A and review key routes. +2. Switch app to Locale B and review same routes. +3. Check for untranslated keys/placeholders. +4. Trigger an error dialog and confirmation dialog in each locale. + **Expected Result:** Strings are translated; fallback language appears only where intended; no raw localization keys visible. + **Post-conditions:** Locale can be restored to default. + **Dependencies/Notes:** Include auth/send/DEX/settings dialogs. + +#### GW-MAN-L10N-002 + +**Module:** Localization and Readability +**Title:** Long-string overflow and UI clipping checks +**Priority/Severity/Type:** P2 / S3 / Compatibility, Usability +**Platform(s):** All +**Preconditions:** Locale with longer text enabled; small screen width available. +**Test Data:** Long labels in settings/errors/buttons +**Steps:** + +1. Open key screens at narrow width. +2. Verify buttons, headers, and dialog text with long translations. +3. Increase text scaling and re-check. +4. Navigate through send/DEX confirmation screens. + **Expected Result:** No critical text clipping/overlap; labels remain understandable and actionable. + **Post-conditions:** Readability status documented. + **Dependencies/Notes:** Coordinate with A11Y text scaling results. + +#### GW-MAN-L10N-003 + +**Module:** Localization and Readability +**Title:** Locale-specific date/number/currency formatting consistency +**Priority/Severity/Type:** P2 / S3 / Functional +**Platform(s):** All +**Preconditions:** Transaction history present; multiple locales supported. +**Test Data:** Same transaction set viewed under different locales +**Steps:** + +1. View transaction history and balances in Locale A. +2. Record date/time and decimal/thousand formatting. +3. Switch to Locale B and compare formats. +4. Verify consistency across dashboard, history, export. + **Expected Result:** Date/number formatting follows selected locale consistently across modules. + **Post-conditions:** Format behavior validated. + **Dependencies/Notes:** Ensure exported history uses documented format rules. + +--- + +### Additional Audited Feature Coverage + + +#### GW-MAN-FIAT-001 +**Module:** Fiat On-ramp +**Title:** Fiat menu access and connect-wallet gating +**Priority/Severity/Type:** P0 / S1 / Smoke, Functional +**Platform(s):** Web, Android, iOS, macOS, Linux, Windows +**Preconditions:** App installed; test user available. +**Test Data:** Logged-out and logged-in states +**Steps:** +1. Open Fiat from main menu while logged out. +2. Verify connect-wallet gating. +3. Connect wallet from Fiat flow. +4. Re-open Fiat form and verify fields are enabled. +**Expected Result:** Logged-out users are gated; logged-in users can access Fiat form and controls. +**Post-conditions:** Logged-in session active. +**Dependencies/Notes:** Covers routed `fiat` menu behavior. + +#### GW-MAN-FIAT-002 +**Module:** Fiat On-ramp +**Title:** Fiat form validation (currency/asset/amount/payment method) +**Priority/Severity/Type:** P0 / S1 / Functional, Boundary, Negative +**Platform(s):** All +**Preconditions:** Logged in; Fiat page loaded. +**Test Data:** Min/max fiat amounts, unsupported combinations +**Steps:** +1. Select fiat currency and crypto asset. +2. Enter below-minimum amount. +3. Enter above-maximum amount. +4. Enter valid amount and switch payment methods. +5. Attempt submit when invalid and when valid. +**Expected Result:** Validation messages are accurate; submit only enabled for valid combinations. +**Post-conditions:** No invalid order submitted. +**Dependencies/Notes:** Boundary behavior must match provider constraints. + +#### GW-MAN-FIAT-003 +**Module:** Fiat On-ramp +**Title:** Fiat checkout success via provider webview/dialog +**Priority/Severity/Type:** P0 / S1 / Functional, Recovery +**Platform(s):** All +**Preconditions:** Valid fiat form inputs and payment method selected. +**Test Data:** Valid provider-supported pair and amount +**Steps:** +1. Submit `Buy Now`. +2. Complete provider checkout flow. +3. Return to app. +4. Verify success status dialog/message. +**Expected Result:** Checkout launches correctly; successful completion state is shown and form status resets appropriately. +**Post-conditions:** Successful fiat order event recorded. +**Dependencies/Notes:** Use QA/test provider mode where available. + +#### GW-MAN-FIAT-004 +**Module:** Fiat On-ramp +**Title:** Fiat checkout closed/failed/pending handling +**Priority/Severity/Type:** P0 / S1 / Negative, Recovery +**Platform(s):** All +**Preconditions:** Fiat checkout opened. +**Test Data:** Provider failure or window close before completion +**Steps:** +1. Submit fiat checkout. +2. Close provider dialog/window before completion. +3. Repeat and trigger provider-side failure. +4. Verify failure messaging and retry capability. +**Expected Result:** Closed/failed states are handled gracefully with user-visible status and no app crash. +**Post-conditions:** User can retry cleanly. +**Dependencies/Notes:** Ensure no stale submitting state. + +#### GW-MAN-FIAT-005 +**Module:** Fiat On-ramp +**Title:** Fiat form behavior across logout/login transitions +**Priority/Severity/Type:** P1 / S2 / Regression +**Platform(s):** All +**Preconditions:** Fiat page open with prefilled values. +**Test Data:** Existing logged-in session +**Steps:** +1. Fill Fiat form partially. +2. Log out from settings while on Fiat module. +3. Verify form resets to logged-out state. +4. Log back in and verify re-initialization. +**Expected Result:** Fiat state resets safely on logout; re-login reloads supported lists and addresses. +**Post-conditions:** Clean Fiat form state. +**Dependencies/Notes:** Data integrity and privacy check. + +#### GW-MAN-SUP-001 +**Module:** Support and Help +**Title:** Support page content, external link, and missing-coins dialog +**Priority/Severity/Type:** P2 / S3 / Functional, Compatibility +**Platform(s):** All +**Preconditions:** Support page accessible from settings/menu route. +**Test Data:** N/A +**Steps:** +1. Open Support page. +2. Verify support content and FAQ items render. +3. Open contact link and confirm target URL opens. +4. Open `My Coins Missing` dialog and verify help link behavior. +**Expected Result:** Support resources are readable and actionable; links/dialog work correctly. +**Post-conditions:** Returned to app context. +**Dependencies/Notes:** Browser/app-link handling differs by platform. + +#### GW-MAN-FEED-001 +**Module:** Feedback +**Title:** Feedback entry points from settings and floating bug button +**Priority/Severity/Type:** P2 / S3 / Functional, Compatibility +**Platform(s):** All +**Preconditions:** Feedback provider available in current build. +**Test Data:** Sample feedback text and screenshot attachment +**Steps:** +1. Open feedback from settings menu (if shown). +2. Open feedback from floating bug button. +3. Submit valid feedback. +4. Repeat and cancel submission. +**Expected Result:** Both entry points open feedback UI; submit/cancel work; success/failure feedback appears. +**Post-conditions:** No blocking overlays remain. +**Dependencies/Notes:** If provider unavailable, verify controls are hidden. + +#### GW-MAN-SECX-001 +**Module:** Security Settings +**Title:** Private key export flow with show/hide, copy/share/download +**Priority/Severity/Type:** P0 / S1 / Security +**Platform(s):** All +**Preconditions:** Logged in with software wallet and active assets. +**Test Data:** Wallet password, active assets including blocked/non-blocked assets if applicable +**Steps:** +1. Open Settings -> Security -> Private Keys. +2. Authenticate with wallet password. +3. Toggle `Show Private Keys`. +4. Execute copy/download/share actions. +5. Toggle blocked assets include/exclude if available. +6. Navigate away and return. +**Expected Result:** Access is password-gated; keys are hidden by default; actions work with security warnings; sensitive state is cleared on navigation. +**Post-conditions:** Private key screen closed; sensitive data no longer visible. +**Dependencies/Notes:** Treat any leakage as S1. + +#### GW-MAN-SECX-002 +**Module:** Security Settings +**Title:** Seed backup show/confirm/success lifecycle +**Priority/Severity/Type:** P0 / S1 / Security, Functional +**Platform(s):** All +**Preconditions:** Software wallet without confirmed backup marker (or reset profile). +**Test Data:** Wallet password +**Steps:** +1. Open seed backup flow. +2. Authenticate and reveal seed. +3. Complete seed confirmation challenge. +4. Verify success state and return to security menu. +**Expected Result:** Seed flow is protected and completes only after confirmation; backup-complete state is reflected in UI indicators. +**Post-conditions:** Seed flow completed. +**Dependencies/Notes:** Validate no seed persistence in non-secure views. + +#### GW-MAN-SECX-003 +**Module:** Security Settings +**Title:** Unban pubkeys operation (success, empty, error) +**Priority/Severity/Type:** P1 / S2 / Functional, Recovery +**Platform(s):** All +**Preconditions:** Wallet with banned pubkey scenario in test environment. +**Test Data:** Pubkeys with mixed statuses +**Steps:** +1. Trigger `Unban Pubkeys`. +2. Observe progress state. +3. Verify results dialog and snackbar messaging. +4. Repeat for no-op/empty and simulated error cases. +**Expected Result:** Operation reports counts and details accurately; errors are surfaced without app instability. +**Post-conditions:** Pubkey state updated or unchanged with clear reason. +**Dependencies/Notes:** High value for wallet recovery workflows. + +#### GW-MAN-SECX-004 +**Module:** Security Settings +**Title:** Change password flow and re-authentication behavior +**Priority/Severity/Type:** P0 / S1 / Security, Regression +**Platform(s):** All +**Preconditions:** Logged in with known current password. +**Test Data:** Valid and invalid new password combinations +**Steps:** +1. Open Change Password. +2. Enter wrong current password and validate rejection. +3. Enter valid current password and valid new password. +4. Log out and log in with old password (expect fail). +5. Log in with new password (expect success). +**Expected Result:** Password update requires valid current password and takes effect immediately after update. +**Post-conditions:** Session authenticated with new password. +**Dependencies/Notes:** Include weak-password policy interaction with `SETX-001`. + +#### GW-MAN-SETX-001 +**Module:** Settings Advanced +**Title:** Weak-password toggle enforcement in wallet create/import +**Priority/Severity/Type:** P1 / S2 / Functional, Security +**Platform(s):** All +**Preconditions:** Access to settings and wallet create/import dialogs. +**Test Data:** Weak and strong passwords +**Steps:** +1. Disable `Allow Weak Password`. +2. Attempt wallet create/import with weak password. +3. Enable `Allow Weak Password`. +4. Retry with weak password. +**Expected Result:** Policy is enforced when disabled and relaxed when enabled, with clear validation messages. +**Post-conditions:** Password policy state persisted as configured. +**Dependencies/Notes:** Security policy must be explicit to user. + +#### GW-MAN-SETX-002 +**Module:** Settings Advanced +**Title:** Trading bot master toggles and stop-on-disable behavior +**Priority/Severity/Type:** P1 / S2 / Functional, Recovery +**Platform(s):** All +**Preconditions:** Trading bot feature enabled; active bot available. +**Test Data:** Existing bot config +**Steps:** +1. Open expert/trading-bot settings. +2. Toggle `Enable Trading Bot` off. +3. Verify active bot stops. +4. Toggle on and verify feature availability returns. +5. Toggle `Save Orders` and relaunch app. +**Expected Result:** Disabling bot stops running bots; save-orders preference persists and affects restart behavior. +**Post-conditions:** Bot feature state matches configured toggles. +**Dependencies/Notes:** Coordinate with BOT module tests. + +#### GW-MAN-SETX-003 +**Module:** Settings Advanced +**Title:** Export/import maker orders JSON (valid, malformed, empty) +**Priority/Severity/Type:** P1 / S2 / Functional, Recovery +**Platform(s):** All +**Preconditions:** Maker orders exist for export. +**Test Data:** Valid export file, malformed JSON, empty file +**Steps:** +1. Export maker orders and verify file created. +2. Import exported file and verify success count. +3. Import malformed JSON and verify error. +4. Import empty/invalid structure and verify error. +**Expected Result:** Valid files import successfully; invalid files fail gracefully with actionable messages. +**Post-conditions:** Stored maker-order config remains consistent. +**Dependencies/Notes:** No duplicate/corrupted configs after import. + +#### GW-MAN-SETX-004 +**Module:** Settings Advanced +**Title:** Show swap data and export swap data +**Priority/Severity/Type:** P2 / S3 / Functional +**Platform(s):** All +**Preconditions:** Swap history exists in test profile. +**Test Data:** Existing swaps +**Steps:** +1. Open `Show Swap Data`. +2. Expand and verify raw swap payload appears. +3. Use copy action. +4. Trigger `Export Swap Data` and verify file output. +**Expected Result:** Swap raw data is viewable/copyable/exportable without UI lockup. +**Post-conditions:** Exported file available. +**Dependencies/Notes:** For debugging/support workflows. + +#### GW-MAN-SETX-005 +**Module:** Settings Advanced +**Title:** Import swaps from JSON payload +**Priority/Severity/Type:** P2 / S3 / Functional, Negative +**Platform(s):** All +**Preconditions:** Import swaps panel accessible. +**Test Data:** Valid swaps JSON list, malformed JSON, empty list +**Steps:** +1. Open `Import Swaps`. +2. Paste valid swaps JSON and import. +3. Repeat with malformed payload. +4. Repeat with empty list. +**Expected Result:** Valid payload imports; malformed/empty payloads display specific errors and do not crash app. +**Post-conditions:** Import status reflected correctly. +**Dependencies/Notes:** Useful for support-led recovery. + +#### GW-MAN-SETX-006 +**Module:** Settings Advanced +**Title:** Download logs and debug flood logs control +**Priority/Severity/Type:** P2 / S3 / Functional, Recovery +**Platform(s):** All (flood logs on debug/profile builds) +**Preconditions:** Logged-in software wallet; diagnostics available. +**Test Data:** N/A +**Steps:** +1. Trigger `Download Logs`. +2. Verify file generation. +3. On debug/profile build, run `Flood Logs`. +4. Download logs again and verify updated content size. +**Expected Result:** Logs are downloadable; debug flood operation completes and app remains responsive. +**Post-conditions:** Diagnostic artifacts available. +**Dependencies/Notes:** Do not run flood logs in performance-critical sessions. + +#### GW-MAN-SETX-007 +**Module:** Settings Advanced +**Title:** Reset activated coins for selected wallet +**Priority/Severity/Type:** P2 / S3 / Recovery +**Platform(s):** All +**Preconditions:** Multiple wallets with activated assets. +**Test Data:** Wallet A and Wallet B +**Steps:** +1. Open reset activated coins tool. +2. Select wallet A and cancel at confirmation. +3. Repeat and confirm reset. +4. Verify only selected wallet activation state resets. +**Expected Result:** Reset operation is wallet-specific, confirmation-protected, and displays completion message. +**Post-conditions:** Selected wallet coin activations reset; others unchanged. +**Dependencies/Notes:** Validate no unintended cross-wallet impact. + +#### GW-MAN-WALX-001 +**Module:** Wallet Dashboard Advanced +**Title:** Wallet overview cards and privacy toggle behavior +**Priority/Severity/Type:** P1 / S2 / Functional, Security +**Platform(s):** All +**Preconditions:** Wallet with balances and portfolio data loaded. +**Test Data:** Non-zero and near-zero portfolio values +**Steps:** +1. Open wallet overview cards. +2. Verify current balance, all-time investment, and all-time profit cards. +3. Toggle privacy icon. +4. Long-press card values to copy (when visible). +**Expected Result:** Overview metrics render consistently; privacy masking applies; copy behavior only available when unmasked. +**Post-conditions:** Privacy state persists per settings behavior. +**Dependencies/Notes:** Include mobile carousel and desktop card layouts. + +#### GW-MAN-WALX-002 +**Module:** Wallet Dashboard Advanced +**Title:** Assets/Growth/Profit-Loss tab behavior for logged-in vs logged-out +**Priority/Severity/Type:** P1 / S2 / Functional, Compatibility +**Platform(s):** All +**Preconditions:** Ability to test both authenticated and unauthenticated states. +**Test Data:** Wallet with historical data +**Steps:** +1. Logged in: switch between Assets, Portfolio Growth, and Profit/Loss tabs. +2. Verify chart rendering and tab changes. +3. Log out and reopen wallet page. +4. Verify logged-out tab set and statistics fallback behavior. +**Expected Result:** Tab availability and content adapt to auth state without errors or stale data. +**Post-conditions:** UI state stable after auth changes. +**Dependencies/Notes:** Ensure no ghost data leakage after logout. + +#### GW-MAN-WADDR-001 +**Module:** Coin Addresses +**Title:** Multi-address display controls, QR/copy/faucet per address +**Priority/Severity/Type:** P1 / S2 / Functional, Regression +**Platform(s):** All +**Preconditions:** Coin with multiple addresses available. +**Test Data:** Addresses with zero and non-zero balances; faucet-capable coin +**Steps:** +1. Open coin addresses section. +2. Toggle hide-zero-balance addresses. +3. Expand/collapse all addresses list. +4. Use copy and QR actions for an address. +5. Trigger faucet on a faucet-supported address. +**Expected Result:** Address list controls work; copy/QR/faucet actions apply to selected address correctly. +**Post-conditions:** Address state remains synchronized with balance updates. +**Dependencies/Notes:** Must use in-app faucet action from address card. + +#### GW-MAN-WADDR-002 +**Module:** Coin Addresses +**Title:** Create new address flow with confirmation/cancel/error paths +**Priority/Severity/Type:** P1 / S2 / Functional, Recovery +**Platform(s):** All (hardware-specific confirmation where applicable) +**Preconditions:** Coin supports new address generation and creation reasons permit action. +**Test Data:** Software wallet and hardware wallet profiles +**Steps:** +1. Press `Create New Address`. +2. For hardware wallet flow, confirm on device when prompted. +3. Validate new address appears in list after completion. +4. Repeat and cancel confirmation. +5. Trigger an error scenario (e.g., disconnected hardware) and observe result. +**Expected Result:** Creation succeeds with confirmation; cancellation/error handled with clear state and messaging. +**Post-conditions:** Address list accurately reflects only completed creations. +**Dependencies/Notes:** Includes derivation-path generation reliability. + +#### GW-MAN-CTOK-001 +**Module:** Custom Token Import +**Title:** Import custom token happy path +**Priority/Severity/Type:** P1 / S2 / Functional +**Platform(s):** All +**Preconditions:** Supported EVM network active; valid token contract. +**Test Data:** Valid EVM token contract address +**Steps:** +1. Open Coins Manager and start custom token import. +2. Select network and enter valid contract address. +3. Fetch token details and review preview. +4. Confirm import. +5. Verify token appears in wallet coin list. +**Expected Result:** Token metadata loads and token is imported/activatable successfully. +**Post-conditions:** Imported token visible in wallet list/details. +**Dependencies/Notes:** Use non-production contract in test environment. + +#### GW-MAN-CTOK-002 +**Module:** Custom Token Import +**Title:** Custom token fetch failure and not-found handling +**Priority/Severity/Type:** P1 / S2 / Negative +**Platform(s):** All +**Preconditions:** Custom token dialog open. +**Test Data:** Invalid contract, unsupported network-contract combo +**Steps:** +1. Enter invalid contract and fetch. +2. Enter valid-format but non-existent contract and fetch. +3. Switch network and retry mismatched contract. +**Expected Result:** Not-found/error states are shown without app crash; import remains blocked. +**Post-conditions:** No token imported from invalid attempts. +**Dependencies/Notes:** Error text should be actionable. + +#### GW-MAN-CTOK-003 +**Module:** Custom Token Import +**Title:** Back/cancel behavior and state reset across pages +**Priority/Severity/Type:** P2 / S3 / Recovery +**Platform(s):** All +**Preconditions:** Import dialog in result page after a fetch attempt. +**Test Data:** Fetched token result and failed result +**Steps:** +1. Navigate to submit/result page. +2. Press back to form page. +3. Verify state reset behavior. +4. Close dialog and reopen import flow. +**Expected Result:** Back/close operations reset state appropriately with no stale data from prior attempts. +**Post-conditions:** Clean form on reopen. +**Dependencies/Notes:** Prevents accidental import from stale context. + +#### GW-MAN-RWD-001 +**Module:** Rewards +**Title:** KMD rewards info refresh and claim lifecycle +**Priority/Severity/Type:** P1 / S2 / Functional, Recovery +**Platform(s):** All +**Preconditions:** KMD asset present; rewards test profile available. +**Test Data:** Reward-available and reward-empty profiles +**Steps:** +1. Open KMD coin details and `Get Rewards`. +2. Verify reward list loads and total reward shown. +3. Claim rewards when available. +4. Verify success screen and updated state. +5. Validate no-reward and claim-failure behavior. +**Expected Result:** Rewards lifecycle is accurate; claim actions reflect real status and errors safely. +**Post-conditions:** Rewards state synchronized after claim attempt. +**Dependencies/Notes:** Use test wallet where rewards flow is reproducible. + +#### GW-MAN-GATE-001 +**Module:** Feature Gating +**Title:** Trading-disabled mode behavior and tooltips +**Priority/Severity/Type:** P0 / S1 / Functional, Compatibility +**Platform(s):** All +**Preconditions:** Environment with trading disallowed status. +**Test Data:** Trading disabled state from backend policy +**Steps:** +1. Open main menu with trading disabled. +2. Hover/tap disabled trading entries. +3. Verify tooltip/messages. +4. Attempt route navigation to restricted features. +**Expected Result:** Trading-restricted features are consistently disabled and clearly explained; route bypass is prevented or safely handled. +**Post-conditions:** App remains navigable for permitted modules. +**Dependencies/Notes:** Covers compliance/risk-based gating. + +#### GW-MAN-GATE-002 +**Module:** Feature Gating +**Title:** Hardware-wallet restrictions for fiat/trading modules +**Priority/Severity/Type:** P0 / S1 / Security, Compatibility +**Platform(s):** Web, macOS, Linux, Windows +**Preconditions:** Trezor wallet logged in. +**Test Data:** WP-04 +**Steps:** +1. Log in with hardware wallet. +2. Inspect Fiat/DEX/Bridge/Trading Bot menu items. +3. Attempt to open restricted modules. +4. Verify tooltip/wallet-only messaging. +**Expected Result:** Restricted modules remain unavailable for hardware wallet where policy requires; user messaging is clear. +**Post-conditions:** Hardware wallet session remains stable. +**Dependencies/Notes:** Must align with product security policy. + +#### GW-MAN-GATE-003 +**Module:** Feature Gating +**Title:** NFT menu disabled state and direct-route safety +**Priority/Severity/Type:** P1 / S2 / Functional, Security +**Platform(s):** All +**Preconditions:** App build where NFT menu is intentionally disabled. +**Test Data:** N/A +**Steps:** +1. Inspect NFT menu item state. +2. Verify disabled tooltip. +3. Attempt direct route/deep link to NFT pages. +4. Validate resulting behavior (blocked/fallback/safe rendering). +**Expected Result:** Disabled menu is consistent and direct route attempts do not expose unsafe or broken state. +**Post-conditions:** Navigation remains stable. +**Dependencies/Notes:** If NFTs enabled in future build, execute full NFT suite instead. + +#### GW-MAN-QLOG-001 +**Module:** Quick Login +**Title:** Remembered wallet prompt and remember-me persistence +**Priority/Severity/Type:** P1 / S2 / Functional, Regression +**Platform(s):** All +**Preconditions:** At least one prior successful login with remember-me enabled. +**Test Data:** Wallet with remember-me on/off toggles +**Steps:** +1. Enable remember-me during login. +2. Relaunch app from logged-out state. +3. Verify remembered-wallet prompt appears. +4. Complete quick login and verify target wallet. +5. Disable remember-me and confirm prompt no longer appears. +**Expected Result:** Remembered-wallet prompt appears only when expected and logs into correct wallet; disable path clears behavior. +**Post-conditions:** Remember-me state matches selection. +**Dependencies/Notes:** Ensure no wrong-wallet auto-selection. + +#### GW-MAN-BREF-001 +**Module:** Bitrefill (Conditional) +**Title:** Bitrefill integration visibility and payment-intent lifecycle +**Priority/Severity/Type:** P3 / S4 / Compatibility, Functional +**Platform(s):** All +**Preconditions:** Coin details page; run once with feature flag off and once with flag on (if available). +**Test Data:** Supported and unsupported coin scenarios +**Steps:** +1. Verify Bitrefill button is hidden when integration flag is off. +2. With integration enabled build, verify supported coin visibility. +3. Launch Bitrefill widget and handle payment-intent event. +4. Verify unsupported/suspended/zero-balance disable states and tooltips. +**Expected Result:** Feature flag and eligibility logic are respected; widget launch and event handling work only when eligible. +**Post-conditions:** No stuck Bitrefill state. +**Dependencies/Notes:** Optional integration; execute only when enabled in build. + +#### GW-MAN-ZHTL-001 +**Module:** ZHTLC Configuration (Conditional) +**Title:** ZHTLC configuration dialog and activation state handling +**Priority/Severity/Type:** P2 / S3 / Recovery, Functional +**Platform(s):** All +**Preconditions:** ZHTLC subclass asset available in test environment. +**Test Data:** Valid/invalid ZHTLC sync parameters +**Steps:** +1. Trigger ZHTLC configuration request (auto/manual path). +2. Validate required fields and advanced settings expansion. +3. Save valid configuration and start activation. +4. Repeat and cancel configuration. +5. Trigger logout during pending configuration/activation. +**Expected Result:** Dialog validates inputs, handles cancel/save, and cleans up pending requests safely on auth changes. +**Post-conditions:** Activation state and dialogs close cleanly. +**Dependencies/Notes:** Execute only where ZHTLC assets are supported. + +#### GW-MAN-WARN-001 +**Module:** System Health +**Title:** Clock warning banner under invalid system-time condition +**Priority/Severity/Type:** P2 / S3 / Compatibility, Recovery +**Platform(s):** All +**Preconditions:** Ability to simulate invalid system-time check with trading enabled. +**Test Data:** Normal vs invalid local clock state +**Steps:** +1. Open DEX/Bridge pages with valid clock. +2. Simulate invalid system-time state. +3. Verify warning banner appears. +4. Restore normal clock and verify banner clears. +**Expected Result:** Clock warning appears only when required and does not block core navigation unexpectedly. +**Post-conditions:** Banner state reconciles after recovery. +**Dependencies/Notes:** Compliance-critical for transaction timing. + + +## 6. End-to-End User Journey Suites + +### E2E-001: New User Onboarding to First Funded Transaction (DOC/MARTY) + +**Mapped Core IDs:** AUTH-001, COIN-001, SEND-001, SEND-003, CDET-002 +**Steps:** + +1. Create new wallet and complete seed backup confirmation. +2. Enable test coins and activate DOC/MARTY. +3. Copy DOC and MARTY receive addresses. +4. Request faucet funds for both coins using in-app faucet actions on each coin page. +5. Refresh until incoming tx appears and confirms. +6. Send DOC to valid recipient. +7. Verify pending -> confirmed in history and explorer. + **Expected Outcome:** First-time user can safely create wallet, fund via faucet, and complete first send successfully. + +### E2E-002: Restore/Import Wallet to Active Trading + +**Mapped Core IDs:** AUTH-003, COIN-001, DEX-001, DEX-002, DEX-005 +**Steps:** + +1. Import wallet from valid seed. +2. Enable DOC/MARTY and verify balances/history sync. +3. Open DEX and place maker order. +4. Execute taker order from orderbook. +5. Validate order/swap history and export. + **Expected Outcome:** Returning user can restore wallet and trade without reconfiguration issues. + +### E2E-003: Faucet Funding to Withdraw/Send Verification with Recovery + +**Mapped Core IDs:** SEND-001, SEND-002, SEND-004, SEND-005, SEND-006, ERR-003 +**Steps:** + +1. Fund wallet via in-app faucet success path on faucet-coin pages. +2. Trigger cooldown/denied faucet path and verify handling. +3. Attempt invalid address/memo send and validate blocking. +4. Submit valid send. +5. Interrupt network/app during pending state. +6. Reopen and verify reconciliation to final status. + **Expected Outcome:** Money flow is robust across happy, negative, boundary, and recovery scenarios. + +### E2E-004: DEX Order Placement to Completion/Cancel Verification + +**Mapped Core IDs:** DEX-001, DEX-003, DEX-004, DEX-006 +**Steps:** + +1. Place valid maker order. +2. Validate invalid order inputs are blocked. +3. Observe partial fill and cancel remainder. +4. Simulate restart/offline and reconcile order status. +5. Verify balances and locked funds are correct. + **Expected Outcome:** DEX lifecycle and data integrity remain correct under normal and interrupted conditions. + +### E2E-005: Settings Persistence Across Logout/Restart + +**Mapped Core IDs:** SET-001, SET-002, SET-003, SET-004, DASH-003 +**Steps:** + +1. Change theme, locale, privacy toggles, and test coin setting. +2. Log out and log back in. +3. Restart app. +4. Verify all settings and dashboard behavior persist per design. + **Expected Outcome:** User preferences persist consistently and predictably. + +--- + +## 7. Non-Functional Manual Test Suite + +| NF ID | Category | Manual Procedure | Pass Criteria | Related Core IDs | +| ------- | ---------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------- | --------------------------- | +| NFM-001 | Performance (Perceived) | Measure cold launch to interactive dashboard on each platform (3 runs). | Median launch within product target; no freeze/stutter >2s. | XPLAT-001, DASH-002 | +| NFM-002 | Performance (Transaction UX) | Time from send confirm tap to pending state visible. | Pending feedback appears quickly and consistently. | SEND-003 | +| NFM-003 | Reliability | Run 2-hour exploratory session switching modules repeatedly. | No crash, no unrecoverable stale state. | NAV-001, ERR-002 | +| NFM-004 | Recovery | Toggle network on/off during active send/DEX/bridge tasks. | Clear recovery path; final state reconciles correctly. | SEND-006, DEX-006, BRDG-004 | +| NFM-005 | Accessibility | Keyboard-only pass across auth/send/settings dialogs. | No keyboard traps; logical focus order. | A11Y-001 | +| NFM-006 | Accessibility | Screen reader pass for critical transaction routes. | Controls announced with correct labels/states. | A11Y-002 | +| NFM-007 | Accessibility | Large text + narrow width visual audit. | No critical truncation/overlap blocking actions. | A11Y-003, L10N-002 | +| NFM-008 | Security/Privacy | Validate seed/session/clipboard controls across lifecycle. | Sensitive data protected per policy. | SEC-001..003 | +| NFM-009 | Compatibility | Browser/device matrix sanity run for core journey. | No platform-specific blocker in P0 path. | XPLAT-001..002 | +| NFM-010 | Compatibility | Background/foreground and interruption behavior (calls, tab refresh). | State resumes safely or prompts user clearly. | RESP-002, ERR-003 | + +--- + +## 8. Regression Pack Definition + +| Pack | Purpose | Test IDs | +| ------------------------ | --------------------------------------------- | -------- | +| Smoke Pack | Fast release gate for core viability | GW-MAN-AUTH-001, GW-MAN-AUTH-002, GW-MAN-COIN-001, GW-MAN-DASH-001, GW-MAN-CDET-001, GW-MAN-SEND-001, GW-MAN-SEND-003, GW-MAN-DEX-001, GW-MAN-BRDG-001, GW-MAN-NFT-001, GW-MAN-SET-003, GW-MAN-NAV-001, GW-MAN-RESP-001, GW-MAN-A11Y-001, GW-MAN-SEC-001, GW-MAN-ERR-001, GW-MAN-FIAT-001, GW-MAN-FIAT-002, GW-MAN-SECX-001, GW-MAN-WADDR-001, GW-MAN-CTOK-001, GW-MAN-GATE-001 | +| Critical Regression Pack | Money movement + auth + data integrity | GW-MAN-AUTH-001..005, GW-MAN-WAL-001..003, GW-MAN-SEND-001..006, GW-MAN-DEX-001..004 + GW-MAN-DEX-006, GW-MAN-BRDG-001..004, GW-MAN-SEC-001..003, GW-MAN-ERR-001..003, GW-MAN-XPLAT-001, GW-MAN-FIAT-001..004, GW-MAN-SECX-001..004, GW-MAN-CTOK-001..002, GW-MAN-WADDR-001..002, GW-MAN-GATE-001..002, GW-MAN-QLOG-001 | +| Full Regression Pack | Complete functional + non-functional coverage | GW-MAN-AUTH-001..005, GW-MAN-WAL-001..003, GW-MAN-COIN-001..003, GW-MAN-DASH-001..003, GW-MAN-CDET-001..003, GW-MAN-SEND-001..006, GW-MAN-DEX-001..006, GW-MAN-BRDG-001..004, GW-MAN-NFT-001..003, GW-MAN-SET-001..004, GW-MAN-BOT-001..003, GW-MAN-NAV-001..003, GW-MAN-RESP-001..002, GW-MAN-XPLAT-001..002, GW-MAN-A11Y-001..003, GW-MAN-SEC-001..003, GW-MAN-ERR-001..003, GW-MAN-L10N-001..003, GW-MAN-FIAT-001..005, GW-MAN-SUP-001, GW-MAN-FEED-001, GW-MAN-SECX-001..004, GW-MAN-SETX-001..007, GW-MAN-WALX-001..002, GW-MAN-WADDR-001..002, GW-MAN-CTOK-001..003, GW-MAN-RWD-001, GW-MAN-GATE-001..003, GW-MAN-QLOG-001, GW-MAN-BREF-001, GW-MAN-ZHTL-001, GW-MAN-WARN-001 | + +--- + +## 9. Defect Classification Model + +### Severity (S1-S4) + +- **S1 Critical:** Security breach, fund loss risk, auth bypass, transaction integrity failure, app crash on core flow. +- **S2 Major:** Core feature unavailable or incorrect (send/DEX/bridge/NFT/settings persistence), strong user impact, no easy workaround. +- **S3 Moderate:** Non-core functional issue, visual/accessibility/localization issue with workaround. +- **S4 Minor:** Cosmetic issue, low-impact copy/layout mismatch with no functional impact. + +### Priority (P0-P3) + +- **P0 Immediate:** Must fix before release (auth, wallet access, seed/security, faucet/send/DEX/bridge correctness). +- **P1 High:** Should fix in current release cycle (coin visibility, histories, NFT send/history, persistence). +- **P2 Medium:** Fix soon; low immediate release risk (secondary controls, localization polish). +- **P3 Low:** Nice-to-have or cosmetic improvements. + +### Reproducibility Labels + +- **Always (100%)** +- **Frequent (>50%)** +- **Intermittent (<50%)** +- **Rare/Environment-specific** +- **Unable to Reproduce** + +### Required Bug Report Fields + +- Defect ID +- Title +- Build version/commit +- Platform + OS + device/browser +- Module/screen +- Preconditions +- Exact steps to reproduce +- Expected result +- Actual result +- Severity + Priority +- Reproducibility label +- Screenshots/video/logs/tx hash/order ID +- Network condition during failure +- Notes on workaround (if any) + +--- + +## 10. Execution Order and Time Estimate + +### Recommended Risk-Based Execution Sequence + +1. `P0 Security/Auth/Gating`: AUTH, SEC, SECX, GATE, QLOG +2. `P0 Money Movement`: SEND, DEX, BRDG, FIAT +3. `P0/P1 Integrity and Asset Controls`: ERR, CTOK, WADDR, XPLAT-001 +4. `P1 Core Usability`: WAL, WALX, COIN, DASH, CDET, NFT, SET, SETX, NAV, SUP/FEED, RWD +5. `P2/P3 Platform and Conditional Integrations`: BOT, RESP, A11Y, L10N, WARN, BREF, ZHTL, XPLAT-002 + +### Estimated Time Per Module (Manual) + +| Module | Estimated Time | +| ------------------------------ | -------------- | +| AUTH | 2.5h | +| WAL | 1.5h | +| WALX/WADDR | 2.4h | +| COIN | 1.2h | +| CTOK | 1.8h | +| DASH | 1.0h | +| CDET | 1.2h | +| SEND | 3.5h | +| DEX | 4.5h | +| BRDG | 3.0h | +| FIAT | 2.8h | +| NFT | 1.8h | +| SET | 1.8h | +| SETX | 3.2h | +| BOT | 1.8h | +| NAV | 1.2h | +| SUP/FEED | 1.0h | +| RWD | 1.0h | +| RESP | 1.0h | +| XPLAT | 3.0h | +| A11Y | 2.0h | +| SEC | 2.0h | +| SECX | 2.5h | +| ERR | 1.8h | +| GATE/QLOG/WARN | 1.6h | +| L10N | 1.5h | +| Optional BREF/ZHTL | 1.8h | +| **Total excl. optional modules** | **~52.6h** | +| **Total incl. optional modules** | **~54.4h** | + +### Suggested Parallel Tester Allocation + +1. **Tester A (Critical Core):** AUTH, SEC, SECX, SEND, ERR, GATE/QLOG +2. **Tester B (Trading/Payments):** DEX, BRDG, FIAT, BOT, RWD +3. **Tester C (Wallet and Settings UX):** WAL, WALX/WADDR, COIN, CTOK, DASH, CDET, NAV, SET, SETX, SUP/FEED +4. **Tester D (Quality/Cross-platform):** NFT, RESP, XPLAT, A11Y, L10N, WARN, optional BREF/ZHTL + +--- + +## 11. Test Completion Checklist + +- [ ] QA build validated on all target platforms. +- [ ] Test coins enabled and DOC/MARTY visibility confirmed. +- [ ] Faucet success/cooldown/error scenarios executed. +- [ ] All `P0` test cases executed. +- [ ] All money-movement integrity checks passed (send/DEX/bridge). +- [ ] Fiat on-ramp validation and provider success/failure handling completed. +- [ ] Custom token import and multi-address create/manage flows completed. +- [ ] Seed/session/privacy tests executed and evidence captured. +- [ ] Advanced security controls completed (seed/private key export/password change/unban pubkeys). +- [ ] Advanced settings operational tooling checks completed (swap data, swap import, logs, reset assets). +- [ ] Recovery tests executed (offline, restart, interrupted flows). +- [ ] Feature-gating and remembered-wallet quick-login behavior validated. +- [ ] Accessibility checks completed (keyboard, SR, contrast, scaling). +- [ ] Localization/readability checks completed. +- [ ] Cross-platform parity run completed. +- [ ] Smoke, Critical, and Full regression pack statuses recorded. +- [ ] Conditional integrations assessed and marked executed/not-applicable (Bitrefill, ZHTLC, NFT-disabled, trading-disabled). +- [ ] All `S1`/`S2` defects triaged with release decision. +- [ ] Final QA sign-off recorded with known-risk list (if any). + +--- + +## 12. Final Coverage Statement + +This document provides complete manual QA coverage for the full Gleec Wallet app scope and implemented feature surface, including authentication/lifecycle, wallet/coin management, dashboard, coin details, send/withdraw, DEX, bridge, NFT, settings, market maker bot, routing/navigation, responsive behavior, cross-platform compatibility, accessibility, security/privacy, error recovery, localization/readability, Fiat on-ramp, support/feedback, advanced security/settings operations, custom token import, rewards, feature-gating, quick-login remembered-wallet flow, and conditional Bitrefill/ZHTLC/system-time warning behavior. +All blockchain-dependent scenarios are explicitly designed for testnet/faucet-only execution using DOC/MARTY with in-app faucet action coverage for success, cooldown/denied, and network/error handling. +Assumptions applied: test services are available; at least one memo/tag-required test asset and one NFT test asset exist; DEX/bridge/Fiat provider QA routes are provisioned; and conditional integrations/features are executed when enabled in the build/environment. + +--- diff --git a/automated_testing/ci-pipeline.sh b/automated_testing/ci-pipeline.sh new file mode 100755 index 0000000000..3aef95271d --- /dev/null +++ b/automated_testing/ci-pipeline.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# Gleec QA Automation — CI Pipeline +# ============================================================================= +# Exit codes: +# 0 = all tests passed +# 1 = test failures or errors +# 2 = pre-flight / infrastructure failure +# 3 = all passed but some flaky +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +MATRIX="${MATRIX:-test_matrix.yaml}" +ARTIFACTS_DIR="${CI_ARTIFACTS_DIR:-results}" + +echo "=== Gleec QA CI Pipeline ===" +mkdir -p "$ARTIFACTS_DIR" + +# --------------------------------------------------------------------------- +# Infrastructure +# --------------------------------------------------------------------------- +echo "[infra] Verifying Ollama..." +if ! curl -sf http://localhost:11434/api/tags > /dev/null 2>&1; then + echo "[infra] Starting Ollama..." + ollama serve & + sleep 5 +fi + +echo "[infra] Starting Docker stack..." +docker compose up -d +sleep 10 + +# --------------------------------------------------------------------------- +# Smoke gate (fast, blocks deployment on failure) +# --------------------------------------------------------------------------- +echo "[smoke] Running smoke gate..." +if python -m runner.runner --matrix "$MATRIX" --tag smoke --single; then + SMOKE_EXIT=0 +else + SMOKE_EXIT=$? +fi + +if [ $SMOKE_EXIT -eq 2 ]; then + echo "[smoke] INFRASTRUCTURE FAILURE — aborting pipeline" + exit 2 +fi + +if [ $SMOKE_EXIT -eq 1 ]; then + echo "[smoke] SMOKE GATE FAILED — blocking deployment" + # Copy whatever reports exist + cp results/run_*/report.html "$ARTIFACTS_DIR/" 2>/dev/null || true + exit 1 +fi + +echo "[smoke] Smoke gate passed (exit=$SMOKE_EXIT)" + +# --------------------------------------------------------------------------- +# Full suite (with retries and majority vote) +# --------------------------------------------------------------------------- +echo "[full] Running full automated suite..." +if python -m runner.runner --matrix "$MATRIX"; then + FULL_EXIT=0 +else + FULL_EXIT=$? +fi + +# --------------------------------------------------------------------------- +# Collect artifacts +# --------------------------------------------------------------------------- +echo "[artifacts] Collecting reports..." +LATEST_RUN=$(ls -td results/run_* 2>/dev/null | head -1) +if [ -n "$LATEST_RUN" ]; then + cp "$LATEST_RUN/report.html" "$ARTIFACTS_DIR/" 2>/dev/null || true + cp "$LATEST_RUN/results.json" "$ARTIFACTS_DIR/" 2>/dev/null || true +fi + +echo "[done] Pipeline complete (exit=$FULL_EXIT)" +exit $FULL_EXIT diff --git a/automated_testing/docker-compose.yml b/automated_testing/docker-compose.yml new file mode 100644 index 0000000000..e68bc2b6fe --- /dev/null +++ b/automated_testing/docker-compose.yml @@ -0,0 +1,33 @@ +services: + postgres: + image: postgres:15 + environment: + POSTGRES_USER: skyvern + POSTGRES_PASSWORD: skyvern + POSTGRES_DB: skyvern + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U skyvern"] + interval: 5s + retries: 5 + + skyvern: + image: ghcr.io/skyvern-ai/skyvern:latest + depends_on: + postgres: + condition: service_healthy + ports: + - "8000:8000" + env_file: + - .env + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - ./results/videos:/app/videos + - ./results/screenshots:/app/artifacts + +volumes: + pgdata: diff --git a/automated_testing/gleec-qa-architecture.md b/automated_testing/gleec-qa-architecture.md new file mode 100644 index 0000000000..d03366944e --- /dev/null +++ b/automated_testing/gleec-qa-architecture.md @@ -0,0 +1,1137 @@ +# Gleec Wallet QA Automation Architecture + +> **Skyvern + Ollama Vision-Based Testing — Consolidated Technical Reference** +> +> Komodo Platform · March 2026 · Version 1.0 + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [Architecture Overview](#2-architecture-overview) +3. [Component Breakdown](#3-component-breakdown) +4. [Infrastructure Setup](#4-infrastructure-setup) +5. [Robustness Hardening](#5-robustness-hardening) +6. [Test Case Evaluation](#6-test-case-evaluation) +7. [Automated Test Matrix](#7-automated-test-matrix) +8. [Manual Test Companion](#8-manual-test-companion) +9. [Implementation Artifacts](#9-implementation-artifacts) +10. [Execution Strategy](#10-execution-strategy) +11. [Performance Expectations](#11-performance-expectations) +12. [Risks and Limitations](#12-risks-and-limitations) + +--- + +## 1. Executive Summary + +This document is the consolidated technical reference for automating QA testing of the Gleec Wallet, a Flutter web application within the Komodo Platform ecosystem. It covers the complete architecture from infrastructure through test case design to execution strategy. + +### Problem + +Flutter web applications render their entire UI to an HTML canvas element, which makes traditional DOM-based testing tools (Selenium, Cypress, Playwright selectors) non-functional. The Gleec Wallet has 85+ manual test cases across 26 feature areas and 6 platforms, requiring approximately 52 hours of manual execution time per full regression cycle. + +### Solution + +A vision-based testing architecture using Skyvern (browser automation orchestrator) backed by Ollama running a local vision-language model (qwen2.5-vl:32b) on an RTX 5090 GPU. The system takes screenshots of the Flutter canvas, sends them to the vision model for analysis and action planning, and executes actions through Playwright. Tests are defined as natural-language prompts in a YAML matrix file. + +### Key Outcomes + +| Metric | Value | +| --------------------------------- | --------------------- | +| Manual test cases evaluated | 85 | +| Fully automatable (Grade A) | 40 (47%) | +| Partially automatable (Grade B) | 18 (21%) | +| Manual only (Grade C) | 27 (32%) | +| Automated tests in Phase 1 matrix | 43 | +| Manual companion checklist items | 36 | +| Estimated automated run time | 30–60 minutes | +| Target pass-rate stability | 90–95% | +| Hardware requirement | RTX 5090 (32 GB VRAM) | + +--- + +## 2. Architecture Overview + +The architecture is a three-layer stack designed for local GPU-accelerated execution with no cloud API dependencies. + +### 2.1 Three-Layer Design + +**Layer 1 — Ollama (native on host):** Runs the qwen2.5-vl:32b vision-language model directly on the host machine with full NVIDIA GPU access. Serves a local HTTP API on port 11434. Not containerised, to avoid Docker GPU passthrough complexity. + +**Layer 2 — Skyvern + PostgreSQL (Docker Compose):** Skyvern is the browser automation orchestrator that manages Chromium sessions via Playwright, captures screenshots, sends them to Ollama for analysis, receives action plans, and executes them. PostgreSQL stores task state and run history. Both run inside Docker with host network access to reach Ollama. + +**Layer 3 — Python Test Runner (host):** A standalone Python script that reads the test matrix YAML, iterates test cases, calls the Skyvern SDK programmatically, applies robustness hardening (retries, majority vote, checkpoints, timeout guards), and generates JSON + HTML reports. + +### 2.2 Data Flow + +The test execution flow follows this sequence: + +1. Runner reads `test_matrix.yaml` and parses test cases with their prompts, expected results, and extraction schemas. +2. Pre-flight checks validate that Ollama, Skyvern, PostgreSQL, and the Flutter app are all healthy before any tests run. +3. For each test case, the runner creates a fresh browser session and calls Skyvern's `run_task()` with the natural-language prompt. +4. Skyvern enters its vision loop: screenshot the page → send to Ollama → receive action plan → execute via Playwright → repeat until COMPLETE or step limit reached. +5. At task completion, Skyvern extracts structured data using the `extraction_schema` and returns it alongside the task status. +6. The runner applies majority vote across multiple attempts (3–5 per test) to determine the final pass/fail/flaky verdict. +7. Results are written to `results.json` and `report.html` in a timestamped run directory. + +### 2.3 System Diagram + +``` +tests/test_matrix.yaml + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ HARDENED TEST RUNNER │ +│ │ +│ ┌──────────────┐ ┌────────────────┐ ┌────────────┐ │ +│ │ Pre-flight │ │ Test Executor │ │ Post-run │ │ +│ │ Checks │ │ (per test) │ │ Analysis │ │ +│ │ │ │ │ │ │ │ +│ │ • Ollama up? │ │ • Fresh session │ │ • Majority │ │ +│ │ • VRAM ok? │ │ • Retry loop │ │ vote │ │ +│ │ • Skyvern up? │ │ • Checkpoints │ │ • Flaky │ │ +│ │ • App up? │ │ • Screenshots │ │ detect │ │ +│ │ • Model loads?│ │ • Timeout guard │ │ • Report │ │ +│ └──────┬───────┘ └───────┬─────────┘ └─────┬──────┘ │ +│ │ │ │ │ +└─────────┼───────────────────┼────────────────────┼────────┘ + ▼ ▼ ▼ + abort if fail Skyvern SDK results.json + + browser sessions report.html +``` + +### 2.4 Network Topology + +| Component | Host | Port | Protocol | +| --------------------- | -------------------------- | ----- | ----------------- | +| Ollama | Host machine (native) | 11434 | HTTP REST | +| Skyvern Server | Docker container | 8000 | HTTP REST | +| PostgreSQL | Docker container | 5432 | TCP | +| Chromium (Playwright) | Docker (inside Skyvern) | — | CDP | +| Flutter Web App | Staging server / localhost | 3000 | HTTPS/HTTP | +| Python Runner | Host machine | — | Calls Skyvern SDK | + +Docker containers reach Ollama on the host via the `host.docker.internal` alias (configured with `extra_hosts: host-gateway`). The runner communicates with Skyvern through its published port 8000 on localhost. + +--- + +## 3. Component Breakdown + +### 3.1 Ollama (Vision Model Server) + +| Setting | Value | +| ----------------- | ------------------------------------------------------------------------ | +| Primary model | qwen2.5-vl:32b (Q4 quantised) | +| Fallback model | gemma3:27b (faster, less accurate) | +| Lightweight model | qwen2.5-vl:7b (for rapid iteration) | +| VRAM usage | ~20 GB (32B Q4) / ~16 GB (27B) / ~5 GB (7B) | +| Host | http://localhost:11434 | +| Role | Vision analysis, action planning, checkpoint validation, data extraction | +| Installation | Native on host via `curl -fsSL https://ollama.com/install.sh \| sh` | + +Ollama runs outside Docker to get direct NVIDIA GPU access without container GPU passthrough complexity. The qwen2.5-vl:32b model is the primary choice because it provides the strongest vision accuracy for Flutter's canvas-rendered UI. The RTX 5090's 32 GB VRAM comfortably holds the Q4-quantised 32B model with room for KV cache. + +### 3.2 Skyvern (Browser Automation Orchestrator) + +| Setting | Value | +| ---------------------- | ------------------------------------------------------------- | +| Image | ghcr.io/skyvern-ai/skyvern:latest | +| Port | 8000 | +| Engine options | skyvern-1.0 (simple tasks) / skyvern-2.0 (complex multi-step) | +| Browser | Chromium via Playwright (headful for video, headless for CI) | +| LLM backend | Ollama via ENABLE_OLLAMA=true | +| Connection to Ollama | http://host.docker.internal:11434 | +| Max steps per run | 50 (configurable per test) | +| Browser action timeout | 10000ms | + +Skyvern orchestrates the vision-action loop: it takes a screenshot of the current browser state, sends it to the LLM with the prompt context, receives an action plan (click coordinates, text to type, scroll direction), executes the action via Playwright, and repeats. Each iteration is one "step." Tasks complete when the LLM determines the goal is met, an error is detected, or the step limit is reached. + +### 3.3 PostgreSQL (Task State Store) + +PostgreSQL 15 runs alongside Skyvern in Docker Compose. It stores task history, step-by-step screenshots, extracted data, and run metadata. No manual interaction is needed; it is managed entirely by Skyvern's internal ORM. Data is persisted in a named Docker volume (`pgdata`) across restarts. + +### 3.4 Python Test Runner + +The runner is the single orchestration point that ties everything together. It is a standalone Python 3.11+ script that uses the Skyvern Python SDK to create tasks programmatically. It reads the YAML test matrix, applies robustness hardening (pre-flight checks, retries, majority vote, timeout guards, Ollama monitoring), and writes structured results. + +--- + +## 4. Infrastructure Setup + +### 4.1 Docker Compose Configuration + +```yaml +# docker-compose.yml +services: + postgres: + image: postgres:15 + environment: + POSTGRES_USER: skyvern + POSTGRES_PASSWORD: skyvern + POSTGRES_DB: skyvern + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U skyvern"] + interval: 5s + retries: 5 + + skyvern: + image: ghcr.io/skyvern-ai/skyvern:latest + depends_on: + postgres: + condition: service_healthy + ports: + - "8000:8000" + environment: + - DATABASE_STRING=postgresql+psycopg://skyvern:skyvern@postgres:5432/skyvern + - BROWSER_TYPE=chromium-headful + - VIDEO_PATH=/app/videos + - BROWSER_ACTION_TIMEOUT_MS=10000 + - MAX_STEPS_PER_RUN=50 + - ENABLE_OLLAMA=true + - OLLAMA_SERVER_URL=http://host.docker.internal:11434 + - OLLAMA_MODEL=qwen2.5-vl:32b + - OLLAMA_SUPPORTS_VISION=true + - ENV=local + - LOG_LEVEL=INFO + - PORT=8000 + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - ./results/videos:/app/videos + - ./results/screenshots:/app/artifacts + +volumes: + pgdata: +``` + +### 4.2 Environment File + +```bash +# .env +ENV=local +ENABLE_OLLAMA=true +OLLAMA_SERVER_URL=http://host.docker.internal:11434 +OLLAMA_MODEL=qwen2.5-vl:32b +OLLAMA_SUPPORTS_VISION=true +DATABASE_STRING=postgresql+psycopg://skyvern:skyvern@postgres:5432/skyvern +BROWSER_TYPE=chromium-headful +VIDEO_PATH=/app/videos +BROWSER_ACTION_TIMEOUT_MS=10000 +MAX_STEPS_PER_RUN=50 +LOG_LEVEL=INFO +PORT=8000 +``` + +### 4.3 Setup Script + +```bash +#!/usr/bin/env bash +set -euo pipefail + +echo "=== Komodo QA Automation Setup ===" + +# 1. Install Ollama +if ! command -v ollama &> /dev/null; then + curl -fsSL https://ollama.com/install.sh | sh +fi + +# 2. Pull vision model +ollama pull qwen2.5-vl:32b + +# 3. Start Ollama server +if ! curl -sf http://localhost:11434/api/tags > /dev/null 2>&1; then + ollama serve & + sleep 3 +fi + +# 4. Create project structure +mkdir -p komodo-qa-automation/{tests,runner,results} + +# 5. Start Docker stack +cd komodo-qa-automation +docker compose up -d + +echo "Setup complete. Ollama: :11434 Skyvern: :8000" +``` + +### 4.4 Directory Structure + +``` +komodo-qa-automation/ +├── docker-compose.yml +├── .env +├── setup.sh +├── tests/ +│ ├── test_matrix.yaml # 43 automated test cases +│ └── manual_companion.yaml # 36 manual-only checklist items +├── runner/ +│ ├── __init__.py +│ ├── runner.py # Hardened main runner +│ ├── models.py # Pydantic data models +│ ├── reporter.py # HTML report generator +│ ├── preflight.py # Pre-flight health checks +│ ├── prompt_builder.py # Flutter-hardened prompt assembly +│ ├── retry.py # Majority vote logic +│ ├── guards.py # Timeout and deadlock guards +│ └── ollama_monitor.py # Background GPU/VRAM monitor +└── results/ + └── run_/ + ├── results.json + ├── report.html + └── screenshots/ +``` + +### 4.5 Python Dependencies + +```text +# requirements.txt +skyvern>=1.0.0 +pyyaml>=6.0 +pydantic>=2.4.0 +httpx>=0.27.0 +``` + +--- + +## 5. Robustness Hardening + +Vision-based testing with LLMs is inherently non-deterministic. The following ten strategies address the primary failure modes. + +### 5.1 Known Fragility Points + +| # | Problem | Severity | Root Cause | +| --- | --------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------- | +| 1 | Vision model hallucination | **Critical** | LLM identifies UI elements that don't exist, or clicks wrong ones. No DOM fallback on Flutter canvas. | +| 2 | Non-deterministic outputs | **High** | Same prompt + same page produces different actions across runs due to LLM stochasticity. | +| 3 | Flutter async rendering | **High** | Skyvern screenshots mid-render, acts on incomplete frame while Flutter rebuilds widgets. | +| 4 | Multi-step state corruption | **Critical** | Step N fails silently (wrong click), but subsequent steps execute against wrong state, producing misleading PASS. | +| 5 | No test isolation | **High** | Test B inherits leftover state from Test A (open modals, changed settings, navigation position). | +| 6 | Login session expiry | **Medium** | Setup logs in, but by test 15 the session has timed out. | +| 7 | Ambiguous completion | **High** | Skyvern cannot distinguish successful completion from dead-end abandonment. | +| 8 | Flaky pass/fail | **High** | Test passes 3/5 times. Single-run result is unreliable. | +| 9 | No visual baseline | **Medium** | Assertions rely on LLM judgment, not pixel-level comparison with known-good screenshots. | +| 10 | Silent Ollama failures | **Medium** | Ollama OOMs, truncates responses, or times out. Skyvern may not surface this cleanly. | + +### 5.2 Mitigation Strategies + +**1. Pre-flight Health Checks:** Before running any tests, the runner validates: Ollama is responding (HTTP + actual inference test), VRAM has >15 GB free, Skyvern server responds on :8000, and the Flutter app is reachable. If any check fails, the run aborts with exit code 2. This prevents wasting time on tests that would all fail due to infrastructure issues. + +```python +# runner/preflight.py + +async def check_ollama(url: str = "http://localhost:11434") -> bool: + """Verify Ollama is running and the model is loaded.""" + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(f"{url}/api/tags") + models = resp.json().get("models", []) + return len(models) > 0 + +async def check_ollama_inference(url: str = "http://localhost:11434", + model: str = "qwen2.5-vl:32b") -> bool: + """Actually run a trivial inference to confirm GPU works.""" + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post(f"{url}/api/generate", json={ + "model": model, + "prompt": "Reply with only the word OK.", + "stream": False, + }) + output = resp.json().get("response", "").strip() + return "ok" in output.lower() + +async def check_vram() -> bool: + """Verify sufficient VRAM is free (>15 GB for 32B Q4 model).""" + result = subprocess.run( + ["nvidia-smi", "--query-gpu=memory.free", "--format=csv,noheader,nounits"], + capture_output=True, text=True, timeout=5, + ) + free_gb = int(result.stdout.strip().split("\n")[0]) / 1024 + return free_gb > 15 + +async def check_skyvern(url: str = "http://localhost:8000") -> bool: + """Verify Skyvern server responds.""" + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(f"{url}/api/v1/heartbeat") + return resp.status_code == 200 + +async def check_app(url: str) -> bool: + """Verify the Flutter app is reachable.""" + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + resp = await client.get(url) + return resp.status_code < 500 + +async def run_preflight(config: dict) -> bool: + """Run all pre-flight checks. Returns False if any critical check fails.""" + results = await asyncio.gather( + check_ollama(), check_vram(), check_skyvern(), check_app(config["base_url"]), + ) + all_ok = all(results) + if all_ok: + all_ok = await check_ollama_inference() + return all_ok +``` + +**2. Test Isolation via Fresh Browser Sessions:** Each test case gets its own fresh browser session with no shared cookies, storage, or navigation history. The setup prompt (login) is embedded directly into each test's prompt so login and test execute in the same session, eliminating the session-handoff problem. + +```python +# In runner.py — each run_task() creates an isolated browser: + +async def run_isolated_test(skyvern, test, config, setup_config): + full_prompt = "" + if setup_config: + full_prompt += f"PHASE 1 — SETUP:\n{setup_config['prompt']}\n\n" + full_prompt += "After setup is complete, proceed immediately to Phase 2.\n\n" + full_prompt += "PHASE 2 — TEST:\n" + full_prompt += test.prompt + + task = await skyvern.run_task( + url=config["base_url"], + prompt=full_prompt, + engine=config.get("default_engine", "skyvern-2.0"), + max_steps=test.max_steps or 25, + data_extraction_schema=test.extraction_schema, + wait_for_completion=True, + # Each run_task gets its own browser — no shared state + ) + return task +``` + +**3. Retry with Majority Vote:** Every test runs 3 times (5 times for critical-tagged tests). The final verdict is determined by majority vote across attempts. If 2/3 pass, the test is PASS. If results are split (1 pass, 1 fail, 1 error), the test is marked FLAKY. This is the single most important robustness measure for dealing with LLM non-determinism. + +```python +# runner/retry.py + +@dataclass +class VotedResult: + test_id: str + final_status: str # PASS | FAIL | FLAKY | ERROR + vote_counts: dict # {"PASS": 2, "FAIL": 1} + confidence: float # 0.0–1.0 + attempts: list + +def majority_vote(attempts: list, test) -> VotedResult: + """ + Rules: + - If ALL attempts agree → that status, confidence 1.0 + - If majority agrees → that status, confidence = majority/total + - If no majority → FLAKY, confidence = max_count/total + - If all ERROR → ERROR + """ + statuses = [a.status for a in attempts] + counts = Counter(statuses) + total = len(attempts) + most_common_status, most_common_count = counts.most_common(1)[0] + + if most_common_count > total / 2: + final_status = most_common_status + confidence = most_common_count / total + else: + final_status = "FLAKY" + confidence = most_common_count / total + + # If "winner" is ERROR but there are PASS/FAIL, prefer those + if final_status == "ERROR": + non_errors = [s for s in statuses if s != "ERROR"] + if non_errors: + final_status = "FLAKY" + + return VotedResult( + test_id=test.id, + final_status=final_status, + vote_counts=dict(counts), + confidence=round(confidence, 2), + attempts=attempts, + ) +``` + +**4. Flutter Render Wait Strategy:** A Flutter preamble is automatically prepended to every prompt instructing the vision model to wait 2–3 seconds for canvas rendering, look for loading spinners before acting, and handle blank-screen initialization delays. This prevents acting on incomplete frames. + +```python +# runner/prompt_builder.py + +FLUTTER_PREAMBLE = """IMPORTANT CONTEXT: +This is a Flutter web application rendered entirely on an HTML canvas element. +You cannot use DOM selectors — you must identify all elements visually. + +Before taking any action on each new screen: +1. Wait 2 seconds for the page to fully render (Flutter animations to complete). +2. If you see a loading spinner, circular progress indicator, or skeleton + placeholders, wait until they disappear before proceeding. +3. If the screen appears blank or only shows a solid color, wait 3 more + seconds — Flutter may still be initialising. + +If you are unsure whether an element is a button or just text, look for +visual cues: rounded corners, drop shadows, background color contrast, +or iconography that suggests interactivity. +""" + +COMPLETION_SUFFIX = """ +After completing the task, clearly state whether you succeeded or encountered +an error. If you see an error message, snackbar, or alert dialog on screen, +report its exact text in your response.""" + +def build_prompt(test_prompt: str, setup_prompt: str | None = None) -> str: + parts = [FLUTTER_PREAMBLE] + if setup_prompt: + parts.append(f"PHASE 1 — SETUP:\n{setup_prompt}\n") + parts.append("After setup is complete, proceed to Phase 2.\n") + parts.append(f"PHASE 2 — TEST:\n{test_prompt}") + else: + parts.append(test_prompt) + parts.append(COMPLETION_SUFFIX) + return "\n".join(parts) +``` + +**5. Checkpoint Assertions (Mid-Flow Validation):** Complex multi-step tests include checkpoint verification between steps. Each checkpoint is a visual assertion ("the send form is visible with recipient and amount fields"). If a checkpoint fails, the test aborts immediately instead of continuing against wrong state, preventing cascading false results. + +```python +# runner/prompt_builder.py (addition) + +def build_stepped_prompt(steps: list[dict]) -> str: + """Convert checkpoint-based steps into a single sequential prompt.""" + lines = [] + for i, step in enumerate(steps, 1): + lines.append(f"STEP {i}: {step['action']}") + if step.get("checkpoint"): + lines.append( + f" → BEFORE proceeding to step {i+1}, verify: {step['checkpoint']}") + lines.append( + f" → If this verification FAILS, STOP and report which step failed and why.") + lines.append("") + return "\n".join(lines) +``` + +**6. Timeout and Deadlock Guards:** Every Skyvern task call is wrapped in an `asyncio.wait_for()` with a configurable timeout (default 180 seconds). If the vision model hangs, the browser deadlocks, or Ollama stalls, the test is terminated and marked ERROR with a diagnostic message. + +```python +# runner/guards.py + +class TestTimeoutError(Exception): + pass + +async def run_with_timeout(coro, seconds: int, test_id: str): + """Run a coroutine with a hard timeout.""" + try: + return await asyncio.wait_for(coro, timeout=seconds) + except asyncio.TimeoutError: + raise TestTimeoutError( + f"Test {test_id} timed out after {seconds}s." + ) +``` + +**7. Background Ollama Health Monitor:** A background asyncio task polls `nvidia-smi` and Ollama's HTTP endpoint every 10 seconds during the run. It checks VRAM free (abort if <500 MB), GPU temperature (warn if >90°C), and Ollama API responsiveness. If Ollama becomes unhealthy mid-run, subsequent tests are immediately marked ERROR with the specific failure reason. + +```python +# runner/ollama_monitor.py + +class OllamaMonitor: + def __init__(self, ollama_url: str = "http://localhost:11434"): + self.url = ollama_url + self._running = False + self.last_error = None + + async def start(self): + self._running = True + self._task = asyncio.create_task(self._monitor_loop()) + + @property + def healthy(self) -> bool: + return self.last_error is None + + async def _monitor_loop(self): + while self._running: + try: + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.get(f"{self.url}/api/tags") + if resp.status_code != 200: + self.last_error = f"Ollama returned HTTP {resp.status_code}" + else: + self.last_error = None + + result = subprocess.run( + ["nvidia-smi", "--query-gpu=memory.free,memory.used,temperature.gpu", + "--format=csv,noheader,nounits"], + capture_output=True, text=True, timeout=5, + ) + parts = result.stdout.strip().split(", ") + if len(parts) >= 3: + free_mb, used_mb, temp_c = int(parts[0]), int(parts[1]), int(parts[2]) + if free_mb < 500: + self.last_error = f"VRAM critically low: {free_mb}MB free" + elif temp_c > 90: + self.last_error = f"GPU temperature critical: {temp_c}°C" + except Exception as e: + self.last_error = f"Monitor error: {e}" + + await asyncio.sleep(10) +``` + +**8. Hardened Prompt Construction:** All prompts are built through a `prompt_builder` module that automatically adds the Flutter preamble, structures multi-phase prompts (setup + test), injects checkpoint verification language, and appends a completion suffix requesting explicit success/error reporting. + +**9. Early Exit Optimisation:** If the first 2 attempts both pass, remaining retries are skipped. If all attempts so far are ERROR (infrastructure issue), retries stop early. This reduces total run time by 30–40% for stable tests while preserving full retry coverage for flaky ones. + +```python +# Inside run_test_with_retries(): + +attempts = [] +for i in range(num_attempts): + result = await execute_single_attempt(skyvern, test, config, setup_config, monitor) + attempts.append(result) + + # Early exit: if first 2 attempts both pass, skip remaining + if len(attempts) >= 2: + pass_count = sum(1 for a in attempts if a.status == "PASS") + if pass_count >= 2: + break + + # Early exit: if all attempts so far are ERROR (infra issue), stop + if all(a.status == "ERROR" for a in attempts) and len(attempts) >= 2: + break +``` + +**10. Structured Exit Codes for CI:** Exit code 0 = all passed. Exit code 1 = failures or errors. Exit code 2 = pre-flight failure (infrastructure). Exit code 3 = all tests passed but some were flaky. This enables CI pipelines to distinguish between test failures, infra failures, and instability. + +| Code | Meaning | +| ---- | ------------------------------------------------------------------ | +| `0` | All tests passed | +| `1` | One or more tests failed or errored | +| `2` | Pre-flight checks failed (infrastructure issue) | +| `3` | All tests passed but some were flaky (inconsistent across retries) | + +--- + +## 6. Test Case Evaluation + +All 85 test cases from `GLEEC_WALLET_MANUAL_TEST_CASES.md` were evaluated for automation suitability with the Skyvern + Ollama stack. + +### 6.1 Classification Framework + +| Grade | Meaning | Count | % | Action | +| ----- | -------------------------------------------------------------------- | ----- | --- | -------------------------------------------- | +| **A** | Fully automatable — pure UI interaction within a web browser | 40 | 47% | Convert to Skyvern prompt | +| **B** | Partially automatable — some steps need human/external action | 18 | 21% | Split: automate UI, flag manual verification | +| **C** | Manual only — requires hardware, OS actions, network, cross-platform | 27 | 32% | Keep in manual checklist | + +### 6.2 Full Classification Table + +| Test ID | Module | Title | Grade | Reason | +| --------- | ------------------- | ------------------------------------- | ----- | ------------------------------------------------------------------------------------------- | +| AUTH-001 | Auth | Create wallet with seed backup | **A** | UI-only flow: tap, enter password, navigate seed screens | +| AUTH-002 | Auth | Login/logout with remember-session | **B** | Login/logout automatable; "close and relaunch app" requires session restart outside Skyvern | +| AUTH-003 | Auth | Import wallet from seed | **A** | UI-only: enter seed, set password, verify balances | +| AUTH-004 | Auth | Invalid password attempts + lockout | **A** | UI-only: enter wrong passwords, observe lockout messages | +| AUTH-005 | Auth | Trezor hardware wallet | **C** | Requires physical Trezor device connected via USB | +| WAL-001 | Wallet Manager | Create/rename/switch wallets | **A** | Pure UI interactions within wallet management | +| WAL-002 | Wallet Manager | Delete wallet with confirmation | **A** | UI dialog flow | +| WAL-003 | Wallet Manager | Selection persistence after restart | **C** | Requires app restart | +| COIN-001 | Coin Manager | Enable DOC/MARTY test coins | **A** | Toggle coins in settings | +| COIN-002 | Coin Manager | Search and activate coins | **A** | Search UI, toggle activation | +| COIN-003 | Coin Manager | Deactivate coin with balance | **A** | Balance warning dialog, deactivation flow | +| DASH-001 | Dashboard | Hide balances / zero balance toggles | **A** | Toggle switches, verify UI changes | +| DASH-002 | Dashboard | Offline indicator | **C** | Requires network disconnection at OS level | +| DASH-003 | Dashboard | Dashboard persistence after restart | **C** | Requires app restart | +| SEND-001 | Send | Faucet funding | **A** | Navigate to faucet, request funds, verify balance | +| SEND-002 | Send | Faucet cooldown | **B** | Faucet automatable; network error fallback requires network toggle | +| SEND-003 | Send | Send DOC happy path | **A** | Fill send form, confirm, verify status | +| SEND-004 | Send | Address validation | **A** | Enter invalid addresses, observe error messages | +| SEND-005 | Send | Amount boundary testing | **A** | Enter boundary amounts, observe validation | +| SEND-006 | Send | Interrupted send (network kill) | **C** | Requires network interruption mid-transaction | +| DEX-001 | DEX | Create maker order | **A** | Fill order form, submit, verify | +| DEX-002 | DEX | Taker order | **B** | Depends on market liquidity availability | +| DEX-003 | DEX | Input validation | **A** | Enter invalid values, observe errors | +| DEX-004 | DEX | Partial fill behaviour | **B** | Depends on market conditions | +| DEX-005 | DEX | History export | **B** | UI automatable; file verification requires filesystem access | +| DEX-006 | DEX | Recovery after closure + network | **C** | Requires app closure and network manipulation | +| BRDG-001 | Bridge | Bridge transfer happy path | **A** | Fill bridge form, submit, verify | +| BRDG-002 | Bridge | Unsupported pair handling | **A** | Select unsupported pair, observe error | +| BRDG-003 | Bridge | Amount boundaries | **A** | Enter boundary amounts, verify validation | +| BRDG-004 | Bridge | Bridge failure (network) | **C** | Requires network interruption | +| NFT-001 | NFT | List and detail view | **A** | Navigate NFT section, browse items | +| NFT-002 | NFT | Send NFT | **A** | Fill send form, confirm transfer | +| NFT-003 | NFT | Send failure handling | **A** | Trigger failure, observe error UI | +| SET-001 | Settings | Persistence after restart | **C** | Requires app restart | +| SET-002 | Settings | Privacy toggles | **A** | Toggle settings, verify UI changes | +| SET-003 | Settings | Test coin toggle impact | **A** | Toggle, verify coin visibility | +| SET-004 | Settings | Settings persistence (logout/restart) | **C** | Requires logout and restart | +| BOT-001 | Bot | Create and start market maker | **A** | Fill bot config, start, verify running | +| BOT-002 | Bot | Bot validation (invalid params) | **A** | Enter invalid params, observe errors | +| NAV-001 | Navigation | Route integrity | **A** | Navigate all routes, verify loading | +| NAV-002 | Navigation | Deep link while logged out | **C** | Requires direct URL manipulation | +| NAV-003 | Navigation | Unsaved changes warning | **A** | Make changes, attempt navigation, verify warning | +| RESP-001 | Responsive | Breakpoint behaviour | **C** | Requires controlled window resizing | +| RESP-002 | Responsive | Orientation change | **C** | Requires device rotation | +| XPLAT-001 | Cross-platform | Feature parity | **C** | Requires Android/iOS/macOS/Linux/Windows | +| XPLAT-002 | Cross-platform | Permission dialogs | **C** | Requires OS-level permission dialogs | +| A11Y-001 | Accessibility | Keyboard navigation | **C** | Requires focus state inspection | +| A11Y-002 | Accessibility | Screen reader | **C** | Requires screen reader output analysis | +| A11Y-003 | Accessibility | Contrast and scaling | **C** | Requires pixel-level measurement | +| SEC-001 | Security | Seed phrase reveal | **B** | Reveal automatable; screenshot masking verification is manual | +| SEC-002 | Security | Auto-lock timeout | **C** | Requires idle timeout + app-switcher | +| SEC-003 | Security | Clipboard clearing | **C** | Requires clipboard monitoring outside browser | +| ERR-001 | Error Handling | Network outage recovery | **C** | Requires network toggling | +| ERR-002 | Error Handling | Partial failure | **C** | Requires selective network failure | +| ERR-003 | Error Handling | Stale state after closure | **C** | Requires app closure | +| L10N-001 | Localization | Translation completeness | **A** | Switch locale, verify text rendering | +| L10N-002 | Localization | Long string overflow | **B** | Visual clipping judgment is low-confidence for LLM | +| L10N-003 | Localization | Locale-specific formats | **A** | Switch locale, verify date/number formats | +| FIAT-001 | Fiat | Fiat menu access | **A** | Navigate to fiat section | +| FIAT-002 | Fiat | Form validation | **A** | Enter invalid data, observe errors | +| FIAT-003 | Fiat | Provider checkout | **B** | Provider webview may cross domain boundaries | +| FIAT-004 | Fiat | Checkout closed/cancelled | **B** | Manual closure detection | +| FIAT-005 | Fiat | Fiat after logout/login | **C** | Requires logout and re-login | +| SUP-001 | Support | Support page access | **A** | Navigate to support section | +| FEED-001 | Feedback | Feedback entry | **A** | Open feedback form, submit | +| SECX-001 | Security (Extended) | Private key export | **B** | Export automatable; download/share may cross browser boundary | +| SECX-002 | Security (Extended) | Seed backup verification | **A** | View seed, confirm backup flow | +| SECX-003 | Security (Extended) | Unban pubkeys | **A** | Navigate to pubkey management, unban | +| SECX-004 | Security (Extended) | Change password | **A** | Enter old/new password, confirm | +| SETX-001 | Advanced Settings | Weak password toggle | **A** | Toggle setting, verify effect | +| SETX-002 | Advanced Settings | Bot toggles | **B** | Stop-on-disable verification depends on running bot | +| SETX-003 | Advanced Settings | Export/import JSON | **C** | Filesystem operation | +| SETX-004 | Advanced Settings | Show swap data | **B** | Export is filesystem operation | +| SETX-005 | Advanced Settings | Import swaps JSON | **C** | Requires paste from external source | +| SETX-006 | Advanced Settings | Download logs | **C** | Filesystem download | +| SETX-007 | Advanced Settings | Reset coins to default | **A** | Trigger reset, verify coin list | +| WALX-001 | Wallet (Extended) | Overview cards | **A** | Verify wallet cards display | +| WALX-002 | Wallet (Extended) | Tabs (logged-out fallback) | **B** | Logged-out fallback needs logout | +| WADDR-001 | Wallet Addresses | Multi-address display | **A** | View address list | +| WADDR-002 | Wallet Addresses | Create new address | **A** | Generate address, verify display | +| CTOK-001 | Custom Token | Import ERC-20 token | **A** | Enter contract address, import | +| CTOK-002 | Custom Token | Invalid contract handling | **A** | Enter invalid contract, observe error | +| CTOK-003 | Custom Token | Back/cancel from import | **A** | Navigate away, verify no side effects | +| GATE-001 | Feature Gating | Trading-disabled tooltips | **A** | Verify disabled state indicators | +| GATE-002 | Feature Gating | Hardware wallet restrictions | **C** | Requires connected hardware wallet | +| GATE-003 | Feature Gating | NFT disabled state | **A** | Verify NFT section disabled UI | +| CDET-001 | Coin Detail | Address display | **B** | Display automatable; clipboard/explorer verification manual | +| CDET-002 | Coin Detail | Transaction list | **B** | List automatable; pending→confirmed needs real chain time | +| CDET-003 | Coin Detail | Price chart | **B** | Chart automatable; offline fallback needs network toggle | +| RWD-001 | Rewards | Rewards claim | **B** | Claim depends on reward availability | +| BREF-001 | Bitrefill | Bitrefill widget | **B** | Widget crosses domain boundaries | +| ZHTL-001 | ZHTLC | ZHTLC activation | **B** | Logout-during-activation is manual | +| QLOG-001 | Quick Login | Remember-me persistence | **C** | Requires app relaunch | +| WARN-001 | Warnings | Clock warning banner | **C** | Requires system clock manipulation | + +### 6.3 Why Grade C Tests Cannot Be Automated + +The 27 Grade C tests fall into these categories, none of which Skyvern can handle: + +**Hardware wallet (2 tests):** GW-MAN-AUTH-005, GW-MAN-GATE-002 require a physical Trezor device connected via USB. The vision model cannot interact with external hardware. + +**Network manipulation (7 tests):** DASH-002, SEND-006, DEX-006, BRDG-004, ERR-001/002/003 require disabling/re-enabling the network at the OS level, which is outside the browser sandbox. + +**App lifecycle (7 tests):** AUTH-002b, WAL-003, DASH-003, SET-004, FIAT-005, QLOG-001, NAV-002 require closing and relaunching the application, which destroys the Skyvern browser session. + +**Cross-platform + responsive (4 tests):** XPLAT-001/002, RESP-001/002 require execution on Android, iOS, macOS, Linux, and Windows native apps, or controlled window resizing that Skyvern cannot reliably perform. + +**Accessibility (3 tests):** A11Y-001/002/003 require keyboard-only navigation focus inspection, screen reader output analysis, and pixel-level contrast measurement. + +**Security/privacy (3 tests):** SEC-002/003, WARN-001 require app-switcher snapshot inspection, clipboard monitoring, and system clock manipulation. + +**Filesystem operations (3 tests):** SETX-003/005/006 require file export/import and log downloading outside the browser context. + +### 6.4 Structural Gaps in the Manual Document + +Beyond per-case suitability, these structural issues in the original document prevent direct conversion to automation: + +**Compound test cases:** A single manual test case often covers 4–6 distinct scenarios. For example, AUTH-001 tests creation, password entry, seed skip attempt, seed confirmation, and onboarding completion. For vision-based agents, this must be split into 2–3 atomic tasks to prevent state corruption at one step from cascading through the rest. + +**No visual element descriptions:** Every manual case says "Open DEX" or "Enter amount" without describing what the element looks like. Skyvern needs "Click the input field labeled Amount below the recipient address, with a coin ticker next to it." + +**Abstract expected results:** "Validation blocks invalid orders with guidance" is not machine-evaluable. The automation needs: "A red error message appears containing the word invalid, insufficient, or minimum." + +**No inline test data:** Cases reference AS-01, AM-03, WP-02 by code. The automation prompt must contain the actual address string, amount value, and seed phrase inline. + +**Missing dependency graph:** Many tests assume DOC/MARTY are funded (from SEND-001) without declaring this dependency. The automation needs explicit execution ordering. + +--- + +## 7. Automated Test Matrix + +43 test cases converted to Skyvern-compatible prompts with visual descriptions, checkpoint assertions, extraction schemas, and inline test data. The full YAML is provided as a companion file (`test_matrix.yaml`); this section summarises the structure and execution phases. + +### 7.1 Execution Phases (Dependency Order) + +| Phase | Tests | Purpose | Tags | +| ------------------ | -------------------------------------------------------------- | -------------------------------------------------------------- | -------------------- | +| 1. Auth + Wallet | AUTH-001a/b, AUTH-003, AUTH-004, WAL-001, WAL-002 | Establish wallet creation, import, login, wallet management | auth, critical, p0 | +| 2. Coin Management | COIN-001, COIN-002, DASH-001 | Enable DOC/MARTY, verify dashboard toggles | coin, p1 | +| 3. Faucet Funding | SEND-001, SEND-002a | Fund wallets with test coins via in-app faucet | prerequisite, p0 | +| 4. Send/Withdraw | SEND-003, SEND-004, SEND-005 | DOC send happy path, address validation, amount boundaries | send, critical, p0 | +| 5. DEX | DEX-001, DEX-003 | Maker order creation, input validation | dex, critical, p0 | +| 6. Bridge | BRDG-001, BRDG-002, BRDG-003 | Bridge transfer, unsupported pairs, boundaries | bridge, critical, p0 | +| 7. NFT | NFT-001 | NFT list/detail view (if enabled) | nft, p1 | +| 8. Settings | SET-002, SET-003, NAV-001, NAV-003 | Privacy toggles, test coin impact, navigation, unsaved changes | settings, p1 | +| 9. Bot | BOT-001, BOT-002 | Market maker bot creation, validation | bot, p1 | +| 10. Fiat | FIAT-001, FIAT-002 | Fiat access, form validation | fiat, p0 | +| 11. Security | SECX-002, SECX-003, SECX-004 | Seed backup, unban pubkeys, password change | security, p0 | +| 12. Custom Token | CTOK-001, CTOK-002 | Token import, error handling | custom_token, p1 | +| 13. Localization | L10N-001 | Translation completeness check | l10n, p2 | +| 14. Feature Gating | GATE-001, GATE-003 | Disabled feature tooltips, NFT gate | gating, p1 | +| 15. Support/Misc | SUP-001, FEED-001, SETX-001, SETX-007, WALX-001, WADDR-001/002 | Support, feedback, advanced settings, addresses | p2 | + +### 7.2 Test Case Format + +Each automated test case in `test_matrix.yaml` follows this structure: + +**id:** Unique identifier prefixed `GW-AUTO-` to distinguish from manual IDs. + +**source_manual_id:** Maps back to the original manual test case ID for traceability. + +**tags:** Array of tags for filtering (smoke, critical, p0/p1/p2, module name). + +**steps:** Ordered list of action + checkpoint pairs. Each action describes what to do visually; each checkpoint describes what must be true before proceeding. + +**prompt:** Alternative to steps for simpler tests — a single natural-language prompt. + +**expected_result:** Human-readable expected outcome for the report. + +**extraction_schema:** JSON Schema defining structured data to extract from the final screen state. Used by Skyvern to return machine-comparable fields. + +**max_steps / timeout:** Safety limits per test case (default 30 steps, 180 seconds). + +### 7.3 Example Test Case + +```yaml +- id: GW-AUTO-SEND-003 + name: "Send DOC happy path" + source_manual_id: GW-MAN-SEND-003 + tags: [send, critical, p0, smoke] + timeout: 240 + steps: + - action: > + Navigate to the wallet or coin that holds DOC. Look for 'DOC' or + 'Document' in your wallet/coin list and click on it. + checkpoint: "The DOC coin detail screen is visible with a balance > 0." + + - action: > + Click the 'Send' or 'Withdraw' button. It may appear as an icon + with an upward arrow or the word 'Send'. + checkpoint: "A send form is visible with fields for recipient address and amount." + + - action: > + Enter the recipient address 'RReplaceMeWithValidDOCAddress' into + the address/recipient field. Enter '0.001' into the amount field. + checkpoint: "Both fields are filled. No error messages are shown." + + - action: > + Click the 'Send', 'Confirm', or 'Submit' button to initiate the + transaction. If a confirmation dialog appears, confirm it. + checkpoint: "A success message, pending indicator, or transaction hash is displayed." + + expected_result: "Transaction submitted; fee and amount match; status is Pending or Confirmed." + extraction_schema: + type: object + properties: + transaction_submitted: + type: boolean + success_or_pending_message: + type: string + fee_displayed: + type: string + transaction_hash: + type: string +``` + +### 7.4 Regression Pack Filtering + +```bash +# Smoke pack (fastest gate check) +python runner/runner.py --tag smoke + +# Critical money-movement tests +python runner/runner.py --tag critical + +# P0 only (highest priority) +python runner/runner.py --tag p0 + +# Full automated suite +python runner/runner.py +``` + +--- + +## 8. Manual Test Companion + +36 test items that must remain manual. Provided as `manual_companion.yaml` — a structured pass/fail checklist that runs alongside the automated suite for full coverage. + +### 8.1 Categories + +| Category | Count | Examples | +| ------------------------------ | ----- | ---------------------------------------------------------------- | +| Hardware wallet (Trezor) | 2 | Connect/sign, restricted modules | +| Network manipulation | 7 | Offline indicators, interrupted transactions, recovery | +| App lifecycle/restart | 7 | Session persistence, settings retention, quick-login | +| Cross-platform + responsive | 4 | Multi-platform parity, breakpoint behaviour, orientation | +| Accessibility | 3 | Keyboard nav, screen reader, contrast/scaling | +| Security/privacy (OS-level) | 3 | Auto-lock, app-switcher, clipboard | +| Filesystem operations | 3 | Export/import JSON, download logs | +| Deep link / clock manipulation | 2 | Auth gating on deep links, clock warning banner | +| Grade-B manual verification | 5 | Clipboard checks, explorer links, provider webview, export files | + +Together, the 43 automated tests and 36 manual checklist items cover the full scope of the original 85 manual test cases with no gaps. + +--- + +## 9. Implementation Artifacts + +The complete runner consists of 7 Python modules plus the YAML test matrix. + +### 9.1 Data Models (`models.py`) + +```python +from pydantic import BaseModel +from typing import Optional + +class TestCase(BaseModel): + id: str + name: str + tags: list[str] = [] + prompt: str = "" + steps: Optional[list[dict]] = None + expected_result: str + extraction_schema: Optional[dict] = None + max_steps: Optional[int] = None + timeout: Optional[int] = None + source_manual_id: Optional[str] = None + +class TestResult(BaseModel): + test_id: str + test_name: str + tags: list[str] + status: str # PASS | FAIL | ERROR | SKIP + skyvern_status: str + expected: str + extracted_data: Optional[dict | str] = None + duration_seconds: float + run_id: Optional[str] = None + error: Optional[str] = None + +class TestRun(BaseModel): + timestamp: str + base_url: str + total: int + passed: int + failed: int + errors: int + skipped: int + flaky: int = 0 + results: list[TestResult] + voted_results: list[dict] = [] +``` + +### 9.2 Pre-flight Checks (`preflight.py`) + +Validates Ollama responsiveness and inference, VRAM availability (>15 GB free), Skyvern HTTP health, and Flutter app reachability. Returns `False` on any failure, causing the runner to abort with exit code 2. Full implementation in Section 5.2. + +### 9.3 Prompt Builder (`prompt_builder.py`) + +Automatically prepends the Flutter render-wait preamble to every prompt, converts checkpoint-based step lists into sequential prompts with verification gates, and appends a completion suffix requesting explicit success/error reporting. Full implementation in Section 5.2. + +### 9.4 Majority Vote (`retry.py`) + +Runs each test N times and determines the final verdict. All-agree = that status at 100% confidence. Majority-agree = that status at majority/total confidence. No majority = FLAKY. All-ERROR with some non-error = FLAKY. Includes early exit: skip remaining retries if first 2 pass, or stop early if all attempts are ERROR. Full implementation in Section 5.2. + +### 9.5 Timeout Guards (`guards.py`) + +Wraps every Skyvern task call in an `asyncio.wait_for()` with a configurable timeout (default 180 seconds). Raises `TestTimeoutError` with a diagnostic message including the test ID and timeout value, allowing the runner to log the issue and continue to the next test. Full implementation in Section 5.2. + +### 9.6 Ollama Monitor (`ollama_monitor.py`) + +Background asyncio task polling every 10 seconds: checks Ollama HTTP endpoint, nvidia-smi VRAM free/used/temperature. Flags unhealthy if VRAM < 500 MB free, temperature > 90°C, or Ollama stops responding. The runner checks `monitor.healthy` before each test attempt. Full implementation in Section 5.2. + +### 9.7 Hardened Runner (`runner.py`) + +The main orchestration script. Loads the YAML matrix, applies tag filtering, runs pre-flight checks, starts the Ollama monitor, iterates tests with retry+majority-vote, applies early exit optimisation, and writes results. + +```python +# runner/runner.py (hardened version) — key structure + +async def main(matrix_path: str, tag_filter: str = None, single: bool = False): + matrix = load_matrix(matrix_path) + config = matrix["config"] + + # Pre-flight + if not await run_preflight(config): + sys.exit(2) + + # Filter tests + tests = [TestCase(**t) for t in matrix["tests"]] + if tag_filter: + tests = [t for t in tests if tag_filter in t.tags] + + # Start infrastructure + skyvern = Skyvern(base_url="http://localhost:8000", api_key="local") + monitor = OllamaMonitor() + await monitor.start() + + # Execute with retries + voted_results = [] + for test in tests: + voted = await run_test_with_retries(skyvern, test, config, setup, monitor) + voted_results.append(voted) + + await monitor.stop() + + # Write results + run_dir = Path(f"results/run_{timestamp}") + run_dir.mkdir(parents=True, exist_ok=True) + (run_dir / "results.json").write_text(json.dumps(results, indent=2)) + generate_html_report(run, run_dir / "report.html") + + # Exit codes + if failed > 0 or errors > 0: + sys.exit(1) + elif flaky > 0: + sys.exit(3) + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--matrix", default="tests/test_matrix.yaml") + parser.add_argument("--tag", default=None) + parser.add_argument("--single", action="store_true") + args = parser.parse_args() + asyncio.run(main(args.matrix, args.tag, args.single)) +``` + +### 9.8 HTML Reporter (`reporter.py`) + +Generates a styled dark-theme HTML report with summary statistics (total, passed, failed, errors, flaky, pass rate) and a results table showing test ID, name, tags, status with colour coding, duration, extracted data preview, and error messages. Saved alongside `results.json` in each timestamped run directory. + +--- + +## 10. Execution Strategy + +### 10.1 Phased Rollout + +**Week 1–2 (Infrastructure):** Run `setup.sh`. Validate Ollama + Skyvern connectivity. Execute a single trivial test (navigate to dashboard, verify it loads) to confirm the vision loop works end-to-end. + +**Week 3–4 (Smoke Suite):** Run the smoke-tagged subset (7–8 tests). Tune prompts and timeouts based on actual Skyvern + Ollama behaviour. Establish baseline pass rate. Target: >90% stability before expanding. + +**Week 5–6 (Critical Suite):** Add critical-tagged tests (money movement: send, DEX, bridge). These are the highest-value automated checks. Tune retry counts and checkpoint language. + +**Week 7–8 (Full Suite):** Enable all 43 automated tests. Run in CI on every staging deployment. Measure flaky rate and triage unstable tests. + +**Ongoing:** Expand manual companion tests to automation as Flutter rendering stabilises and Skyvern capabilities evolve. Target: increase Grade A percentage from 47% to 60%+ over 6 months. + +### 10.2 CI Integration + +```bash +#!/usr/bin/env bash +# ci-pipeline.sh + +# Start infrastructure +ollama serve & +docker compose up -d +sleep 10 + +# Run smoke gate +python runner/runner.py --tag smoke --single +SMOKE_EXIT=$? + +if [ $SMOKE_EXIT -ne 0 ]; then + echo "SMOKE GATE FAILED — blocking deployment" + exit 1 +fi + +# Run full suite with retries +python runner/runner.py --matrix tests/test_matrix.yaml +FULL_EXIT=$? + +# Upload report as artifact +cp results/run_*/report.html $CI_ARTIFACTS_DIR/ + +exit $FULL_EXIT +``` + +### 10.3 Tag Filtering Strategy + +| Scenario | Command | Tests | Time | +| ---------------------- | ---------------------- | ----- | --------- | +| Pre-merge gate | `--tag smoke` | ~8 | 5–10 min | +| Nightly regression | `--tag critical` | ~20 | 15–30 min | +| Full weekly regression | (no filter) | 43 | 30–60 min | +| Quick infra check | `--tag smoke --single` | ~8 | 3–5 min | + +### 10.4 Test Data Population + +Before running the suite, the `test_data` section in `test_matrix.yaml` must be populated with actual QA environment values: + +| Key | Description | Example | +| ------------------------- | ---------------------------- | --------------------------- | +| `wallet_password` | QA environment password | `TestPass123!` | +| `import_seed_12` | Valid testnet 12-word seed | `abandon abandon ... about` | +| `doc_recipient_address` | Valid DOC testnet address | `R9o9xTocqr6...` | +| `marty_recipient_address` | Valid MARTY testnet address | `R4kL2xPqm7...` | +| `evm_token_contract` | Test ERC-20 contract address | `0x1234...abcd` | + +--- + +## 11. Performance Expectations + +| Metric | Estimate (RTX 5090 + qwen2.5-vl:32b) | +| ------------------------------------------------- | ------------------------------------ | +| Time per Skyvern step (screenshot → LLM → action) | 2–4 seconds | +| Average test case (10–15 steps) | 30–60 seconds | +| Setup task per test (login, ~5 steps) | 10–20 seconds | +| Single test with 3x majority vote | 90–180 seconds | +| Full 43-test suite (with retries) | 30–60 minutes | +| Smoke suite (8 tests, single attempt) | 3–5 minutes | +| VRAM usage during inference | ~20 GB | +| Peak GPU utilisation during inference | 80–95% | +| Ollama idle VRAM (model loaded) | ~20 GB | + +For faster iteration during prompt tuning, use gemma3:27b (~16 GB VRAM, faster inference) or qwen2.5-vl:7b (~5 GB, much faster but less accurate on complex UIs). Switch models by changing `OLLAMA_MODEL` in `.env`. + +### Comparison with Manual Testing + +| Metric | Manual | Automated | +| --------------------- | ----------------- | ---------------------------------- | +| Full regression cycle | ~52 hours | ~1 hour | +| Smoke check | ~4 hours | ~5 minutes | +| Cost per run | Human tester time | Electricity (~$0.10) | +| Consistency | Varies by tester | 90–95% stable | +| Coverage | 85 tests | 43 automated + 36 manual companion | +| Time reduction | Baseline | ~75% reduction | + +--- + +## 12. Risks and Limitations + +### 12.1 Inherent Limitations + +| Limitation | Why | Workaround | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| No 100% deterministic results | LLMs are probabilistic. Even temperature=0 varies with subtle screenshot differences. | Majority vote targets 90–95% consistency. Accept this as the realistic ceiling. | +| No pixel-perfect visual validation | LLM describes what it sees in natural language, not coordinates. Cannot detect 2px misalignment. | Supplement with pixelmatch or BackstopJS for visual regression. | +| No complex gestures | Skyvern supports click, type, scroll. Pinch, long-press, drag are unreliable. | Test gesture-dependent features manually. | +| No timing assertions | LLM cannot measure load times or animation duration. | Use Playwright performance APIs in a separate non-LLM test suite. | +| No cross-test state | Each test runs in isolation. If Test A creates data for Test B, it requires a shared database or API. | Add teardown/setup hooks or use a test database reset endpoint. | +| 32% of tests remain manual | Hardware, OS, accessibility, and cross-platform tests are architecturally impossible in-browser. | Run manual_companion.yaml alongside automated suite. | + +### 12.2 Operational Risks + +| Risk | Impact | Mitigation | +| ---------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Vision model misidentifies Flutter UI elements | Tests click wrong things, produce false passes | Use 32B model for best accuracy. Add explicit visual descriptions in prompts. Majority vote catches intermittent errors. | +| Ollama OOM on 32B model | All tests fail with ERROR | Pre-flight VRAM check aborts early. Drop to 7B for debugging. RTX 5090 32 GB handles Q4 32B comfortably. | +| Skyvern + Ollama integration bugs | Tasks hang or produce garbled output | Pin Skyvern version. Test with skyvern-1.0 engine first. Monitor Skyvern GitHub issues. | +| Flutter app load time causes timeouts | Tests fail before the app renders | Increase BROWSER_ACTION_TIMEOUT_MS. Flutter preamble adds explicit wait instructions. | +| Faucet rate-limiting blocks test funding | Send/DEX/Bridge tests cannot execute | Pre-fund test wallets. Add faucet cooldown handling. Run funding phase once, not per test. | +| Prompt drift after app UI changes | Tests fail because prompts describe old UI | Maintain prompts alongside app releases. Use visual descriptions, not hard-coded labels. | + +--- + +## Companion Files + +| File | Description | +| ----------------------- | ----------------------------------------------------------------------------------- | +| `test_matrix.yaml` | 43 automated test cases with Skyvern prompts, extraction schemas, and tag filtering | +| `manual_companion.yaml` | 36 manual-only checklist items for Grade-C and Grade-B verification steps | + +--- + +_End of document._ diff --git a/automated_testing/gleec-qa-evaluation.md b/automated_testing/gleec-qa-evaluation.md new file mode 100644 index 0000000000..8bdfb1a0ed --- /dev/null +++ b/automated_testing/gleec-qa-evaluation.md @@ -0,0 +1,196 @@ +# Gleec Wallet Test Cases: Automation Suitability Evaluation & Overhauled Test Matrix + +> Evaluates `GLEEC_WALLET_MANUAL_TEST_CASES.md` against the Skyvern + Ollama vision-based automation architecture. + +--- + +## 1. Executive Assessment + +The test cases document is excellent manual QA documentation. It is not suitable for vision-based automation in its current form, and roughly 40% of it can never be automated with this stack at all. The document needs to be split into two separate artifacts: one that feeds the Skyvern runner, and one that remains a manual checklist. + +**What the document does well:** + +- Comprehensive coverage across 26+ modules with 80+ test cases +- Proper risk-based prioritisation (P0–P3, S1–S4) +- Strong test data strategy with wallet profiles, coin sets, address sets, amount sets +- Good traceability matrix linking features to test IDs +- Realistic time estimates and parallel tester allocation +- Regression pack definitions (smoke, critical, full) + +**What makes it unsuitable for Skyvern automation as-is:** + +- Written for human judgment, not machine-executable prompts +- Steps are abstract and assume contextual understanding ("Attempt to proceed without confirming backup" — what does that look like visually?) +- Many test cases are compound: a single case tests 4–6 different things requiring different verdicts +- No visual descriptions of UI elements (Skyvern needs "click the blue button labeled Send", not "open send screen") +- ~35% of cases require capabilities outside the browser (network toggling, hardware wallets, screen readers, clipboard inspection, app-switcher behaviour, device rotation) +- Expected results are qualitative ("clear error messaging") rather than extractable assertions + +--- + +## 2. Test Case Classification + +Every test case classified by automation suitability with Skyvern + Ollama. + +### Classification Key + +| Grade | Meaning | Action | +|-------|---------|--------| +| **A — Fully automatable** | Pure UI interaction within a web browser. All steps and verification are visual. | Convert to Skyvern prompt. | +| **B — Partially automatable** | Some steps are automatable, but verification or setup requires human/external action. | Split: automate the UI steps, flag verification as manual. | +| **C — Manual only** | Requires hardware, OS-level actions, network manipulation, screen reader, or cross-platform device. | Keep in manual checklist. Remove from automation matrix. | + +### Full Classification + +| Test ID | Module | Title | Grade | Reason | +|---------|--------|-------|-------|--------| +| AUTH-001 | Auth | Create wallet with seed backup | **A** | UI-only flow: tap, enter password, navigate seed screens | +| AUTH-002 | Auth | Login/logout with remember-session | **B** | Login/logout automatable; "close and relaunch app" requires session restart outside Skyvern | +| AUTH-003 | Auth | Import wallet from seed | **A** | UI-only: enter seed, set password, verify balances | +| AUTH-004 | Auth | Invalid password attempts + lockout | **A** | UI-only: enter wrong passwords, observe lockout messages | +| AUTH-005 | Auth | Trezor connect/disconnect | **C** | Requires physical hardware wallet + USB | +| WAL-001 | Wallet Manager | Create, rename, select wallets | **A** | UI-only multi-step flow | +| WAL-002 | Wallet Manager | Delete wallet with confirmation | **A** | UI-only: cancel/confirm delete dialogs | +| WAL-003 | Wallet Manager | Selection persistence across restart | **C** | Requires app restart + re-login outside browser | +| COIN-001 | Coin Manager | Test coin visibility gate | **A** | Toggle setting, search, verify visibility | +| COIN-002 | Coin Manager | Activate/deactivate with search/filter | **A** | UI-only search and toggle | +| COIN-003 | Coin Manager | Deactivate coin with balance + restore | **A** | UI-only with warning dialog | +| DASH-001 | Dashboard | Hide balances / hide zero toggles | **A** | Toggle and verify masking | +| DASH-002 | Dashboard | Balance refresh + offline indicator | **C** | Requires network toggle (OS-level) | +| DASH-003 | Dashboard | Dashboard persistence across restart | **C** | Requires app restart | +| CDET-001 | Coin Details | Address display, copy, QR, explorer | **B** | View/QR automatable; clipboard + external explorer are manual | +| CDET-002 | Coin Details | Transaction list + status progression | **B** | List view automatable; pending→confirmed requires real-time chain state | +| CDET-003 | Coin Details | Price chart + no-data/network fallback | **B** | Chart view automatable; offline fallback requires network toggle | +| SEND-001 | Send | Faucet funding success | **A** | Click faucet button, verify incoming tx in UI | +| SEND-002 | Send | Faucet cooldown/denied + network error | **B** | Cooldown automatable; network error requires network toggle | +| SEND-003 | Send | DOC send happy path | **A** | Enter recipient, amount, confirm, track in history | +| SEND-004 | Send | Address validation + memo/tag | **A** | Enter invalid addresses, verify error messages | +| SEND-005 | Send | Amount boundary + insufficient funds | **A** | Enter boundary amounts, verify validation messages | +| SEND-006 | Send | Interrupted send + duplicate-submit | **C** | Requires network kill mid-transaction, app backgrounding | +| DEX-001 | DEX | Maker limit order creation | **A** | Select pair, enter price/amount, submit, verify in open orders | +| DEX-002 | DEX | Taker order execution | **B** | Depends on orderbook liquidity in test environment | +| DEX-003 | DEX | DEX validation (invalid inputs) | **A** | Enter invalid values, verify error messages | +| DEX-004 | DEX | Order lifecycle: partial fill, cancel | **B** | Cancel automatable; partial fill depends on external market activity | +| DEX-005 | DEX | Swap history filtering + export | **B** | Filtering automatable; export file verification needs filesystem access | +| DEX-006 | DEX | DEX recovery after restart/network drop | **C** | Requires app closure and network toggling | +| BRDG-001 | Bridge | Bridge transfer happy path | **A** | Select pair, enter amount, confirm, track status | +| BRDG-002 | Bridge | Unsupported pair validation | **A** | Select unsupported pair, verify blocking message | +| BRDG-003 | Bridge | Amount boundaries + insufficient funds | **A** | Enter boundary amounts, verify error messages | +| BRDG-004 | Bridge | Bridge failure/timeout + recovery | **C** | Requires network interruption and app restart | +| NFT-001 | NFT | NFT list/details/history filtering | **A** | Browse, filter, view details | +| NFT-002 | NFT | NFT send happy path | **A** | Enter recipient, confirm, monitor history | +| NFT-003 | NFT | NFT send failure + recovery | **A** | Enter invalid recipient, verify error, retry | +| SET-001 | Settings | Theme + language + format persistence | **B** | Change settings automatable; restart persistence check is manual | +| SET-002 | Settings | Analytics/privacy toggles | **A** | Toggle on/off, verify state | +| SET-003 | Settings | Test coin toggle impact | **A** | Toggle, verify DOC/MARTY visibility | +| SET-004 | Settings | Settings persistence across restart | **C** | Requires logout/restart | +| BOT-001 | Bot | Create + start market maker bot | **A** | Fill form, save, start, verify running status | +| BOT-002 | Bot | Bot validation (invalid config) | **A** | Enter invalid values, verify error blocking | +| BOT-003 | Bot | Edit, stop, restart bot | **B** | Edit/stop/restart automatable; persistence across relaunch is manual | +| NAV-001 | Navigation | Route integrity + back navigation | **A** | Click through all menu items, use back button | +| NAV-002 | Navigation | Deep link + auth gating | **C** | Requires direct URL entry while logged out + auth redirect chain | +| NAV-003 | Navigation | Unsaved changes prompt | **A** | Enter data in form, navigate away, interact with dialog | +| RESP-001 | Responsive | Breakpoint behaviour | **C** | Requires browser window resize (not reliably controllable via Skyvern) | +| RESP-002 | Responsive | Orientation/resize state retention | **C** | Requires device rotation or window resize mid-flow | +| XPLAT-001 | Cross-Platform | Core flow parity | **C** | By definition requires running on Android, iOS, macOS, Linux, Windows | +| XPLAT-002 | Cross-Platform | Platform permissions + input | **C** | Requires OS permission dialogs, hardware back, etc. | +| A11Y-001 | Accessibility | Keyboard-only navigation | **C** | Requires keyboard Tab/Shift+Tab, focus ring inspection — vision model cannot reliably judge focus state | +| A11Y-002 | Accessibility | Screen reader labels/roles | **C** | Requires VoiceOver/TalkBack | +| A11Y-003 | Accessibility | Color contrast + touch targets + text scaling | **C** | Requires pixel-level contrast analysis and OS text scaling | +| SEC-001 | Security | Seed phrase handling/reveal | **B** | Reveal flow automatable; screenshot masking policy and background behaviour are manual | +| SEC-002 | Security | Session auto-lock + app-switcher privacy | **C** | Requires idle timeout, app-switcher snapshot | +| SEC-003 | Security | Clipboard exposure risk | **C** | Requires clipboard access/monitoring outside browser | +| ERR-001 | Error Handling | Global network outage | **C** | Requires network toggle | +| ERR-002 | Error Handling | Partial backend failure isolation | **C** | Requires endpoint-specific failure simulation | +| ERR-003 | Error Handling | Stale-state reconciliation | **C** | Requires app closure during in-flight transaction | +| L10N-001 | Localization | Translation completeness | **A** | Switch locale, review UI text | +| L10N-002 | Localization | Long-string overflow/clipping | **B** | Can screenshot narrow width, but visual clipping judgment is low-confidence for vision model | +| L10N-003 | Localization | Locale-specific format consistency | **A** | Switch locale, compare date/number formatting | +| FIAT-001 | Fiat | Menu access + connect-wallet gating | **A** | Open fiat menu, verify gating, connect wallet | +| FIAT-002 | Fiat | Form validation | **A** | Enter invalid amounts, switch payment methods | +| FIAT-003 | Fiat | Checkout success via provider webview | **B** | Provider webview/dialog may be a separate domain the vision model can't follow | +| FIAT-004 | Fiat | Checkout closed/failed handling | **B** | Closing provider window mid-flow is manual | +| FIAT-005 | Fiat | Form behaviour across logout/login | **C** | Requires logout/re-login | +| SUP-001 | Support | Support page + links + missing coins dialog | **A** | Open page, verify content, open dialog | +| FEED-001 | Feedback | Feedback entry points | **A** | Open feedback from settings/bug button, submit/cancel | +| SECX-001 | Security Settings | Private key export flow | **B** | Auth + toggle automatable; download/share actions may cross browser boundary | +| SECX-002 | Security Settings | Seed backup show/confirm/success | **A** | Auth, reveal, confirm challenge — all visual | +| SECX-003 | Security Settings | Unban pubkeys | **A** | Trigger action, observe results | +| SECX-004 | Security Settings | Change password flow | **A** | Enter old/new passwords, verify rejection/acceptance | +| SETX-001 | Settings Advanced | Weak-password toggle | **A** | Toggle setting, attempt wallet create with weak password | +| SETX-002 | Settings Advanced | Trading bot master toggles | **B** | Toggle automatable; stop-on-disable verification depends on running bot state | +| SETX-003 | Settings Advanced | Export/import maker orders JSON | **C** | File system import/export outside browser | +| SETX-004 | Settings Advanced | Show/export swap data | **B** | View/copy automatable; export is filesystem | +| SETX-005 | Settings Advanced | Import swaps from JSON | **C** | Requires pasting external JSON payload | +| SETX-006 | Settings Advanced | Download logs + flood logs | **C** | File download + debug build action | +| SETX-007 | Settings Advanced | Reset activated coins | **A** | Select wallet, confirm reset, verify | +| WALX-001 | Wallet Advanced | Overview cards + privacy toggle | **A** | View cards, toggle privacy, verify masking | +| WALX-002 | Wallet Advanced | Assets/Growth/PnL tabs | **B** | Tab switching automatable; logged-out fallback requires logout | +| WADDR-001 | Coin Addresses | Multi-address display + controls | **A** | Toggle hide-zero, expand/collapse, copy, QR, faucet | +| WADDR-002 | Coin Addresses | Create new address flow | **A** | Click create, confirm, verify new address appears | +| CTOK-001 | Custom Token | Import happy path | **A** | Select network, enter contract, fetch, confirm import | +| CTOK-002 | Custom Token | Fetch failure + not-found | **A** | Enter invalid contract, verify error | +| CTOK-003 | Custom Token | Back/cancel + state reset | **A** | Navigate back, close dialog, verify clean state | +| RWD-001 | Rewards | KMD rewards refresh + claim | **B** | View automatable; claim depends on reward availability | +| GATE-001 | Feature Gating | Trading-disabled mode | **A** | Verify disabled menu items, tooltips | +| GATE-002 | Feature Gating | Hardware-wallet restrictions | **C** | Requires Trezor login | +| GATE-003 | Feature Gating | NFT menu disabled + direct route | **A** | Verify disabled state, attempt direct navigation | +| QLOG-001 | Quick Login | Remember-me persistence | **C** | Requires app relaunch | +| BREF-001 | Bitrefill | Integration visibility + lifecycle | **B** | Button visibility automatable; widget interaction crosses domains | +| ZHTL-001 | ZHTLC | Configuration dialog + activation | **B** | Dialog automatable; logout-during-activation is manual | +| WARN-001 | System Health | Clock warning banner | **C** | Requires system clock manipulation | + +### Summary Count + +| Grade | Count | Percentage | +|-------|-------|------------| +| **A — Fully automatable** | 40 | 47% | +| **B — Partially automatable** | 18 | 21% | +| **C — Manual only** | 27 | 32% | +| **Total** | 85 | 100% | + +--- + +## 3. Structural Problems for Automation + +Beyond per-case suitability, these structural issues in the original document prevent direct conversion: + +**Problem 1: Compound test cases.** +AUTH-001 tests five things in one case: tap create wallet, enter password, attempt to skip seed backup, complete seed confirmation, finish onboarding. For a vision-based agent, this needs to be 2–3 separate tasks to avoid state corruption at step 3 causing steps 4–5 to run against the wrong screen. + +**Problem 2: No visual element descriptions.** +Every case says "Open DEX" or "Enter amount" without describing what the DEX screen looks like, what the amount field looks like, or what distinguishes it from adjacent inputs. Skyvern needs: "Look for the input field labeled 'Amount' below the recipient address field, with a coin ticker symbol next to it." + +**Problem 3: Abstract expected results.** +"Validation blocks invalid orders with specific guidance" is not machine-evaluable. The automation needs: "A red error message or banner appears on screen containing the word 'invalid', 'insufficient', or 'minimum'." + +**Problem 4: No test data in-line.** +The cases reference AS-01, AM-03, WP-02 — but the automation prompt must contain the actual address string, the actual amount value, and the actual seed phrase. The runner cannot look up a test data matrix. + +**Problem 5: Missing setup/teardown coupling.** +Many Grade-A tests assume DOC/MARTY are already funded (from SEND-001). The automation needs explicit dependency ordering or the setup block must handle funding. + +--- + +## 4. Recommended Architecture: Two Documents + +``` +GLEEC_WALLET_MANUAL_TEST_CASES.md (original — keep as-is) + │ + ├── Remains the canonical QA reference for manual testers + ├── All 85 test cases, all platforms, all edge cases + └── Used by human QA team for full regression + +tests/test_matrix.yaml (NEW — Skyvern automation) + │ + ├── Grade-A tests converted to vision-compatible prompts + ├── Grade-B tests with automatable portions only + ├── Hardened with checkpoints, explicit data, visual descriptions + └── Used by the Skyvern runner for automated regression + +tests/manual_companion.yaml (NEW — manual-only checklist) + │ + ├── Grade-C tests formatted as pass/fail checklist + ├── Grade-B manual verification steps + └── Run alongside automation for full coverage +``` diff --git a/automated_testing/manual_companion.yaml b/automated_testing/manual_companion.yaml new file mode 100644 index 0000000000..593d35a159 --- /dev/null +++ b/automated_testing/manual_companion.yaml @@ -0,0 +1,287 @@ +# Gleec Wallet — Manual-Only Test Companion Checklist +# +# These 14 tests CANNOT be automated even with OS calls and Playwright. +# They require physical hardware, screen reader software, native platform +# builds, or real-time market conditions. +# +# 22 formerly-manual tests have been moved to test_matrix.yaml as composite +# tests (tagged "composite") using OS network toggle, Playwright browser +# lifecycle, viewport resizing, clock mocking, and accessibility auditing. +# +# Usage: +# Automated runner integration: python -m runner.runner --include-manual +# Manual-only mode: python -m runner.runner --manual-only + +manual_tests: + + # ========================================================================= + # HARDWARE WALLET (Trezor) — requires physical device + # ========================================================================= + + - id: MAN-AUTH-005 + source: GW-MAN-AUTH-005 + title: "Trezor connect/disconnect and signing" + reason: "Requires physical Trezor device + USB" + platforms: [web, macOS, Linux, Windows] + tags: [hardware, security, critical] + checklist: + - "[ ] Connect Trezor and import hardware account" + - "[ ] Start sign-required action (send preview) — confirm on device" + - "[ ] Disconnect device and retry action — app prompts reconnection" + - "[ ] No crash on disconnect" + interactive_steps: + - prompt: "Connect your Trezor device via USB to the computer." + wait_for: keypress + - prompt: "Open the Gleec Wallet web app and look for a hardware wallet import option. Click it to begin pairing the Trezor. Follow any prompts on the Trezor screen to authorize the connection. Is the Trezor wallet successfully imported? (y/n)" + wait_for: confirmation + - prompt: "Navigate to a coin with balance and open the Send form. Fill in a valid address and small amount, then click Preview/Confirm. The Trezor should display a signing request on its screen. Confirm on the Trezor device. Did the transaction submit successfully? (y/n)" + wait_for: confirmation + - prompt: "Disconnect the Trezor USB cable physically. Now try to initiate another send action in the app. Does the app show a reconnection prompt or clear error message (no crash)? (y/n)" + wait_for: confirmation + + - id: MAN-GATE-002 + source: GW-MAN-GATE-002 + title: "Hardware-wallet restrictions for fiat/trading" + reason: "Requires Trezor login" + platforms: [web, macOS, Linux, Windows] + tags: [hardware, gating] + checklist: + - "[ ] Log in with Trezor wallet" + - "[ ] Fiat/DEX/Bridge/Bot menu items show restricted/disabled state" + - "[ ] Clear wallet-only messaging shown" + interactive_steps: + - prompt: "Ensure you are logged in with a Trezor hardware wallet. Press Enter when ready." + wait_for: keypress + - prompt: "Check the main navigation menu. Are the Fiat, DEX, Bridge, and Bot menu items showing a disabled/restricted state (grayed out, lock icon, or tooltip)? (y/n)" + wait_for: confirmation + - prompt: "Try clicking one of the disabled items. Does a clear message explain the restriction for hardware wallets? (y/n)" + wait_for: confirmation + + # ========================================================================= + # PARTIAL BACKEND FAILURE — requires custom proxy/endpoint blocking + # ========================================================================= + + - id: MAN-ERR-002 + source: GW-MAN-ERR-002 + title: "Partial backend failure isolation" + reason: "Requires endpoint-specific failure simulation (proxy/firewall)" + platforms: [all] + tags: [network, error_handling] + checklist: + - "[ ] Trigger failure in one module endpoint" + - "[ ] Unaffected modules still functional" + - "[ ] Failed module recovers after service restore" + interactive_steps: + - prompt: "Set up a proxy or firewall rule to block a specific API endpoint (e.g., DEX orderbook). Press Enter when the failure condition is active." + wait_for: keypress + - prompt: "With one module's endpoint failing, do other modules (wallet, settings, etc.) still function normally? (y/n)" + wait_for: confirmation + - prompt: "Restore the blocked endpoint. Does the failed module recover and function correctly? (y/n)" + wait_for: confirmation + + # ========================================================================= + # CROSS-PLATFORM — requires native app builds + # ========================================================================= + + - id: MAN-XPLAT-001 + source: GW-MAN-XPLAT-001 + title: "Core-flow parity across platforms" + reason: "Requires running on Android, iOS, macOS, Linux, Windows" + platforms: [web, Android, iOS, macOS, Linux, Windows] + tags: [cross_platform] + checklist: + - "[ ] Create wallet on each platform" + - "[ ] Fund DOC via faucet on each" + - "[ ] Send DOC on each" + - "[ ] Verify history on each" + - "[ ] Document any platform-specific blockers" + interactive_steps: + - prompt: "Which platform are you testing now? (type the name and press Enter)" + wait_for: keypress + - prompt: "Create a new wallet on this platform. Does wallet creation complete? (y/n)" + wait_for: confirmation + - prompt: "Enable DOC and use the faucet. Does the faucet work? (y/n)" + wait_for: confirmation + - prompt: "Send 0.001 DOC to a test address. Does the transaction complete? (y/n)" + wait_for: confirmation + - prompt: "Check transaction history. Does it show correctly? (y/n)" + wait_for: confirmation + + - id: MAN-XPLAT-002 + source: GW-MAN-XPLAT-002 + title: "Platform permissions and input behavior" + reason: "Requires OS permission dialogs" + platforms: [all] + tags: [cross_platform] + checklist: + - "[ ] Trigger QR scan / export / notification permissions" + - "[ ] Deny permission — graceful handling" + - "[ ] Grant permission — action works" + - "[ ] Hardware back (Android) / keyboard shortcuts (desktop) work" + interactive_steps: + - prompt: "Trigger a permission request (QR scan, file export, etc.). DENY it. Does the app handle denial gracefully? (y/n)" + wait_for: confirmation + - prompt: "Trigger the same action and GRANT the permission. Does it work? (y/n)" + wait_for: confirmation + - prompt: "Test hardware back (Android) or keyboard shortcuts (desktop). Do they work? (y/n)" + wait_for: confirmation + + # ========================================================================= + # SCREEN READER — requires VoiceOver/TalkBack + # ========================================================================= + + - id: MAN-A11Y-002 + source: GW-MAN-A11Y-002 + title: "Screen reader labels and announcements" + reason: "Requires VoiceOver/TalkBack — cannot be automated" + platforms: [iOS, Android, desktop, web] + tags: [accessibility] + checklist: + - "[ ] Navigate key screens with screen reader" + - "[ ] Form fields have meaningful labels" + - "[ ] Validation errors announced" + - "[ ] Status changes (pending/confirmed) announced" + - "[ ] No unlabeled actionable controls" + interactive_steps: + - prompt: "Enable a screen reader (VoiceOver, TalkBack, NVDA). Navigate the dashboard. Press Enter when ready." + wait_for: keypress + - prompt: "Do form fields on the send screen have meaningful labels read by the screen reader? (y/n)" + wait_for: confirmation + - prompt: "Trigger a validation error. Is it announced by the screen reader? (y/n)" + wait_for: confirmation + - prompt: "Are there any unlabeled buttons or controls? (y/n = y means NO unlabeled controls)" + wait_for: confirmation + + # ========================================================================= + # NATIVE SECURITY — requires OS screenshot/app-switcher + # ========================================================================= + + - id: MAN-SEC-001b + source: GW-MAN-SEC-001 + title: "Seed phrase — screenshot masking and background behavior" + reason: "Requires OS screenshot and app backgrounding (native behavior)" + platforms: [all] + tags: [security] + checklist: + - "[ ] Take screenshot while seed visible — masking policy applied" + - "[ ] Background app while seed visible — seed not exposed" + - "[ ] Return to app — seed screen state handled correctly" + interactive_steps: + - prompt: "Reveal the seed phrase (Settings > Security > View Seed, enter password)." + wait_for: keypress + - prompt: "Take an OS screenshot. Is the seed phrase masked/protected in the screenshot? (y/n)" + wait_for: confirmation + - prompt: "Background/minimize the app. Check the app-switcher preview. Is the seed hidden? (y/n)" + wait_for: confirmation + - prompt: "Return to the app. Is the seed screen handled correctly? (y/n)" + wait_for: confirmation + + - id: MAN-SEC-002 + source: GW-MAN-SEC-002 + title: "Session auto-lock and app-switcher privacy" + reason: "Requires idle timeout and native app-switcher" + platforms: [all] + tags: [security] + checklist: + - "[ ] Set short inactivity timeout" + - "[ ] Leave idle until timeout — re-auth required" + - "[ ] App-switcher snapshot — no sensitive data visible" + interactive_steps: + - prompt: "Set the auto-lock timeout to the shortest option in Settings." + wait_for: keypress + - prompt: "Leave the app idle until the timeout triggers. Does it require re-authentication? (y/n)" + wait_for: confirmation + - prompt: "Check the app-switcher preview while sensitive data is on screen. Is it hidden? (y/n)" + wait_for: confirmation + + # ========================================================================= + # GRADE-B: Manual verification requiring real chain/market conditions + # ========================================================================= + + - id: MAN-VERIFY-CDET-001 + source: GW-AUTO-CDET-001a + title: "Explorer link verification after address copy" + reason: "Explorer link correctness requires opening external browser" + tags: [wallet, verification] + checklist: + - "[ ] Explorer link opens correct network/address URL" + interactive_steps: + - prompt: "Click the explorer link on a coin detail page. Does it open the correct block explorer URL? (y/n)" + wait_for: confirmation + + - id: MAN-VERIFY-CDET-002 + source: GW-MAN-CDET-002 + title: "Transaction pending to confirmed progression" + reason: "Requires waiting for real chain confirmations (1-5 min)" + tags: [wallet, verification] + checklist: + - "[ ] After sending DOC, watch for pending→confirmed in history" + - "[ ] Confirmed tx shows correct hash, amount, fee, timestamp" + - "[ ] Explorer link shows matching transaction" + interactive_steps: + - prompt: "After sending DOC, watch the history. Wait for pending→confirmed (1-5 min). Did status progress? (y/n)" + wait_for: confirmation + - prompt: "Does the confirmed tx show correct hash, amount, fee, timestamp? (y/n)" + wait_for: confirmation + + - id: MAN-VERIFY-DEX-004 + source: GW-MAN-DEX-004 + title: "DEX partial fill observation" + reason: "Depends on external market activity and counterparty" + tags: [dex, verification] + checklist: + - "[ ] Place maker order with moderate size" + - "[ ] Wait for partial fill (requires counterparty)" + - "[ ] Cancel remaining — balances/locked funds reconcile" + interactive_steps: + - prompt: "Place a maker order (1 DOC). Wait for partial fill from a counterparty. Did it occur? (y/n/s to skip)" + wait_for: confirmation + - prompt: "If partially filled, cancel the remaining. Do balances reconcile? (y/n)" + wait_for: confirmation + + - id: MAN-VERIFY-DEX-005 + source: GW-MAN-DEX-005 + title: "History export file verification" + reason: "Requires opening and inspecting exported file contents" + tags: [dex, verification] + checklist: + - "[ ] Export CSV/file from DEX history" + - "[ ] Open file — records match visible history" + - "[ ] Timestamp/decimal formatting correct" + interactive_steps: + - prompt: "Export a file from DEX history. Open it. Do records match the visible history? (y/n)" + wait_for: confirmation + - prompt: "Are timestamps and decimal values formatted correctly? (y/n)" + wait_for: confirmation + + - id: MAN-VERIFY-FIAT-003 + source: GW-MAN-FIAT-003 + title: "Fiat checkout via provider webview" + reason: "Provider webview crosses domain boundary" + tags: [fiat, verification] + checklist: + - "[ ] Submit Buy Now — provider checkout launches" + - "[ ] Complete provider flow" + - "[ ] Return to app — success status shown" + interactive_steps: + - prompt: "Click Buy Now in the Fiat section. Does the provider checkout launch? (y/n)" + wait_for: confirmation + - prompt: "Complete the provider flow and return to the app. Is a success status shown? (y/n)" + wait_for: confirmation + + - id: MAN-VERIFY-FIAT-004 + source: GW-MAN-FIAT-004 + title: "Fiat checkout closed/failed handling" + reason: "Closing provider window mid-flow is manual" + tags: [fiat, verification] + checklist: + - "[ ] Close provider dialog before completion" + - "[ ] Failure messaging shown, no stale state" + - "[ ] Retry works cleanly" + interactive_steps: + - prompt: "Start fiat checkout. Close the provider dialog BEFORE completing payment." + wait_for: keypress + - prompt: "Does the app show appropriate messaging (no stale state)? (y/n)" + wait_for: confirmation + - prompt: "Try starting checkout again. Does it work cleanly? (y/n)" + wait_for: confirmation diff --git a/automated_testing/requirements.txt b/automated_testing/requirements.txt new file mode 100644 index 0000000000..b0ef6fe54e --- /dev/null +++ b/automated_testing/requirements.txt @@ -0,0 +1,8 @@ +skyvern>=1.0.0 +pyyaml>=6.0 +pydantic>=2.4.0 +httpx>=0.27.0 +jinja2>=3.1.6 +rich>=13.0 +playwright>=1.40.0 +axe-playwright-python>=0.1.0 diff --git a/automated_testing/runner/__init__.py b/automated_testing/runner/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/automated_testing/runner/__init__.py @@ -0,0 +1 @@ + diff --git a/automated_testing/runner/__main__.py b/automated_testing/runner/__main__.py new file mode 100644 index 0000000000..67e0888252 --- /dev/null +++ b/automated_testing/runner/__main__.py @@ -0,0 +1,4 @@ +"""Allow running the runner as a module: python -m runner""" +from .runner import cli + +cli() diff --git a/automated_testing/runner/guards.py b/automated_testing/runner/guards.py new file mode 100644 index 0000000000..cc1ccac76d --- /dev/null +++ b/automated_testing/runner/guards.py @@ -0,0 +1,25 @@ +"""Timeout and deadlock guards for Skyvern task execution.""" + +from __future__ import annotations + +import asyncio + + +class TestTimeoutError(Exception): + """Raised when a test exceeds its allowed execution time.""" + + +async def run_with_timeout(coro, seconds: int, test_id: str): + """Run a coroutine with a hard timeout. + + Raises TestTimeoutError with a diagnostic message including the test ID + and timeout value so the runner can log the issue and continue. + """ + try: + return await asyncio.wait_for(coro, timeout=seconds) + except asyncio.TimeoutError: + raise TestTimeoutError( + f"Test {test_id} timed out after {seconds}s. " + f"This may indicate a hung browser session, Ollama stall, " + f"or the app failing to reach the expected state." + ) diff --git a/automated_testing/runner/interactive.py b/automated_testing/runner/interactive.py new file mode 100644 index 0000000000..8742b436cd --- /dev/null +++ b/automated_testing/runner/interactive.py @@ -0,0 +1,174 @@ +"""Human-in-the-loop interactive prompting for Grade-C and hardware tests. + +When the runner encounters tests tagged 'manual_step' or loaded from +manual_companion.yaml with interactive_steps, this module pauses execution, +presents clear instructions to the human tester, and awaits confirmation. +""" + +from __future__ import annotations + +import asyncio +import sys +from typing import Optional + +from .models import ManualResult + +try: + from rich.console import Console + from rich.panel import Panel + from rich.prompt import Prompt + + _console = Console() + _HAS_RICH = True +except ImportError: + _HAS_RICH = False + + +def _print_header(text: str) -> None: + if _HAS_RICH: + _console.print(Panel(text, title="Manual Test", border_style="yellow")) + else: + print(f"\n{'=' * 60}") + print(f" MANUAL TEST: {text}") + print(f"{'=' * 60}\n") + + +def _print_instruction(text: str) -> None: + if _HAS_RICH: + _console.print(f" [bold cyan]>>>[/bold cyan] {text}") + else: + print(f" >>> {text}") + + +def _get_response(prompt_text: str = "Result (y=pass / n=fail / s=skip)") -> str: + if _HAS_RICH: + return Prompt.ask(f" {prompt_text}", choices=["y", "n", "s"], default="s") + while True: + resp = input(f" {prompt_text} [y/n/s]: ").strip().lower() + if resp in ("y", "n", "s"): + return resp + print(" Please enter y, n, or s.") + + +def _get_keypress(prompt_text: str = "Press Enter when ready...") -> None: + if _HAS_RICH: + _console.input(f" [dim]{prompt_text}[/dim] ") + else: + input(f" {prompt_text} ") + + +async def run_interactive_test(test_def: dict) -> ManualResult: + """Execute a manual/interactive test with human-in-the-loop prompting. + + Args: + test_def: A dict from manual_companion.yaml containing at minimum: + id, title, and either 'interactive_steps' or 'checklist'. + + Returns: + ManualResult with human-provided pass/fail/skip status. + """ + test_id = test_def["id"] + title = test_def.get("title", test_id) + reason = test_def.get("reason", "") + checklist = test_def.get("checklist", []) + interactive_steps = test_def.get("interactive_steps", []) + + _print_header(f"{test_id}: {title}") + if reason: + _print_instruction(f"Reason for manual execution: {reason}") + print() + + checklist_results: list[dict] = [] + + if interactive_steps: + for i, step in enumerate(interactive_steps, 1): + prompt_text = step.get("prompt", "") + wait_for = step.get("wait_for", "confirmation") + + print(f"\n Step {i}/{len(interactive_steps)}:") + _print_instruction(prompt_text) + + if wait_for == "keypress": + _get_keypress() + checklist_results.append({"step": i, "prompt": prompt_text, "result": "acknowledged"}) + elif wait_for == "confirmation": + resp = _get_response() + status = {"y": "pass", "n": "fail", "s": "skip"}[resp] + checklist_results.append({"step": i, "prompt": prompt_text, "result": status}) + if status == "fail": + notes = input(" Failure notes (optional): ").strip() + checklist_results[-1]["notes"] = notes + else: + _get_keypress(f"{wait_for} — Press Enter when done...") + checklist_results.append({"step": i, "prompt": prompt_text, "result": "acknowledged"}) + + elif checklist: + for i, item in enumerate(checklist, 1): + item_text = item.replace("[ ] ", "").replace("[x] ", "").strip() + print(f"\n Checklist item {i}/{len(checklist)}:") + _print_instruction(item_text) + resp = _get_response() + status = {"y": "pass", "n": "fail", "s": "skip"}[resp] + checklist_results.append({"item": i, "text": item_text, "result": status}) + + print() + overall = _get_response("Overall test result (y=pass / n=fail / s=skip)") + overall_status = {"y": "PASS", "n": "FAIL", "s": "SKIP"}[overall] + notes = "" + if overall_status == "FAIL": + notes = input(" Failure notes: ").strip() + + return ManualResult( + test_id=test_id, + title=title, + status=overall_status, + checklist_results=checklist_results, + notes=notes, + ) + + +async def run_interactive_batch( + manual_tests: list[dict], + tag_filter: Optional[str] = None, +) -> list[ManualResult]: + """Run a batch of interactive/manual tests sequentially. + + Args: + manual_tests: List of test defs from manual_companion.yaml + tag_filter: If set, only run tests whose tags include this value + + Returns: + List of ManualResult objects. + """ + results: list[ManualResult] = [] + filtered = manual_tests + if tag_filter: + filtered = [ + t for t in manual_tests + if tag_filter in t.get("tags", []) + ] + + total = len(filtered) + print(f"\n{'=' * 60}") + print(f" INTERACTIVE TEST SESSION — {total} manual tests") + print(f"{'=' * 60}") + + for i, test_def in enumerate(filtered, 1): + print(f"\n [{i}/{total}]") + result = await run_interactive_test(test_def) + results.append(result) + + if i < total: + resp = input("\n Continue to next test? (Enter=yes, q=quit): ").strip() + if resp.lower() == "q": + print(" Skipping remaining manual tests.") + for remaining in filtered[i:]: + results.append(ManualResult( + test_id=remaining["id"], + title=remaining.get("title", ""), + status="SKIP", + notes="Skipped by user", + )) + break + + return results diff --git a/automated_testing/runner/models.py b/automated_testing/runner/models.py new file mode 100644 index 0000000000..65f0840c4f --- /dev/null +++ b/automated_testing/runner/models.py @@ -0,0 +1,106 @@ +"""Pydantic data models for the Gleec QA test runner.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from pydantic import BaseModel, Field + + +class TestStep(BaseModel): + action: str + checkpoint: Optional[str] = None + + +class CompositePhase(BaseModel): + """A single phase in a composite test that mixes Skyvern + OS/Playwright.""" + type: str # skyvern | os_call | playwright | assert + action: str = "" + prompt: str = "" + args: dict = Field(default_factory=dict) + expected: Optional[str] = None + checkpoint: Optional[str] = None + extraction_schema: Optional[dict] = None + max_steps: Optional[int] = None + + +class TestCase(BaseModel): + id: str + name: str + tags: list[str] = Field(default_factory=list) + prompt: str = "" + steps: Optional[list[TestStep]] = None + phases: Optional[list[CompositePhase]] = None + expected_result: str = "" + extraction_schema: Optional[dict] = None + max_steps: Optional[int] = None + timeout: Optional[int] = None + source_manual_id: Optional[str] = None + manual_verification_note: Optional[str] = None + + @property + def is_composite(self) -> bool: + return self.phases is not None and len(self.phases) > 0 + + +class AttemptResult(BaseModel): + attempt: int + status: str # PASS | FAIL | ERROR | TIMEOUT + skyvern_status: str = "" + extracted_data: Optional[dict | str] = None + duration_seconds: float = 0.0 + run_id: Optional[str] = None + error: Optional[str] = None + screenshot_path: Optional[str] = None + + +class VotedResult(BaseModel): + test_id: str + test_name: str + tags: list[str] = Field(default_factory=list) + final_status: str # PASS | FAIL | FLAKY | ERROR | SKIP + vote_counts: dict[str, int] = Field(default_factory=dict) + confidence: float = 0.0 + expected: str = "" + manual_verification_note: Optional[str] = None + attempts: list[AttemptResult] = Field(default_factory=list) + duration_seconds: float = 0.0 + + +class ManualResult(BaseModel): + test_id: str + title: str + status: str # PASS | FAIL | SKIP + checklist_results: list[dict] = Field(default_factory=list) + notes: str = "" + + +class TestRun(BaseModel): + timestamp: str = Field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + base_url: str = "" + engine: str = "skyvern-2.0" + model: str = "qwen2.5-vl:32b" + total: int = 0 + passed: int = 0 + failed: int = 0 + errors: int = 0 + skipped: int = 0 + flaky: int = 0 + pass_rate: float = 0.0 + duration_seconds: float = 0.0 + voted_results: list[VotedResult] = Field(default_factory=list) + manual_results: list[ManualResult] = Field(default_factory=list) + + def compute_summary(self) -> None: + self.total = len(self.voted_results) + self.passed = sum(1 for r in self.voted_results if r.final_status == "PASS") + self.failed = sum(1 for r in self.voted_results if r.final_status == "FAIL") + self.errors = sum(1 for r in self.voted_results if r.final_status == "ERROR") + self.skipped = sum(1 for r in self.voted_results if r.final_status == "SKIP") + self.flaky = sum(1 for r in self.voted_results if r.final_status == "FLAKY") + executed = self.passed + self.failed + self.flaky + self.pass_rate = round(self.passed / executed * 100, 1) if executed > 0 else 0.0 + self.duration_seconds = sum(r.duration_seconds for r in self.voted_results) diff --git a/automated_testing/runner/ollama_monitor.py b/automated_testing/runner/ollama_monitor.py new file mode 100644 index 0000000000..360d0a7bdd --- /dev/null +++ b/automated_testing/runner/ollama_monitor.py @@ -0,0 +1,111 @@ +"""Background Ollama and GPU health monitor running during test execution.""" + +from __future__ import annotations + +import asyncio +import logging +import platform +import shutil +import subprocess + +import httpx + +logger = logging.getLogger(__name__) + + +class OllamaMonitor: + """Polls Ollama health and GPU metrics every interval_seconds. + + The runner checks ``monitor.healthy`` before each test attempt. + If unhealthy, subsequent tests are immediately marked ERROR with + the specific failure reason from ``last_error``. + """ + + def __init__( + self, + ollama_url: str = "http://localhost:11434", + interval_seconds: int = 10, + vram_critical_mb: int = 500, + temp_critical_c: int = 90, + ): + self.url = ollama_url + self.interval = interval_seconds + self.vram_critical_mb = vram_critical_mb + self.temp_critical_c = temp_critical_c + self._running = False + self._task: asyncio.Task | None = None + self.last_error: str | None = None + + @property + def healthy(self) -> bool: + return self.last_error is None + + async def start(self) -> None: + self._running = True + self._task = asyncio.create_task(self._monitor_loop()) + logger.info("Ollama monitor started (interval=%ds)", self.interval) + + async def stop(self) -> None: + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info("Ollama monitor stopped") + + async def _monitor_loop(self) -> None: + while self._running: + try: + await self._check_ollama_http() + await self._check_gpu() + except asyncio.CancelledError: + break + except Exception as exc: + self.last_error = f"Monitor error: {exc}" + logger.warning("Monitor exception: %s", exc) + + await asyncio.sleep(self.interval) + + async def _check_ollama_http(self) -> None: + try: + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.get(f"{self.url}/api/tags") + if resp.status_code != 200: + self.last_error = f"Ollama returned HTTP {resp.status_code}" + else: + self.last_error = None + except Exception as exc: + self.last_error = f"Ollama unreachable: {exc}" + + async def _check_gpu(self) -> None: + nvidia_smi = shutil.which("nvidia-smi") + if nvidia_smi is None and platform.system() == "Linux": + nvidia_smi = "/usr/lib/wsl/lib/nvidia-smi" + if nvidia_smi is None: + return + + try: + result = subprocess.run( + [ + nvidia_smi, + "--query-gpu=memory.free,memory.used,temperature.gpu", + "--format=csv,noheader,nounits", + ], + capture_output=True, + text=True, + timeout=5, + ) + parts = result.stdout.strip().split(", ") + if len(parts) >= 3: + free_mb = int(parts[0]) + temp_c = int(parts[2]) + if free_mb < self.vram_critical_mb: + self.last_error = ( + f"VRAM critically low: {free_mb}MB free" + ) + elif temp_c > self.temp_critical_c: + self.last_error = f"GPU temperature critical: {temp_c}°C" + except Exception: + pass diff --git a/automated_testing/runner/os_automation.py b/automated_testing/runner/os_automation.py new file mode 100644 index 0000000000..be4923a3e3 --- /dev/null +++ b/automated_testing/runner/os_automation.py @@ -0,0 +1,266 @@ +"""Cross-platform OS-level automation utilities. + +Provides clipboard access and (opt-in) system-wide network toggling. + +IMPORTANT: Network toggling here is SYSTEM-WIDE — it kills connectivity +for the entire host, including the test runner, Skyvern, and Ollama. +For normal test runs, use PlaywrightSession.set_offline() instead, which +simulates network loss at the browser context level without affecting +the runner's infrastructure. + +The OS-level network functions are retained for CI environments where +the runner and Skyvern run on a separate machine from the app under test. +""" + +from __future__ import annotations + +import asyncio +import logging +import platform +import shutil +import subprocess + +logger = logging.getLogger(__name__) + + +def _detect_platform() -> str: + """Detect the runtime platform category.""" + system = platform.system() + if system == "Darwin": + return "macos" + if system == "Linux": + with open("/proc/version", "r") as f: + if "microsoft" in f.read().lower(): + return "wsl2" + return "linux" + if system == "Windows": + return "windows" + return "unknown" + + +PLATFORM = _detect_platform() + + +# --------------------------------------------------------------------------- +# Network toggling +# --------------------------------------------------------------------------- + +async def set_network_enabled(enabled: bool) -> tuple[bool, str]: + """Toggle the network connection at the OS level. + + Returns (success, message). + """ + action = "enable" if enabled else "disable" + logger.info("Network %s on platform=%s", action, PLATFORM) + + try: + if PLATFORM == "macos": + return await _network_macos(enabled) + elif PLATFORM == "linux": + return await _network_linux(enabled) + elif PLATFORM == "wsl2": + return await _network_wsl2(enabled) + else: + return False, f"Unsupported platform: {PLATFORM}" + except Exception as exc: + return False, f"Network toggle failed: {exc}" + + +async def _network_macos(enabled: bool) -> tuple[bool, str]: + """Toggle Wi-Fi on macOS via networksetup.""" + iface = await _get_macos_wifi_interface() + if not iface: + return False, "No Wi-Fi interface found on macOS" + + state = "on" if enabled else "off" + proc = await asyncio.create_subprocess_exec( + "networksetup", "-setairportpower", iface, state, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + if proc.returncode == 0: + return True, f"macOS Wi-Fi ({iface}) set to {state}" + return False, f"networksetup failed: {stderr.decode()}" + + +async def _get_macos_wifi_interface() -> str | None: + """Find the Wi-Fi network interface name on macOS.""" + proc = await asyncio.create_subprocess_exec( + "networksetup", "-listallhardwareports", + stdout=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + lines = stdout.decode().splitlines() + for i, line in enumerate(lines): + if "Wi-Fi" in line or "AirPort" in line: + for j in range(i + 1, min(i + 3, len(lines))): + if lines[j].strip().startswith("Device:"): + return lines[j].split(":", 1)[1].strip() + return None + + +async def _network_linux(enabled: bool) -> tuple[bool, str]: + """Toggle network on Linux via nmcli or ip.""" + if shutil.which("nmcli"): + state = "on" if enabled else "off" + proc = await asyncio.create_subprocess_exec( + "nmcli", "networking", state, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + if proc.returncode == 0: + return True, f"nmcli networking {state}" + return False, f"nmcli failed: {stderr.decode()}" + + iface = await _get_linux_default_interface() + if not iface: + return False, "No default network interface found" + + action = "up" if enabled else "down" + proc = await asyncio.create_subprocess_exec( + "sudo", "ip", "link", "set", iface, action, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + if proc.returncode == 0: + return True, f"ip link set {iface} {action}" + return False, f"ip link failed: {stderr.decode()}" + + +async def _get_linux_default_interface() -> str | None: + """Find the default network interface on Linux.""" + proc = await asyncio.create_subprocess_exec( + "ip", "route", "show", "default", + stdout=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + parts = stdout.decode().split() + if "dev" in parts: + idx = parts.index("dev") + if idx + 1 < len(parts): + return parts[idx + 1] + return None + + +async def _network_wsl2(enabled: bool) -> tuple[bool, str]: + """Toggle network from WSL2 by calling PowerShell on the Windows host. + + Uses iptables to block/unblock outbound traffic from WSL2 since + directly toggling the Windows adapter from WSL2 requires elevated + privileges on the host. + """ + if shutil.which("iptables"): + if enabled: + proc = await asyncio.create_subprocess_exec( + "sudo", "iptables", "-D", "OUTPUT", "-j", "DROP", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + else: + proc = await asyncio.create_subprocess_exec( + "sudo", "iptables", "-A", "OUTPUT", "-j", "DROP", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + if proc.returncode == 0: + state = "restored" if enabled else "blocked" + return True, f"WSL2 outbound traffic {state} via iptables" + return False, f"iptables failed: {stderr.decode()}" + + return False, "iptables not available in WSL2" + + +# --------------------------------------------------------------------------- +# Clipboard +# --------------------------------------------------------------------------- + +async def read_clipboard() -> tuple[bool, str]: + """Read the system clipboard contents. + + Returns (success, clipboard_text_or_error). + """ + try: + if PLATFORM == "macos": + proc = await asyncio.create_subprocess_exec( + "pbpaste", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + if proc.returncode == 0: + return True, stdout.decode() + return False, f"pbpaste failed (rc={proc.returncode}): {stderr.decode()}" + + elif PLATFORM in ("linux", "wsl2"): + for cmd in (["xclip", "-selection", "clipboard", "-o"], + ["xsel", "--clipboard", "--output"]): + if shutil.which(cmd[0]): + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + if proc.returncode == 0: + return True, stdout.decode() + + if PLATFORM == "wsl2" and shutil.which("powershell.exe"): + proc = await asyncio.create_subprocess_exec( + "powershell.exe", "-Command", "Get-Clipboard", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + if proc.returncode == 0: + return True, stdout.decode().strip() + + return False, "No clipboard tool found (install xclip or xsel)" + + return False, f"Unsupported platform: {PLATFORM}" + except Exception as exc: + return False, f"Clipboard read failed: {exc}" + + +async def write_clipboard(text: str) -> tuple[bool, str]: + """Write text to the system clipboard.""" + try: + if PLATFORM == "macos": + proc = await asyncio.create_subprocess_exec( + "pbcopy", + stdin=asyncio.subprocess.PIPE, + ) + await proc.communicate(input=text.encode()) + return True, "Clipboard written (macOS)" + + elif PLATFORM in ("linux", "wsl2"): + for cmd in (["xclip", "-selection", "clipboard"], + ["xsel", "--clipboard", "--input"]): + if shutil.which(cmd[0]): + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + ) + await proc.communicate(input=text.encode()) + if proc.returncode == 0: + return True, f"Clipboard written ({cmd[0]})" + + if PLATFORM == "wsl2" and shutil.which("powershell.exe"): + proc = await asyncio.create_subprocess_exec( + "powershell.exe", "-Command", + "$input | Set-Clipboard", + stdin=asyncio.subprocess.PIPE, + ) + await proc.communicate(input=text.encode()) + if proc.returncode == 0: + return True, "Clipboard written (WSL2/PowerShell)" + return False, f"PowerShell Set-Clipboard failed (rc={proc.returncode})" + + return False, "No clipboard tool found" + + return False, f"Unsupported platform: {PLATFORM}" + except Exception as exc: + return False, f"Clipboard write failed: {exc}" diff --git a/automated_testing/runner/playwright_helpers.py b/automated_testing/runner/playwright_helpers.py new file mode 100644 index 0000000000..2bea16a113 --- /dev/null +++ b/automated_testing/runner/playwright_helpers.py @@ -0,0 +1,287 @@ +"""Direct Playwright automation for tasks that Skyvern cannot handle. + +Provides browser lifecycle management, viewport resizing, file downloads, +keyboard navigation auditing, accessibility scanning, and clock mocking. +These run in a separate Playwright instance from Skyvern's browser. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +class PlaywrightSession: + """Manages a direct Playwright browser session for composite tests. + + This is separate from Skyvern's internal Playwright instance. + Used for OS-level browser manipulation that Skyvern's API doesn't expose. + """ + + def __init__(self, headless: bool = False): + self.headless = headless + self._pw = None + self._browser = None + self._context = None + self._page = None + + async def start(self, viewport: dict | None = None) -> None: + from playwright.async_api import async_playwright + self._pw = await async_playwright().start() + self._browser = await self._pw.chromium.launch(headless=self.headless) + ctx_opts = {"accept_downloads": True} + if viewport: + ctx_opts["viewport"] = viewport + self._context = await self._browser.new_context(**ctx_opts) + await self._context.grant_permissions( + ["clipboard-read", "clipboard-write"] + ) + self._page = await self._context.new_page() + + async def stop(self) -> None: + if self._browser: + await self._browser.close() + if self._pw: + await self._pw.stop() + + @property + def page(self): + return self._page + + @property + def context(self): + return self._context + + @property + def browser(self): + return self._browser + + # ------------------------------------------------------------------- + # Browser lifecycle (simulate app restart) + # ------------------------------------------------------------------- + + async def restart_session( + self, url: str, viewport: dict | None = None + ) -> None: + """Close the current context and open a fresh one (simulates app restart). + + Cookies, localStorage, and sessionStorage are wiped. + """ + if self._context: + await self._context.close() + + ctx_opts = {"accept_downloads": True} + if viewport: + ctx_opts["viewport"] = viewport + self._context = await self._browser.new_context(**ctx_opts) + await self._context.grant_permissions( + ["clipboard-read", "clipboard-write"] + ) + self._page = await self._context.new_page() + await self._page.goto(url, wait_until="networkidle") + + # ------------------------------------------------------------------- + # Navigation + # ------------------------------------------------------------------- + + async def navigate(self, url: str, wait: str = "networkidle") -> None: + await self._page.goto(url, wait_until=wait) + + async def wait_for_flutter(self, seconds: float = 3.0) -> None: + """Wait for Flutter canvas to finish rendering.""" + await asyncio.sleep(seconds) + + # ------------------------------------------------------------------- + # Network simulation (per-browser-context, not system-wide) + # ------------------------------------------------------------------- + + async def set_offline(self, offline: bool = True) -> None: + """Simulate network loss/restore at the browser context level. + + This only affects the browser — the test runner, Skyvern, and Ollama + remain fully connected. + """ + await self._context.set_offline(offline) + + # ------------------------------------------------------------------- + # Viewport / responsive + # ------------------------------------------------------------------- + + async def set_viewport(self, width: int, height: int) -> None: + await self._page.set_viewport_size({"width": width, "height": height}) + + async def take_screenshot(self, path: str | Path) -> str: + await self._page.screenshot(path=str(path), full_page=True) + return str(path) + + # ------------------------------------------------------------------- + # Clipboard + # ------------------------------------------------------------------- + + async def read_clipboard(self) -> str: + return await self._page.evaluate( + "async () => await navigator.clipboard.readText()" + ) + + async def write_clipboard(self, text: str) -> None: + await self._page.evaluate( + "async (t) => await navigator.clipboard.writeText(t)", text + ) + + # ------------------------------------------------------------------- + # Clock mocking + # ------------------------------------------------------------------- + + async def mock_clock(self, fake_time: datetime) -> None: + """Set a fixed fake time for Date.now() and new Date().""" + await self._page.clock.set_fixed_time(fake_time) + + async def reset_clock(self) -> None: + """Remove clock mock by reloading without the override.""" + url = self._page.url + await self._page.reload() + await self.wait_for_flutter() + + # ------------------------------------------------------------------- + # File downloads + # ------------------------------------------------------------------- + + async def trigger_download_and_capture( + self, click_selector: str | None = None, click_text: str | None = None + ) -> dict: + """Click an element that triggers a download and capture the file. + + Returns dict with: path, filename, size, content_preview. + """ + async with self._page.expect_download() as dl_info: + if click_selector: + await self._page.click(click_selector) + elif click_text: + await self._page.get_by_text(click_text).click() + else: + raise ValueError("Provide click_selector or click_text") + + download = await dl_info.value + tmp = tempfile.mktemp(suffix=f"_{download.suggested_filename}") + await download.save_as(tmp) + + content = Path(tmp).read_text(encoding="utf-8", errors="replace") + return { + "path": tmp, + "filename": download.suggested_filename, + "size": Path(tmp).stat().st_size, + "content_preview": content[:500], + "is_valid_json": _is_valid_json(content), + } + + # ------------------------------------------------------------------- + # Keyboard navigation audit + # ------------------------------------------------------------------- + + async def keyboard_navigation_audit( + self, max_tabs: int = 100 + ) -> dict: + """Tab through the page and record focus order. + + Returns dict with: focused_elements (list), traps_detected (bool), + total_tabbable (int). + """ + focused_elements = [] + seen_tags = set() + trap_count = 0 + prev_element = None + + for i in range(max_tabs): + await self._page.keyboard.press("Tab") + await asyncio.sleep(0.15) + + info = await self._page.evaluate("""() => { + const el = document.activeElement; + if (!el || el === document.body) return null; + return { + tag: el.tagName, + role: el.getAttribute('role'), + label: el.getAttribute('aria-label') || el.textContent?.slice(0, 50), + id: el.id, + tabIndex: el.tabIndex, + }; + }""") + + if info is None: + continue + + element_key = f"{info['tag']}:{info.get('id', '')}:{info.get('label', '')}" + + if element_key == prev_element: + trap_count += 1 + if trap_count > 3: + break + else: + trap_count = 0 + + if element_key not in seen_tags: + focused_elements.append(info) + seen_tags.add(element_key) + + prev_element = element_key + + return { + "focused_elements": focused_elements, + "total_tabbable": len(focused_elements), + "traps_detected": trap_count > 3, + } + + # ------------------------------------------------------------------- + # Accessibility audit (axe-core) + # ------------------------------------------------------------------- + + async def accessibility_audit(self) -> dict: + """Run axe-core accessibility scan on the current page. + + Returns dict with: violations_count, violations (list), + passes_count. + """ + try: + from axe_playwright_python.async_playwright import Axe + axe = Axe() + results = await axe.run(self._page) + + response = getattr(results, "response", {}) or {} + raw_violations = response.get("violations", []) + raw_passes = response.get("passes", []) + + violations = [ + { + "id": v.get("id"), + "impact": v.get("impact"), + "description": v.get("description"), + "nodes_count": len(v.get("nodes", [])), + } + for v in raw_violations + ] + + return { + "violations_count": len(raw_violations), + "violations": violations, + "passes_count": len(raw_passes), + } + except ImportError: + logger.warning("axe-playwright-python not installed — skipping a11y audit") + return {"violations_count": -1, "error": "axe-playwright-python not installed"} + except Exception as exc: + return {"violations_count": -1, "error": str(exc)} + + +def _is_valid_json(content: str) -> bool: + try: + json.loads(content) + return True + except (json.JSONDecodeError, ValueError): + return False diff --git a/automated_testing/runner/preflight.py b/automated_testing/runner/preflight.py new file mode 100644 index 0000000000..e8a9de4f73 --- /dev/null +++ b/automated_testing/runner/preflight.py @@ -0,0 +1,131 @@ +"""Pre-flight health checks — validates infrastructure before any tests run.""" + +from __future__ import annotations + +import asyncio +import platform +import shutil +import subprocess + +import httpx + + +async def check_ollama(url: str = "http://localhost:11434") -> tuple[bool, str]: + """Verify Ollama is running and has at least one model loaded.""" + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(f"{url}/api/tags") + models = resp.json().get("models", []) + if len(models) > 0: + names = [m.get("name", "?") for m in models] + return True, f"Ollama OK — models: {', '.join(names)}" + return False, "Ollama running but no models loaded" + except Exception as exc: + return False, f"Ollama unreachable: {exc}" + + +async def check_ollama_inference( + url: str = "http://localhost:11434", + model: str = "qwen2.5-vl:32b", +) -> tuple[bool, str]: + """Run a trivial inference to confirm the GPU pipeline works.""" + try: + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.post( + f"{url}/api/generate", + json={ + "model": model, + "prompt": "Reply with only the word OK.", + "stream": False, + }, + ) + output = resp.json().get("response", "").strip() + if "ok" in output.lower(): + return True, "Ollama inference OK" + return False, f"Ollama inference unexpected output: {output!r}" + except Exception as exc: + return False, f"Ollama inference failed: {exc}" + + +async def check_vram(min_gb: float = 15.0) -> tuple[bool, str]: + """Verify sufficient VRAM is free. + + Handles both Linux native nvidia-smi and Windows (via WSL2 or native). + """ + nvidia_smi = shutil.which("nvidia-smi") + if nvidia_smi is None and platform.system() == "Linux": + nvidia_smi = "/usr/lib/wsl/lib/nvidia-smi" + + if nvidia_smi is None: + return True, "nvidia-smi not found — skipping VRAM check" + + try: + result = subprocess.run( + [nvidia_smi, "--query-gpu=memory.free", "--format=csv,noheader,nounits"], + capture_output=True, + text=True, + timeout=5, + ) + free_mb = int(result.stdout.strip().split("\n")[0]) + free_gb = free_mb / 1024 + if free_gb >= min_gb: + return True, f"VRAM OK — {free_gb:.1f} GB free" + return False, f"VRAM low — {free_gb:.1f} GB free (need {min_gb}+ GB)" + except Exception as exc: + return True, f"VRAM check skipped: {exc}" + + +async def check_skyvern(url: str = "http://localhost:8000") -> tuple[bool, str]: + """Verify Skyvern server responds to heartbeat.""" + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(f"{url}/api/v1/heartbeat") + if resp.status_code == 200: + return True, "Skyvern OK" + return False, f"Skyvern returned HTTP {resp.status_code}" + except Exception as exc: + return False, f"Skyvern unreachable: {exc}" + + +async def check_app(url: str) -> tuple[bool, str]: + """Verify the Flutter app is reachable.""" + try: + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + resp = await client.get(url) + if 200 <= resp.status_code < 300: + return True, f"App OK — HTTP {resp.status_code}" + return False, f"App returned HTTP {resp.status_code}" + except Exception as exc: + return False, f"App unreachable: {exc}" + + +async def run_preflight( + config: dict, + *, + ollama_url: str = "http://localhost:11434", + skyvern_url: str = "http://localhost:8000", + vram_min_gb: float = 15.0, +) -> tuple[bool, list[tuple[str, bool, str]]]: + """Run all pre-flight checks. Returns (all_ok, list of (name, ok, message)).""" + base_url = config.get("base_url") + if not base_url: + return False, [("Config", False, "config.base_url is missing from test matrix")] + + checks = await asyncio.gather( + check_ollama(ollama_url), + check_vram(vram_min_gb), + check_skyvern(skyvern_url), + check_app(base_url), + ) + + names = ["Ollama", "VRAM", "Skyvern", "App"] + results = [(names[i], checks[i][0], checks[i][1]) for i in range(len(checks))] + all_ok = all(ok for _, ok, _ in results) + + if all_ok: + model = config.get("ollama_model", "qwen2.5-vl:32b") + inf_ok, inf_msg = await check_ollama_inference(ollama_url, model) + results.append(("Inference", inf_ok, inf_msg)) + all_ok = inf_ok + + return all_ok, results diff --git a/automated_testing/runner/prompt_builder.py b/automated_testing/runner/prompt_builder.py new file mode 100644 index 0000000000..10ca2fbc14 --- /dev/null +++ b/automated_testing/runner/prompt_builder.py @@ -0,0 +1,78 @@ +"""Prompt construction for Skyvern tasks targeting Flutter web apps.""" + +from __future__ import annotations + +from .models import TestCase + +FLUTTER_PREAMBLE = """\ +IMPORTANT CONTEXT: +This is a Flutter web application rendered entirely on an HTML canvas element. +You cannot use DOM selectors — you must identify all elements visually. + +Before taking any action on each new screen: +1. Wait 2 seconds for the page to fully render (Flutter animations to complete). +2. If you see a loading spinner, circular progress indicator, or skeleton + placeholders, wait until they disappear before proceeding. +3. If the screen appears blank or only shows a solid color, wait 3 more + seconds — Flutter may still be initialising. + +If you are unsure whether an element is a button or just text, look for +visual cues: rounded corners, drop shadows, background color contrast, +or iconography that suggests interactivity. +""" + +COMPLETION_SUFFIX = """ +After completing the task, clearly state whether you succeeded or encountered +an error. If you see an error message, snackbar, or alert dialog on screen, +report its exact text in your response.""" + + +def build_stepped_prompt(steps: list[dict]) -> str: + """Convert checkpoint-based steps into a single sequential prompt.""" + lines = [] + for i, step in enumerate(steps, 1): + action = step.get("action", "") if isinstance(step, dict) else step.action + checkpoint = step.get("checkpoint") if isinstance(step, dict) else step.checkpoint + + lines.append(f"STEP {i}: {action.strip()}") + if checkpoint: + lines.append( + f" → BEFORE proceeding to step {i + 1}, verify: {checkpoint}" + ) + lines.append( + f" → If this verification FAILS, STOP and report which step " + f"failed and why." + ) + lines.append("") + return "\n".join(lines) + + +def build_prompt( + test: TestCase, + setup_prompt: str | None = None, +) -> str: + """Assemble the full prompt for a Skyvern task. + + Combines the Flutter preamble, optional setup phase, the test body + (either stepped or freeform), and the completion suffix. + """ + parts: list[str] = [FLUTTER_PREAMBLE] + + if setup_prompt: + parts.append(f"PHASE 1 — SETUP:\n{setup_prompt.strip()}\n") + parts.append("After setup is complete, proceed immediately to Phase 2.\n") + parts.append("PHASE 2 — TEST:") + + if test.steps: + step_dicts = [ + {"action": s.action, "checkpoint": s.checkpoint} + for s in test.steps + ] + parts.append(build_stepped_prompt(step_dicts)) + elif test.prompt: + parts.append(test.prompt.strip()) + else: + parts.append(f"Complete the following: {test.name}") + + parts.append(COMPLETION_SUFFIX) + return "\n".join(parts) diff --git a/automated_testing/runner/reporter.py b/automated_testing/runner/reporter.py new file mode 100644 index 0000000000..56d80caa55 --- /dev/null +++ b/automated_testing/runner/reporter.py @@ -0,0 +1,168 @@ +"""HTML report generator for test run results.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from jinja2 import Template + +from .models import TestRun + +REPORT_TEMPLATE = Template("""\ + + + + + +Gleec QA Report — {{ run.timestamp }} + + + +

Gleec Wallet QA Report

+
+ {{ run.timestamp }} · {{ run.base_url }} · + Engine: {{ run.engine }} · Model: {{ run.model }} · + Duration: {{ "%.0f"|format(run.duration_seconds) }}s +
+ +
+
{{ run.total }}
Total
+
{{ run.passed }}
Passed
+
{{ run.failed }}
Failed
+
{{ run.flaky }}
Flaky
+
{{ run.errors }}
Errors
+ +
{{ run.pass_rate }}%
Pass Rate
+
+ +

Automated Tests

+ + + + + + + + +{% for r in run.voted_results %} + + + + + + + + + + +{% endfor %} + +
IDNameTagsStatusConfidenceVotesDurationDetails
{{ r.test_id }}{{ r.test_name }}{{ r.tags | join(', ') }}{{ r.final_status }}{{ "%.0f"|format(r.confidence * 100) }}%{{ r.vote_counts }}{{ "%.1f"|format(r.duration_seconds) }}s + {% if r.attempts %} +
+ {{ r.attempts | length }} attempt(s) +
{{ r.attempts | tojson }}
+
+ {% endif %} + {% if r.manual_verification_note %} +
Manual check: {{ r.manual_verification_note }}
+ {% endif %} +
+ +{% if run.manual_results %} +

Manual / Interactive Tests

+ + + + + +{% for m in run.manual_results %} + + + + + + +{% endfor %} + +
IDTitleStatusNotes
{{ m.test_id }}{{ m.title }}{{ m.status }}{{ m.notes }}
+{% endif %} + + + +""") + + +def generate_html_report(run: TestRun, output_path: Path) -> None: + """Render the test run as a styled HTML report.""" + html = REPORT_TEMPLATE.render(run=run.model_dump()) + output_path.write_text(html, encoding="utf-8") + + +def write_json_results(run: TestRun, output_path: Path) -> None: + """Write the test run as a structured JSON file.""" + output_path.write_text( + json.dumps(run.model_dump(), indent=2, default=str), + encoding="utf-8", + ) diff --git a/automated_testing/runner/retry.py b/automated_testing/runner/retry.py new file mode 100644 index 0000000000..99e5d983cd --- /dev/null +++ b/automated_testing/runner/retry.py @@ -0,0 +1,89 @@ +"""Majority vote and retry logic for non-deterministic vision-based tests.""" + +from __future__ import annotations + +from collections import Counter + +from .models import AttemptResult, VotedResult + + +def majority_vote(attempts: list[AttemptResult], test_id: str, test_name: str, + tags: list[str], expected: str) -> VotedResult: + """Determine final verdict from multiple attempts using majority vote. + + Rules: + - If ALL attempts agree → that status, confidence 1.0 + - If majority agrees → that status, confidence = majority/total + - If no majority → FLAKY, confidence = max_count/total + - If all ERROR → ERROR + - If "winner" is ERROR but PASS/FAIL exist → FLAKY + """ + statuses = [a.status for a in attempts] + counts = Counter(statuses) + total = len(attempts) + + if total == 0: + return VotedResult( + test_id=test_id, + test_name=test_name, + tags=tags, + final_status="SKIP", + vote_counts={}, + confidence=0.0, + expected=expected, + attempts=[], + ) + + most_common_status, most_common_count = counts.most_common(1)[0] + + if most_common_count > total / 2: + final_status = most_common_status + confidence = most_common_count / total + else: + final_status = "FLAKY" + confidence = most_common_count / total + + if final_status == "ERROR": + non_errors = [s for s in statuses if s != "ERROR"] + if non_errors: + final_status = "FLAKY" + + total_duration = sum(a.duration_seconds for a in attempts) + + return VotedResult( + test_id=test_id, + test_name=test_name, + tags=tags, + final_status=final_status, + vote_counts=dict(counts), + confidence=round(confidence, 2), + expected=expected, + attempts=attempts, + duration_seconds=round(total_duration, 2), + ) + + +def should_stop_early(attempts: list[AttemptResult], max_attempts: int) -> bool: + """Determine if remaining retries can be skipped. + + Early exit conditions: + - Any status has already reached the required majority for max_attempts + - No status can still reach majority with remaining attempts + """ + if not attempts: + return False + + counts = Counter(a.status for a in attempts) + majority = max_attempts // 2 + 1 + completed = len(attempts) + remaining_attempts = max(0, max_attempts - completed) + + leading_count = counts.most_common(1)[0][1] + if leading_count >= majority: + return True + + # Even if all remaining attempts match the current leader, majority is impossible. + if leading_count + remaining_attempts < majority: + return True + + return False diff --git a/automated_testing/runner/runner.py b/automated_testing/runner/runner.py new file mode 100644 index 0000000000..2c53eee58f --- /dev/null +++ b/automated_testing/runner/runner.py @@ -0,0 +1,606 @@ +"""Hardened test runner — main orchestration for Gleec QA automation. + +Usage: + python -m runner.runner [--matrix PATH] [--tag TAG] [--single] + [--include-manual] [--manual-only] + [--ollama-url URL] [--skyvern-url URL] +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +import yaml + +from .guards import TestTimeoutError, run_with_timeout +from .interactive import run_interactive_batch +from .models import ( + AttemptResult, CompositePhase, ManualResult, TestCase, TestRun, VotedResult, +) +from .ollama_monitor import OllamaMonitor +from .preflight import run_preflight +from .prompt_builder import build_prompt +from .reporter import generate_html_report, write_json_results +from .retry import majority_vote, should_stop_early + +logger = logging.getLogger(__name__) + +DEFAULT_RETRIES = 3 +CRITICAL_RETRIES = 5 + + +def load_matrix(path: str) -> dict: + with open(path, "r") as f: + return yaml.safe_load(f) + + +def load_manual_tests(path: str) -> list[dict]: + with open(path, "r") as f: + data = yaml.safe_load(f) + return data.get("manual_tests", []) + + +def parse_tests(matrix: dict) -> list[TestCase]: + tests_raw = matrix.get("tests", []) + tests = [] + for raw in tests_raw: + if "steps" in raw and raw["steps"]: + steps_parsed = [] + for s in raw["steps"]: + if isinstance(s, dict): + steps_parsed.append(s) + else: + steps_parsed.append({"action": str(s), "checkpoint": None}) + raw["steps"] = steps_parsed + if "phases" in raw and raw["phases"]: + for phase in raw["phases"]: + phase.setdefault("args", {}) + tests.append(TestCase(**raw)) + return tests + + +async def execute_single_attempt( + test: TestCase, + config: dict, + setup_prompt: Optional[str], + skyvern_url: str, +) -> AttemptResult: + """Execute a single test attempt via the Skyvern API.""" + import httpx + + full_prompt = build_prompt(test, setup_prompt) + + start = time.monotonic() + try: + base_url = config.get("base_url") + if not base_url: + return AttemptResult( + attempt=0, + status="ERROR", + error="config.base_url is missing from test matrix", + duration_seconds=0.0, + ) + + payload = { + "url": base_url, + "navigation_goal": full_prompt, + "proxy_location": "NONE", + "navigation_payload": None, + "extracted_information_schema": test.extraction_schema, + } + + engine = config.get("default_engine", "skyvern-2.0") + max_steps = test.max_steps or config.get("max_steps_per_test", 30) + + async with httpx.AsyncClient( + base_url=skyvern_url, timeout=None + ) as client: + resp = await client.post( + "/api/v1/tasks", + json=payload, + headers={"x-api-key": "local"}, + ) + if resp.status_code not in (200, 201): + return AttemptResult( + attempt=0, + status="ERROR", + skyvern_status=f"HTTP {resp.status_code}", + error=resp.text[:500], + duration_seconds=round(time.monotonic() - start, 2), + ) + + task_data = resp.json() + task_id = task_data.get("task_id", "") + + for _ in range(max_steps * 3): + await asyncio.sleep(5) + status_resp = await client.get( + f"/api/v1/tasks/{task_id}", + headers={"x-api-key": "local"}, + ) + if status_resp.status_code != 200: + continue + + task_status = status_resp.json() + skyvern_status = task_status.get("status", "") + + if skyvern_status in ("completed", "failed", "terminated"): + extracted = task_status.get("extracted_information") + is_pass = skyvern_status == "completed" + return AttemptResult( + attempt=0, + status="PASS" if is_pass else "FAIL", + skyvern_status=skyvern_status, + extracted_data=extracted, + duration_seconds=round(time.monotonic() - start, 2), + run_id=task_id, + ) + + return AttemptResult( + attempt=0, + status="ERROR", + skyvern_status="polling_timeout", + error=f"Task {task_id} did not complete within polling limit", + duration_seconds=round(time.monotonic() - start, 2), + run_id=task_id, + ) + + except TestTimeoutError as exc: + return AttemptResult( + attempt=0, + status="TIMEOUT", + error=str(exc), + duration_seconds=round(time.monotonic() - start, 2), + ) + except Exception as exc: + return AttemptResult( + attempt=0, + status="ERROR", + error=f"{type(exc).__name__}: {exc}", + duration_seconds=round(time.monotonic() - start, 2), + ) + + +async def execute_composite_attempt( + test: TestCase, + config: dict, + setup_prompt: Optional[str], + skyvern_url: str, +) -> AttemptResult: + """Execute a composite test with mixed Skyvern + OS/Playwright phases.""" + from .os_automation import read_clipboard + from .playwright_helpers import PlaywrightSession + + start = time.monotonic() + pw_session = None + phase_results = [] + + try: + for i, phase in enumerate(test.phases): + phase_type = phase.type + logger.info(" Phase %d/%d: %s — %s", + i + 1, len(test.phases), phase_type, phase.action[:60]) + + if phase_type == "skyvern": + sub_test = TestCase( + id=f"{test.id}_phase{i}", + name=phase.action[:80], + prompt=phase.prompt or phase.action, + steps=None, + expected_result=phase.expected or "", + extraction_schema=phase.extraction_schema, + max_steps=phase.max_steps or test.max_steps, + timeout=test.timeout, + ) + result = await execute_single_attempt( + sub_test, config, setup_prompt if i == 0 else None, skyvern_url + ) + phase_results.append({ + "phase": i + 1, "type": phase_type, + "status": result.status, + "extracted": result.extracted_data, + }) + if result.status != "PASS": + label = phase.checkpoint or phase.action[:80] + return AttemptResult( + attempt=0, status=result.status, + error=f"Phase {i+1} ({phase_type}) failed: {label}", + extracted_data={"phases": phase_results}, + duration_seconds=round(time.monotonic() - start, 2), + ) + + elif phase_type == "os_call": + action = phase.action + if action == "read_clipboard": + ok, text = await read_clipboard() + phase_results.append({"phase": i+1, "type": "os_call", "action": action, "ok": ok, "text": text}) + elif action == "wait": + seconds = phase.args.get("seconds", 5) + await asyncio.sleep(seconds) + phase_results.append({"phase": i+1, "type": "os_call", "action": "wait", "seconds": seconds}) + else: + phase_results.append({"phase": i+1, "type": "os_call", "action": action, "error": "unknown action"}) + + elif phase_type == "playwright": + if pw_session is None: + pw_session = PlaywrightSession(headless=False) + await pw_session.start() + await pw_session.navigate(config["base_url"]) + await pw_session.wait_for_flutter() + + action = phase.action + result_data = {} + + if action == "set_offline": + await pw_session.set_offline(True) + result_data = {"offline": True} + + elif action == "set_online": + await pw_session.set_offline(False) + result_data = {"offline": False} + + elif action == "restart_session": + await pw_session.restart_session(config["base_url"]) + await pw_session.wait_for_flutter(5.0) + result_data = {"restarted": True} + + elif action == "set_viewport": + w = phase.args.get("width", 1280) + h = phase.args.get("height", 800) + await pw_session.set_viewport(w, h) + await pw_session.wait_for_flutter(2.0) + result_data = {"viewport": f"{w}x{h}"} + + elif action == "screenshot": + path = phase.args.get("path", f"results/screenshots/{test.id}_phase{i}.png") + await pw_session.take_screenshot(path) + result_data = {"screenshot": path} + + elif action == "navigate": + base = config.get("base_url", "") + suffix = phase.args.get("url_suffix", "") + url = phase.args.get("url", base + suffix) + await pw_session.navigate(url) + await pw_session.wait_for_flutter() + result_data = {"navigated_to": url} + + elif action == "mock_clock": + from datetime import datetime as dt, timedelta + offset_hours = phase.args.get("offset_hours", 8760) + fake = dt.now() + timedelta(hours=offset_hours) + await pw_session.mock_clock(fake) + result_data = {"mocked_time": str(fake)} + + elif action == "reset_clock": + await pw_session.reset_clock() + result_data = {"clock_reset": True} + + elif action == "keyboard_audit": + audit = await pw_session.keyboard_navigation_audit( + max_tabs=phase.args.get("max_tabs", 100) + ) + result_data = audit + + elif action == "accessibility_audit": + audit = await pw_session.accessibility_audit() + result_data = audit + + elif action == "capture_download": + dl = await pw_session.trigger_download_and_capture( + click_text=phase.args.get("click_text"), + click_selector=phase.args.get("click_selector"), + ) + result_data = dl + + elif action == "read_clipboard": + text = await pw_session.read_clipboard() + result_data = {"clipboard": text} + + else: + result_data = {"error": f"Unknown playwright action: {action}"} + + phase_results.append({"phase": i+1, "type": "playwright", "action": action, **result_data}) + + elif phase_type == "assert": + prev = phase_results[-1] if phase_results else {} + check_key = phase.args.get("key", "") + check_value = phase.args.get("value") + check_contains = phase.args.get("contains") + actual = prev.get(check_key) + + passed = False + if check_value is not None: + passed = actual == check_value + elif check_contains is not None and isinstance(actual, str): + passed = check_contains in actual + elif check_key == "ok": + passed = prev.get("ok", False) is True + else: + passed = actual is not None + + phase_results.append({ + "phase": i+1, "type": "assert", + "key": check_key, "expected": check_value or check_contains, + "actual": actual, "passed": passed, + }) + if not passed: + return AttemptResult( + attempt=0, status="FAIL", + error=f"Assertion failed at phase {i+1}: {check_key}={actual!r}", + extracted_data={"phases": phase_results}, + duration_seconds=round(time.monotonic() - start, 2), + ) + + return AttemptResult( + attempt=0, status="PASS", + extracted_data={"phases": phase_results}, + duration_seconds=round(time.monotonic() - start, 2), + ) + + except Exception as exc: + return AttemptResult( + attempt=0, status="ERROR", + error=f"{type(exc).__name__}: {exc}", + extracted_data={"phases": phase_results}, + duration_seconds=round(time.monotonic() - start, 2), + ) + finally: + if pw_session: + try: + await pw_session.set_offline(False) + except Exception: + pass + try: + await pw_session.stop() + except Exception: + pass + + +async def run_test_with_retries( + test: TestCase, + config: dict, + setup_prompt: Optional[str], + monitor: OllamaMonitor, + skyvern_url: str, + single: bool = False, +) -> VotedResult: + """Run a test with majority vote across multiple attempts.""" + is_critical = "critical" in test.tags + num_attempts = 1 if single else (CRITICAL_RETRIES if is_critical else DEFAULT_RETRIES) + timeout = test.timeout or config.get("timeout_per_test", 180) + + attempts: list[AttemptResult] = [] + + for i in range(num_attempts): + if not monitor.healthy: + attempts.append(AttemptResult( + attempt=i + 1, + status="ERROR", + error=f"Ollama unhealthy: {monitor.last_error}", + duration_seconds=0.0, + )) + break + + logger.info( + " Attempt %d/%d for %s", i + 1, num_attempts, test.id + ) + + if test.is_composite: + coro = execute_composite_attempt(test, config, setup_prompt, skyvern_url) + else: + coro = execute_single_attempt(test, config, setup_prompt, skyvern_url) + try: + result = await run_with_timeout(coro, timeout, test.id) + except TestTimeoutError as exc: + result = AttemptResult( + attempt=i + 1, + status="TIMEOUT", + error=str(exc), + duration_seconds=float(timeout), + ) + + result.attempt = i + 1 + attempts.append(result) + + if should_stop_early(attempts, num_attempts): + logger.info(" Early exit for %s after %d attempts", test.id, len(attempts)) + break + + voted = majority_vote( + attempts, + test_id=test.id, + test_name=test.name, + tags=test.tags, + expected=test.expected_result, + ) + voted.manual_verification_note = test.manual_verification_note + return voted + + +async def main( + matrix_path: str, + tag_filter: Optional[str] = None, + single: bool = False, + include_manual: bool = False, + manual_only: bool = False, + ollama_url: str = "http://localhost:11434", + skyvern_url: str = "http://localhost:8000", +) -> int: + matrix = load_matrix(matrix_path) + config = matrix.get("config", {}) + setup_prompt = matrix.get("setup", {}).get("prompt") + + run = TestRun( + base_url=config.get("base_url", ""), + engine=config.get("default_engine", "skyvern-2.0"), + ) + + # ----------------------------------------------------------------------- + # Pre-flight + # ----------------------------------------------------------------------- + if not manual_only: + logger.info("Running pre-flight checks...") + all_ok, checks = await run_preflight( + config, ollama_url=ollama_url, skyvern_url=skyvern_url + ) + for name, ok, msg in checks: + status = "OK" if ok else "FAIL" + logger.info(" [%s] %s: %s", status, name, msg) + + if not all_ok: + logger.error("Pre-flight checks failed — aborting.") + return 2 + + # ----------------------------------------------------------------------- + # Automated tests + # ----------------------------------------------------------------------- + if not manual_only: + tests = parse_tests(matrix) + if tag_filter: + tests = [t for t in tests if tag_filter in t.tags] + + logger.info("Running %d automated test(s)...", len(tests)) + + monitor = OllamaMonitor(ollama_url=ollama_url) + await monitor.start() + + for i, test in enumerate(tests, 1): + logger.info("[%d/%d] %s — %s", i, len(tests), test.id, test.name) + voted = await run_test_with_retries( + test, config, setup_prompt, monitor, skyvern_url, single + ) + run.voted_results.append(voted) + logger.info( + " Result: %s (confidence=%.0f%%)", + voted.final_status, + voted.confidence * 100, + ) + + await monitor.stop() + + # ----------------------------------------------------------------------- + # Interactive/manual tests + # ----------------------------------------------------------------------- + if include_manual or manual_only: + manual_path = Path(matrix_path).parent / "manual_companion.yaml" + if manual_path.exists(): + manual_tests = load_manual_tests(str(manual_path)) + logger.info("Running %d interactive/manual test(s)...", len(manual_tests)) + manual_results = await run_interactive_batch(manual_tests, tag_filter) + run.manual_results = manual_results + else: + logger.warning("manual_companion.yaml not found at %s", manual_path) + + # ----------------------------------------------------------------------- + # Results + # ----------------------------------------------------------------------- + run.compute_summary() + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + run_dir = Path("results") / f"run_{timestamp}" + run_dir.mkdir(parents=True, exist_ok=True) + + write_json_results(run, run_dir / "results.json") + generate_html_report(run, run_dir / "report.html") + + logger.info("") + logger.info("=" * 60) + logger.info(" RESULTS SUMMARY") + logger.info("=" * 60) + logger.info(" Total: %d | Passed: %d | Failed: %d | Flaky: %d | Errors: %d | Skipped: %d", + run.total, run.passed, run.failed, run.flaky, run.errors, run.skipped) + logger.info(" Pass rate: %.1f%%", run.pass_rate) + logger.info(" Duration: %.0fs", run.duration_seconds) + logger.info(" Report: %s", run_dir / "report.html") + logger.info(" JSON: %s", run_dir / "results.json") + logger.info("=" * 60) + + if run.manual_results: + manual_passed = sum(1 for m in run.manual_results if m.status == "PASS") + manual_failed = sum(1 for m in run.manual_results if m.status == "FAIL") + logger.info(" Manual: %d passed, %d failed, %d skipped", + manual_passed, manual_failed, + len(run.manual_results) - manual_passed - manual_failed) + + # Exit codes + if run.failed > 0 or run.errors > 0: + return 1 + elif run.flaky > 0: + return 3 + return 0 + + +def cli() -> None: + parser = argparse.ArgumentParser( + description="Gleec Wallet QA Automation Runner" + ) + parser.add_argument( + "--matrix", + default="test_matrix.yaml", + help="Path to test_matrix.yaml (default: test_matrix.yaml)", + ) + parser.add_argument( + "--tag", + default=None, + help="Filter tests by tag (e.g., smoke, critical, p0)", + ) + parser.add_argument( + "--single", + action="store_true", + help="Single attempt per test (no majority vote)", + ) + parser.add_argument( + "--include-manual", + action="store_true", + help="Include interactive/manual tests after automated suite", + ) + parser.add_argument( + "--manual-only", + action="store_true", + help="Run only manual/interactive tests (skip automated)", + ) + parser.add_argument( + "--ollama-url", + default="http://localhost:11434", + help="Ollama server URL", + ) + parser.add_argument( + "--skyvern-url", + default="http://localhost:8000", + help="Skyvern server URL", + ) + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Enable debug logging", + ) + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", + ) + + exit_code = asyncio.run(main( + matrix_path=args.matrix, + tag_filter=args.tag, + single=args.single, + include_manual=args.include_manual, + manual_only=args.manual_only, + ollama_url=args.ollama_url, + skyvern_url=args.skyvern_url, + )) + sys.exit(exit_code) + + +if __name__ == "__main__": + cli() diff --git a/automated_testing/setup.sh b/automated_testing/setup.sh new file mode 100755 index 0000000000..6aa26951d9 --- /dev/null +++ b/automated_testing/setup.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "=== Gleec QA Automation Setup ===" + +# --------------------------------------------------------------------------- +# Platform detection +# --------------------------------------------------------------------------- +IS_WSL=false +IS_WINDOWS_HOST=false + +if grep -qi microsoft /proc/version 2>/dev/null; then + IS_WSL=true + IS_WINDOWS_HOST=true + echo "[platform] Running inside WSL2 (Windows host detected)" +elif [[ "$(uname -s)" == *MINGW* ]] || [[ "$(uname -s)" == *MSYS* ]]; then + IS_WINDOWS_HOST=true + echo "[platform] Running on native Windows — please use WSL2 for the runner" + exit 1 +else + echo "[platform] Running on Linux/macOS" +fi + +# --------------------------------------------------------------------------- +# 1. Ollama +# --------------------------------------------------------------------------- +if $IS_WSL; then + echo "[ollama] On WSL2: Ollama should run natively on Windows for best GPU performance." + echo "[ollama] Install from https://ollama.com/download/windows if not already installed." + echo "[ollama] Checking if Ollama is reachable on localhost:11434..." + if curl -sf http://localhost:11434/api/tags > /dev/null 2>&1; then + echo "[ollama] Ollama is reachable from WSL2." + else + echo "[ollama] WARNING: Ollama not reachable on localhost:11434." + echo "[ollama] Start Ollama on Windows and ensure it listens on all interfaces." + echo "[ollama] Set OLLAMA_HOST=0.0.0.0 in Windows environment variables if needed." + fi +else + if ! command -v ollama &> /dev/null; then + echo "[ollama] Installing Ollama..." + curl -fsSL https://ollama.com/install.sh | sh + else + echo "[ollama] Ollama already installed." + fi + + if ! curl -sf http://localhost:11434/api/tags > /dev/null 2>&1; then + echo "[ollama] Starting Ollama server..." + ollama serve & + sleep 3 + fi +fi + +# --------------------------------------------------------------------------- +# 2. Pull vision model +# --------------------------------------------------------------------------- +echo "[model] Pulling qwen2.5-vl:32b (this may take a while on first run)..." +if $IS_WSL; then + echo "[model] Run 'ollama pull qwen2.5-vl:32b' on your Windows host if not already pulled." +else + ollama pull qwen2.5-vl:32b +fi + +# --------------------------------------------------------------------------- +# 3. Create directory structure +# --------------------------------------------------------------------------- +echo "[dirs] Creating project directories..." +mkdir -p results/screenshots results/videos + +# --------------------------------------------------------------------------- +# 4. Environment file +# --------------------------------------------------------------------------- +if [ ! -f .env ]; then + echo "[env] Creating .env from .env.example..." + cp .env.example .env + echo "[env] Edit .env to set your APP_BASE_URL and other overrides." +else + echo "[env] .env already exists, skipping." +fi + +# --------------------------------------------------------------------------- +# 5. Python dependencies +# --------------------------------------------------------------------------- +echo "[python] Installing Python dependencies..." +pip install -r requirements.txt + +# --------------------------------------------------------------------------- +# 6. Docker stack +# --------------------------------------------------------------------------- +echo "[docker] Starting Skyvern + PostgreSQL..." +docker compose up -d + +echo "" +echo "=== Setup complete ===" +echo " Ollama: http://localhost:11434" +echo " Skyvern: http://localhost:8000" +echo "" +echo "Next steps:" +echo " 1. Edit .env if needed (APP_BASE_URL, model choice, etc.)" +echo " 2. Run smoke test: python -m runner.runner --tag smoke" +echo " 3. Run full suite: python -m runner.runner" +echo "" diff --git a/automated_testing/test_matrix.yaml b/automated_testing/test_matrix.yaml new file mode 100644 index 0000000000..948791ed5b --- /dev/null +++ b/automated_testing/test_matrix.yaml @@ -0,0 +1,2807 @@ +# ============================================================================= +# Gleec Wallet — Skyvern Automated Test Matrix (Web Only) +# ============================================================================= +# +# Converted from GLEEC_WALLET_MANUAL_TEST_CASES.md for the Skyvern + Ollama +# vision-based QA runner. Only Grade-A (fully automatable) and Grade-B +# (partially automatable UI steps) tests are included. +# +# Platform scope: Web (Chrome) — Flutter canvas-rendered application. +# Test assets: DOC/MARTY testnet coins via in-app faucet. +# +# IMPORTANT: Before running, fill in the values under `test_data` with +# your actual environment-specific addresses, seeds, and credentials. +# +# ============================================================================= + +config: + base_url: "https://app.gleecwallet.com" # Or your QA staging URL + default_engine: "skyvern-2.0" + max_steps_per_test: 30 + timeout_per_test: 180 + screenshot_on_complete: true + video_recording: true + +# --------------------------------------------------------------------------- +# TEST DATA — Fill these in with your QA environment values +# --------------------------------------------------------------------------- +test_data: + wallet_password: "QaTestPass!2026" + wallet_password_weak: "abc" + import_seed_12: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + doc_recipient_address: "RPDPE1XqGuHXSJn9q6VAaGDoRVMEwAYjT3" + marty_recipient_address: "RQCyFA4cAyjpfzcCGnxNxC4YTQsRDVPzec" + invalid_address: "RThisIsNotAValidAddress12345" + wrong_network_address: "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18" + evm_token_contract: "0xReplaceMeWithTestTokenContract" # Replace with a real testnet ERC-20 contract for your environment + self_address: "WILL_BE_CAPTURED_DURING_TEST" + alt_doc_address: "RKXzCCaT5ukqnyJBKTr9KyEpCBHR8itEFd" + alt_marty_address: "RD8WeYCaBQSx9e6mH5hX51uZ5FxNyirawj" + +# --------------------------------------------------------------------------- +# SETUP — Runs before each test to ensure logged-in state +# --------------------------------------------------------------------------- +setup: + prompt: | + This is a Flutter web app rendered on an HTML canvas. Identify all elements visually. + Wait 3 seconds for the app to fully load after the page appears. + + If you see a login screen with a password field: + Enter the password 'QaTestPass!2026' in the password field. + Click the login or unlock button. + Wait until a dashboard or home screen with coin balances or a wallet overview appears. + + If you see a welcome screen with 'Create Wallet' and 'Import Wallet' buttons: + COMPLETE — the app is at the initial state and no login is needed for this test. + + If you already see a dashboard with coin balances: + COMPLETE — already logged in. + + COMPLETE when the dashboard or wallet overview is visible. + success_criteria: "App is either on the dashboard (logged in) or the welcome screen" + +# ============================================================================= +# TESTS — Ordered by execution dependency and priority +# ============================================================================= +tests: + + # =========================================================================== + # PHASE 1: AUTH + WALLET LIFECYCLE (P0) + # =========================================================================== + + - id: GW-AUTO-AUTH-001a + name: "Create wallet — password and seed display" + source_manual_id: GW-MAN-AUTH-001 + tags: [auth, critical, smoke, p0] + max_steps: 25 + timeout: 240 + steps: + - action: | + Look for a button labeled 'Create Wallet' or 'Create New Wallet' on the welcome screen. + Click it. + checkpoint: "A password entry form appears with at least one password input field" + - action: | + Enter 'QaTestPass!2026' into the password field. + If there is a 'Confirm Password' or second password field, enter the same password there. + Click the continue, next, or create button. + checkpoint: "A seed phrase screen appears showing 12 or 24 words arranged in a grid or numbered list" + - action: | + Read all the seed words displayed on screen. Do NOT click continue or next yet. + Look for any warning text about backing up the seed phrase. + checkpoint: "Seed words are visible on screen with a warning about backup" + - action: | + Look for a 'Continue', 'Next', 'I have backed up', or similar button. + If there is a checkbox saying 'I have backed up my seed' or similar, check it first. + Then click the continue/next button. + checkpoint: "Either a seed confirmation challenge appears (asking to select words in order) or the dashboard loads" + expected_result: "Wallet creation reaches seed display and progresses to confirmation or dashboard" + extraction_schema: + type: object + properties: + seed_words_visible: + type: boolean + description: "Whether seed words were displayed on screen" + seed_word_count: + type: integer + description: "Number of seed words shown (12 or 24)" + reached_confirmation_or_dashboard: + type: boolean + description: "Whether the flow progressed past seed display" + + - id: GW-AUTO-AUTH-001b + name: "Create wallet — complete seed confirmation" + source_manual_id: GW-MAN-AUTH-001 + tags: [auth, critical, smoke, p0] + max_steps: 30 + timeout: 300 + prompt: | + You are completing the wallet creation flow for a Gleec Wallet. + If you see a seed confirmation challenge (asking you to select or enter specific seed words + in a certain order), complete it by clicking the correct words in the correct order. + The words should be visible on screen as clickable chips or buttons. + + If you see the dashboard already, COMPLETE — no confirmation was needed. + + After completing the confirmation, look for a 'Done', 'Finish', or similar button and click it. + + COMPLETE when the main dashboard or home screen appears showing coin balances, + a wallet overview, or a coin list. + expected_result: "Dashboard is visible after completing seed confirmation" + extraction_schema: + type: object + properties: + dashboard_visible: + type: boolean + description: "Whether the dashboard/home screen loaded" + confirmation_challenge_present: + type: boolean + description: "Whether a seed confirmation challenge was shown" + + - id: GW-AUTO-AUTH-003 + name: "Import wallet from valid 12-word seed" + source_manual_id: GW-MAN-AUTH-003 + tags: [auth, critical, p0] + max_steps: 30 + timeout: 300 + steps: + - action: | + If on the welcome screen, click 'Import Wallet' or 'Restore Wallet'. + If on the dashboard already, open the wallet manager (usually in a sidebar, + top-left menu, or account icon), and look for 'Import' or 'Add Wallet' > 'Import'. + checkpoint: "A seed phrase input screen appears with text fields or a text area for entering words" + - action: | + Enter the following 12-word seed phrase into the input fields: + abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about + + If there are 12 separate input fields, enter one word per field in order. + If there is a single text area, paste the entire phrase with spaces between words. + checkpoint: "All 12 seed words are entered in the input fields" + - action: | + If a password field is shown, enter 'QaTestPass!2026' as the password. + If a confirm password field is shown, enter the same password again. + Click the 'Import', 'Restore', 'Continue', or 'Submit' button. + checkpoint: "Either a loading/syncing indicator appears or the dashboard loads" + - action: | + Wait for the import to complete. Look for the dashboard or home screen. + If you see a coin list or wallet overview, the import succeeded. + checkpoint: "The dashboard is visible with a coin list or wallet overview" + expected_result: "Wallet imported successfully and dashboard shows with coin list" + extraction_schema: + type: object + properties: + import_succeeded: + type: boolean + description: "Whether the wallet import completed" + dashboard_visible: + type: boolean + description: "Whether the dashboard loaded after import" + + - id: GW-AUTO-AUTH-004 + name: "Invalid password attempts and lockout feedback" + source_manual_id: GW-MAN-AUTH-004 + tags: [auth, critical, security, p0] + max_steps: 20 + prompt: | + You should be on the login/unlock screen with a password field. + If not, look for a logout option in settings and log out first. + + Enter the wrong password 'WrongPassword1' and click the login/unlock button. + Observe any error message that appears. + + Enter another wrong password 'WrongPassword2' and click login again. + Observe any updated error message or lockout/cooldown indicator. + + Enter a third wrong password 'WrongPassword3' and click login. + Look for any lockout message, cooldown timer, or additional security warning. + + COMPLETE after the third failed attempt. Report all error messages you see. + expected_result: "Error messages shown for wrong passwords; possible lockout after multiple attempts" + extraction_schema: + type: object + properties: + error_messages: + type: array + items: + type: string + description: "All error messages shown during failed login attempts" + lockout_triggered: + type: boolean + description: "Whether a lockout or cooldown was triggered" + lockout_message: + type: string + description: "Text of any lockout/cooldown message shown" + + # =========================================================================== + # PHASE 2: WALLET MANAGER (P0/P1) + # =========================================================================== + + - id: GW-AUTO-WAL-001 + name: "Create second wallet + rename + switch" + source_manual_id: GW-MAN-WAL-001 + tags: [wallet, critical, p0] + max_steps: 30 + steps: + - action: | + From the dashboard, open the wallet manager. Look for a wallet icon, + account selector, sidebar menu, or dropdown in the top area of the screen. + Click it to open the wallet list or wallet management area. + checkpoint: "A wallet list or wallet manager panel is visible" + - action: | + Look for a 'Create Wallet', 'Add Wallet', or '+' button and click it. + If prompted, choose 'Create New' (not import). + Enter a password if required ('QaTestPass!2026'). + Complete any seed backup flow shown (read words, confirm if needed). + checkpoint: "A second wallet has been created and appears in the wallet list" + - action: | + Look for a rename, edit, or pencil icon next to one of the wallet names. + If found, click it and rename the wallet to 'QA-Wallet-Primary'. + If no inline rename exists, look in a wallet settings/detail screen. + checkpoint: "One wallet is now named 'QA-Wallet-Primary' or a rename action was attempted" + - action: | + Switch between the wallets by clicking on the other wallet in the list. + Observe that the dashboard content or coin balances change to reflect the selected wallet. + checkpoint: "The dashboard or balance display changed after switching wallets" + expected_result: "Two wallets exist, rename works, switching updates dashboard context" + extraction_schema: + type: object + properties: + second_wallet_created: + type: boolean + rename_successful: + type: boolean + switch_changed_content: + type: boolean + wallet_count: + type: integer + + - id: GW-AUTO-WAL-002 + name: "Delete wallet with cancel and confirm" + source_manual_id: GW-MAN-WAL-002 + tags: [wallet, critical, security, p0] + max_steps: 25 + steps: + - action: | + Open the wallet manager. You should see at least two wallets. + Look for a delete, trash, or remove icon/option on the non-active wallet. + Click it. + checkpoint: "A confirmation dialog appears asking to confirm wallet deletion" + - action: | + Click 'Cancel', 'No', or the X button to dismiss the confirmation dialog. + Verify the wallet is still listed in the wallet manager. + checkpoint: "The wallet is still present in the wallet list after canceling" + - action: | + Click the delete/remove option again for the same wallet. + This time click 'Confirm', 'Delete', or 'Yes' to proceed. + checkpoint: "The wallet has been removed from the list" + expected_result: "Cancel preserves wallet; confirm removes it" + extraction_schema: + type: object + properties: + cancel_preserved_wallet: + type: boolean + confirm_deleted_wallet: + type: boolean + remaining_wallet_count: + type: integer + + # =========================================================================== + # PHASE 3: COIN MANAGEMENT + DASHBOARD (P1) + # =========================================================================== + + - id: GW-AUTO-COIN-001 + name: "Enable test coins and activate DOC/MARTY" + source_manual_id: GW-MAN-COIN-001 + tags: [coin, smoke, p1, prerequisite] + max_steps: 25 + steps: + - action: | + Navigate to Settings. Look for a gear icon, 'Settings' in the sidebar menu, + or an account/settings option in the navigation. + checkpoint: "The settings page or settings menu is visible" + - action: | + Look for a toggle or switch labeled 'Test Coins', 'Show Test Coins', + 'Enable Test Coins', or 'Testnet'. Enable/turn on this toggle. + If it is already enabled, proceed. + checkpoint: "Test coins toggle is in the ON/enabled state" + - action: | + Navigate to the Coin Manager, Coins list, or 'Add Coins' section. + Search for 'DOC' using any search or filter bar. + If DOC appears, activate/enable it by clicking its toggle or add button. + checkpoint: "DOC coin is now activated and appears in the active coins list" + - action: | + Search for 'MARTY' in the same coin manager. + Activate/enable MARTY. + checkpoint: "MARTY coin is now activated and appears in the active coins list" + expected_result: "Test coins toggle is on; DOC and MARTY are both activated" + extraction_schema: + type: object + properties: + test_coins_enabled: + type: boolean + doc_activated: + type: boolean + marty_activated: + type: boolean + + - id: GW-AUTO-COIN-002 + name: "Search, activate, deactivate coin with filter" + source_manual_id: GW-MAN-COIN-002 + tags: [coin, p1] + max_steps: 20 + prompt: | + Navigate to the Coin Manager or coin list from the dashboard. + + If there is a search or filter bar, type 'MARTY' in it. + Verify that MARTY appears in the results. + + If MARTY is currently deactivated/disabled, click its toggle or add button to activate it. + If there is an 'active only' or 'enabled only' filter option, select it. + Verify MARTY appears in the filtered list. + + Now deactivate MARTY by clicking its toggle or remove button. + Verify MARTY is no longer in the 'active only' filtered list. + Clear any filters. + + COMPLETE when you have confirmed MARTY can be activated and deactivated and + the filter reflects the current state. + expected_result: "MARTY activates/deactivates correctly and filters reflect state" + extraction_schema: + type: object + properties: + search_found_marty: + type: boolean + marty_toggleable: + type: boolean + filter_reflected_state: + type: boolean + + - id: GW-AUTO-DASH-001 + name: "Hide balances and hide zero balances toggles" + source_manual_id: GW-MAN-DASH-001 + tags: [dashboard, p1, smoke] + max_steps: 20 + prompt: | + Navigate to the main dashboard or wallet overview. + You should see a list of coins with balance amounts. + + Look for a 'Hide Balances' toggle, eye icon, or privacy button on the dashboard. + Click it to hide/mask balances. + Verify that balance amounts are replaced with asterisks, dots, dashes, or a hidden indicator. + + Now look for a 'Hide Zero Balances' toggle or similar option. + If found, enable it. + Verify that coins with 0 balance are no longer shown in the list. + + Restore both toggles to their original state (show balances, show all coins). + + COMPLETE when both toggles have been tested and restored. + expected_result: "Balances can be hidden/shown; zero-balance coins can be filtered" + extraction_schema: + type: object + properties: + hide_balances_works: + type: boolean + balances_masked_indicator: + type: string + description: "What replaced the balance values (e.g., '***', '---', etc.)" + hide_zero_balances_works: + type: boolean + + # =========================================================================== + # PHASE 4: FAUCET FUNDING (P0 — prerequisite for money movement tests) + # =========================================================================== + + - id: GW-AUTO-SEND-001 + name: "Faucet funding for DOC and MARTY" + source_manual_id: GW-MAN-SEND-001 + tags: [send, critical, smoke, p0, prerequisite] + max_steps: 25 + timeout: 240 + steps: + - action: | + Navigate to the DOC coin page by clicking on 'DOC' in the dashboard coin list + or navigating to the DOC details/overview screen. + checkpoint: "The DOC coin detail page is visible showing the DOC balance and/or address" + - action: | + Look for a 'Faucet', 'Get Test Coins', 'Request Funds', or tap icon that + triggers the in-app faucet for DOC. Click it. + Wait 5 seconds for the response. + checkpoint: "A success message, toast, or snackbar appears confirming the faucet request" + - action: | + Navigate back to the dashboard, then open the MARTY coin page by clicking + on 'MARTY' in the coin list. + checkpoint: "The MARTY coin detail page is visible" + - action: | + Look for the same faucet/request button on the MARTY page. Click it. + Wait 5 seconds for the response. + checkpoint: "A success message appears for the MARTY faucet request" + expected_result: "Both DOC and MARTY faucet requests succeeded" + extraction_schema: + type: object + properties: + doc_faucet_success: + type: boolean + doc_faucet_message: + type: string + marty_faucet_success: + type: boolean + marty_faucet_message: + type: string + + - id: GW-AUTO-SEND-002a + name: "Faucet cooldown/denied handling" + source_manual_id: GW-MAN-SEND-002 + tags: [send, critical, p0] + max_steps: 15 + prompt: | + Navigate to the DOC coin page. + Look for the faucet/request button and click it again immediately. + You should see a cooldown, denied, rate-limited, or 'already requested' message. + + Report the exact text of any message shown. + + COMPLETE when you have observed the cooldown/denied response. + expected_result: "Cooldown or denied message is shown for rapid repeat faucet request" + extraction_schema: + type: object + properties: + cooldown_message_shown: + type: boolean + message_text: + type: string + + # =========================================================================== + # PHASE 5: SEND / WITHDRAW (P0) + # =========================================================================== + + - id: GW-AUTO-SEND-003 + name: "DOC send happy path" + source_manual_id: GW-MAN-SEND-003 + tags: [send, critical, smoke, p0] + max_steps: 25 + timeout: 240 + steps: + - action: | + Navigate to the DOC coin page and find the 'Send', 'Withdraw', or arrow-up button. + Click it to open the send form. + checkpoint: "A send form is visible with fields for recipient address and amount" + - action: | + Enter the recipient address 'RPDPE1XqGuHXSJn9q6VAaGDoRVMEwAYjT3' into the address/recipient field. + Enter the amount '0.01' into the amount field. + checkpoint: "Both fields are filled with the entered values" + - action: | + Click 'Next', 'Preview', 'Review', or 'Continue' to see the transaction summary. + Look for a confirmation screen showing the amount, recipient, and fee. + checkpoint: "A transaction summary or confirmation screen is visible showing amount, address, and fee" + - action: | + Click 'Confirm', 'Send', or 'Submit' to broadcast the transaction. + Wait 5 seconds for confirmation feedback. + checkpoint: "A success message, pending transaction indicator, or transaction hash is shown" + expected_result: "Transaction submitted with success confirmation and visible in pending/history" + extraction_schema: + type: object + properties: + transaction_submitted: + type: boolean + success_or_pending_message: + type: string + fee_displayed: + type: string + transaction_hash: + type: string + description: "Transaction hash if visible" + + - id: GW-AUTO-SEND-004 + name: "Address validation — invalid and wrong-network" + source_manual_id: GW-MAN-SEND-004 + tags: [send, critical, p0] + max_steps: 20 + steps: + - action: | + Navigate to DOC send screen. Enter the invalid address 'RThisIsNotAValidAddress12345' + in the recipient field. Enter amount '0.01'. Click send/next/continue. + checkpoint: "A red error message appears mentioning invalid address, wrong format, or similar" + - action: | + Clear the address field. Enter the Ethereum address '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18' + which is a wrong-network address for DOC. Click send/next/continue. + checkpoint: "An error message appears about unsupported address format or wrong network" + expected_result: "Both invalid and wrong-network addresses are blocked with error messages" + extraction_schema: + type: object + properties: + invalid_address_blocked: + type: boolean + invalid_address_error: + type: string + wrong_network_blocked: + type: boolean + wrong_network_error: + type: string + + - id: GW-AUTO-SEND-005 + name: "Amount validation — zero, too small, exceeds balance" + source_manual_id: GW-MAN-SEND-005 + tags: [send, critical, boundary, p0] + max_steps: 25 + steps: + - action: | + Navigate to DOC send screen. Enter a valid DOC address in the recipient field. + Enter '0' as the amount. Click send/next/continue. + checkpoint: "An error message appears about amount being zero or must be greater than zero" + - action: | + Clear the amount field. Enter '0.000000001' as the amount (extremely small). + Click send/next/continue. + checkpoint: "An error message about minimum amount or insufficient value appears" + - action: | + Clear the amount field. Enter '999999999' as the amount (far exceeds balance). + Click send/next/continue. + checkpoint: "An error about insufficient funds or balance exceeded appears" + expected_result: "Zero, sub-minimum, and over-balance amounts all blocked with errors" + extraction_schema: + type: object + properties: + zero_amount_error: + type: string + tiny_amount_error: + type: string + exceeds_balance_error: + type: string + + # =========================================================================== + # PHASE 6: DEX (P0) + # =========================================================================== + + - id: GW-AUTO-DEX-001 + name: "Maker limit order creation" + source_manual_id: GW-MAN-DEX-001 + tags: [dex, critical, smoke, p0] + max_steps: 25 + steps: + - action: | + Navigate to the DEX section from the main menu or sidebar. + Look for 'DEX', 'Exchange', 'Trade', or 'Swap' in the navigation. + checkpoint: "The DEX trading interface is visible with pair selectors and order forms" + - action: | + Select the trading pair DOC/MARTY. Look for a pair selector or dropdown + and choose DOC as the base and MARTY as the quote (or vice versa if that is how they are listed). + checkpoint: "DOC/MARTY pair is selected and the orderbook or trading form reflects this pair" + - action: | + Look for a 'Maker', 'Limit', or 'Place Order' option. + Enter a price of '1' (1 DOC = 1 MARTY or similar). + Enter an amount of '0.1' in the amount field. + checkpoint: "Price and amount fields are filled" + - action: | + Click 'Place Order', 'Submit', 'Create Order', or similar button. + Wait 5 seconds for confirmation. + checkpoint: "Order creation is confirmed with a success message or the order appears in an 'Open Orders' section" + expected_result: "Maker order created and visible in open orders" + extraction_schema: + type: object + properties: + order_created: + type: boolean + order_visible_in_list: + type: boolean + order_details: + type: object + properties: + pair: + type: string + price: + type: string + amount: + type: string + + - id: GW-AUTO-DEX-003 + name: "DEX validation — invalid inputs" + source_manual_id: GW-MAN-DEX-003 + tags: [dex, critical, boundary, p0] + max_steps: 20 + steps: + - action: | + On the DEX trading screen with DOC/MARTY pair selected, enter '0' as the amount. + Click the submit/place order button. + checkpoint: "An error message about zero or invalid amount appears" + - action: | + Clear the amount field. Enter '999999999' as the amount (exceeds available balance). + Click submit/place order. + checkpoint: "An error about insufficient funds or balance appears" + expected_result: "Zero and over-balance DEX orders are blocked with validation messages" + extraction_schema: + type: object + properties: + zero_amount_blocked: + type: boolean + zero_amount_error: + type: string + insufficient_funds_blocked: + type: boolean + insufficient_funds_error: + type: string + + # =========================================================================== + # PHASE 7: BRIDGE (P0) + # =========================================================================== + + - id: GW-AUTO-BRDG-001 + name: "Bridge transfer happy path" + source_manual_id: GW-MAN-BRDG-001 + tags: [bridge, critical, smoke, p0] + max_steps: 25 + timeout: 240 + steps: + - action: | + Navigate to the Bridge section from the main menu or sidebar. + Look for 'Bridge', 'Cross-chain', or a bridge icon in the navigation. + checkpoint: "The bridge interface is visible with source/destination selectors" + - action: | + Select a supported source coin and destination. If DOC has a bridge route, + select DOC as source. Look for available destination chains/coins and select one. + Enter a valid amount like '0.1'. + checkpoint: "Source, destination, and amount are all filled in" + - action: | + If a destination address field is shown, enter a valid recipient address. + Click 'Preview', 'Next', or 'Review' to see the bridge transfer summary. + checkpoint: "A summary showing amount, fees, estimated time, and route is displayed" + - action: | + Click 'Confirm', 'Bridge', or 'Submit' to initiate the bridge transfer. + Wait 5 seconds for confirmation. + checkpoint: "A success confirmation, pending status, or bridge tracking screen appears" + expected_result: "Bridge transfer initiated with confirmation" + extraction_schema: + type: object + properties: + bridge_initiated: + type: boolean + status_message: + type: string + fee_displayed: + type: string + + - id: GW-AUTO-BRDG-002 + name: "Unsupported bridge pair validation" + source_manual_id: GW-MAN-BRDG-002 + tags: [bridge, critical, p0] + max_steps: 15 + prompt: | + Navigate to the Bridge section. + Try to select a combination of source and destination that is not supported. + Look for grayed-out options, 'not available', 'unsupported', or similar indicators. + + If all combinations appear available, try entering an amount and see if any + pair-specific validation message appears. + + COMPLETE when you have identified how the bridge handles unsupported pairs. + expected_result: "Unsupported pairs are blocked or marked unavailable" + extraction_schema: + type: object + properties: + unsupported_handling_exists: + type: boolean + blocking_message: + type: string + + - id: GW-AUTO-BRDG-003 + name: "Bridge amount boundaries" + source_manual_id: GW-MAN-BRDG-003 + tags: [bridge, critical, boundary, p0] + max_steps: 20 + prompt: | + Navigate to the Bridge section and select a supported pair. + + Enter an extremely small amount like '0.0000001' and observe if a minimum + amount error appears. + + Clear and enter '999999999' (exceeds balance) and observe the error. + + Look for any displayed minimum or maximum amount on the bridge form + and report those values. + + COMPLETE after testing both boundaries. + expected_result: "Below-min and above-balance bridge amounts are blocked" + extraction_schema: + type: object + properties: + min_amount_error: + type: string + exceeds_balance_error: + type: string + displayed_min_amount: + type: string + displayed_max_amount: + type: string + + # =========================================================================== + # PHASE 8: NFT (P1) + # =========================================================================== + + - id: GW-AUTO-NFT-001 + name: "NFT list, details, and history" + source_manual_id: GW-MAN-NFT-001 + tags: [nft, p1] + max_steps: 20 + prompt: | + Navigate to the NFT section from the main menu or sidebar. + + If NFT is disabled or hidden, COMPLETE and report that NFT is not available. + + If the NFT section opens, observe the NFT list. Report how many NFTs are shown. + If any NFTs exist, click on the first one to open its detail page. + Look for name, image, collection, and any history or transaction entries. + Navigate back to the NFT list. + + If filters are available (collection, status, date), try applying one. + + COMPLETE after viewing the NFT list and at least one detail page (if items exist). + expected_result: "NFT section accessible with list and detail views" + extraction_schema: + type: object + properties: + nft_section_available: + type: boolean + nft_count: + type: integer + detail_view_loaded: + type: boolean + filters_available: + type: boolean + + # =========================================================================== + # PHASE 9: SETTINGS (P1) + # =========================================================================== + + - id: GW-AUTO-SET-002 + name: "Analytics/privacy toggles" + source_manual_id: GW-MAN-SET-002 + tags: [settings, security, p1] + max_steps: 15 + prompt: | + Navigate to Settings. + Look for any toggles related to 'Analytics', 'Diagnostics', 'Usage Data', + 'Privacy', or 'Telemetry'. + + Toggle each one off then on (or on then off) and verify the toggle state updates + visually when clicked. + + Report the names and current states of all privacy-related toggles found. + + COMPLETE after toggling available privacy settings. + expected_result: "Privacy/analytics toggles are interactive and state changes visually" + extraction_schema: + type: object + properties: + toggles_found: + type: array + items: + type: object + properties: + toggle_name: + type: string + responds_to_click: + type: boolean + + - id: GW-AUTO-SET-003 + name: "Test coin toggle impact" + source_manual_id: GW-MAN-SET-003 + tags: [settings, smoke, p1] + max_steps: 20 + steps: + - action: | + Navigate to Settings and find the 'Test Coins' toggle. + Turn it OFF. + checkpoint: "Test coins toggle is in the OFF/disabled state" + - action: | + Navigate to the Coin Manager or coins list. + Search for 'DOC'. Observe whether DOC appears in the list. + checkpoint: "DOC is NOT visible in the coin list when test coins are disabled" + - action: | + Go back to Settings and turn the Test Coins toggle ON. + checkpoint: "Test coins toggle is back in the ON/enabled state" + - action: | + Return to the Coin Manager and search for 'DOC' again. + checkpoint: "DOC is now visible in the coin list" + expected_result: "DOC visibility toggles with the test coins setting" + extraction_schema: + type: object + properties: + doc_hidden_when_off: + type: boolean + doc_visible_when_on: + type: boolean + + # =========================================================================== + # PHASE 10: NAVIGATION (P1) + # =========================================================================== + + - id: GW-AUTO-NAV-001 + name: "Main menu route integrity" + source_manual_id: GW-MAN-NAV-001 + tags: [navigation, smoke, p1] + max_steps: 30 + prompt: | + Starting from the dashboard/home screen, visit every main navigation item + one by one. Look for menu items or sidebar links such as: + Dashboard, Wallet, DEX/Exchange, Bridge, NFT, Settings, Market Maker/Bot, Fiat. + + For each item: + 1. Click it + 2. Wait 3 seconds for the page to load + 3. Note whether content loaded or an error/blank screen appeared + 4. Use the browser back button or app back arrow to return + + COMPLETE after visiting all available navigation items. + expected_result: "All main navigation items load content without errors" + extraction_schema: + type: object + properties: + tabs_visited: + type: array + items: + type: object + properties: + tab_name: + type: string + loaded_successfully: + type: boolean + error_visible: + type: boolean + + - id: GW-AUTO-NAV-003 + name: "Unsaved changes prompt on form exit" + source_manual_id: GW-MAN-NAV-003 + tags: [navigation, p2] + max_steps: 20 + prompt: | + Navigate to the DOC send screen. + Enter some text in the recipient address field (like 'test') and an amount ('0.5'). + Do NOT click send/confirm. + + Now click the browser back button or a navigation menu item to leave the page. + + If a dialog appears asking 'Discard changes?', 'Leave this page?', or similar: + Click 'Stay', 'Cancel', or 'No' to remain on the page. + Verify your entered data is still present. + Then try to navigate away again and this time click 'Discard', 'Leave', or 'Yes'. + + If no dialog appears, note that as well. + + COMPLETE after testing the unsaved changes behavior. + expected_result: "Unsaved changes dialog appears and respects Stay/Discard choices" + extraction_schema: + type: object + properties: + dialog_appeared: + type: boolean + stay_preserved_data: + type: boolean + discard_exited_page: + type: boolean + + # =========================================================================== + # PHASE 11: MARKET MAKER BOT (P1) + # =========================================================================== + + - id: GW-AUTO-BOT-001 + name: "Create and start market maker bot" + source_manual_id: GW-MAN-BOT-001 + tags: [bot, p1] + max_steps: 25 + steps: + - action: | + Navigate to the Market Maker Bot section from the menu. + If the bot feature is disabled or hidden, COMPLETE and report it as unavailable. + checkpoint: "Bot management interface is visible with a create/add option" + - action: | + Click 'Create Bot', 'Add Bot', 'New', or similar button. + Select the DOC/MARTY pair. + Enter a spread value like '5' or '5%'. + Enter a volume or amount like '0.1'. + checkpoint: "Bot configuration form is filled with pair, spread, and volume" + - action: | + Click 'Save', 'Create', or 'Start' to create the bot. + If separate save and start actions exist, save first then start. + checkpoint: "The bot appears in the bot list with a 'Running', 'Active', or similar status" + expected_result: "Bot created and started with active status" + extraction_schema: + type: object + properties: + bot_feature_available: + type: boolean + bot_created: + type: boolean + bot_status: + type: string + + - id: GW-AUTO-BOT-002 + name: "Bot validation — invalid config" + source_manual_id: GW-MAN-BOT-002 + tags: [bot, boundary, p1] + max_steps: 15 + prompt: | + Navigate to the Bot creation form. + + Try entering a spread of '0' or '-1' and observe any validation error. + Try entering a volume/amount of '0' and observe any validation error. + Try submitting without selecting a pair (if possible). + + Report all validation messages seen. + + COMPLETE after testing invalid bot configuration inputs. + expected_result: "Invalid bot config values are blocked with validation messages" + extraction_schema: + type: object + properties: + invalid_spread_error: + type: string + invalid_volume_error: + type: string + missing_pair_error: + type: string + + # =========================================================================== + # PHASE 12: FIAT ON-RAMP (P0) + # =========================================================================== + + - id: GW-AUTO-FIAT-001 + name: "Fiat menu access and connect-wallet gating" + source_manual_id: GW-MAN-FIAT-001 + tags: [fiat, smoke, p0] + max_steps: 15 + prompt: | + Navigate to the Fiat section from the main menu. + Look for 'Fiat', 'Buy Crypto', 'Buy', or similar navigation item. + + If the Fiat section loads, observe whether form fields are enabled. + Look for a currency selector, crypto asset selector, and amount field. + + If a 'Connect Wallet' gate is shown, report it. + + COMPLETE when you have observed the Fiat section state. + expected_result: "Fiat section is accessible and form state reflects auth state" + extraction_schema: + type: object + properties: + fiat_section_available: + type: boolean + form_fields_enabled: + type: boolean + connect_wallet_gate_shown: + type: boolean + + - id: GW-AUTO-FIAT-002 + name: "Fiat form validation — boundary amounts" + source_manual_id: GW-MAN-FIAT-002 + tags: [fiat, critical, boundary, p0] + max_steps: 20 + prompt: | + Navigate to the Fiat section. If fields are available: + + Select a fiat currency (e.g., USD or EUR) and a crypto asset. + Enter '0.01' (likely below minimum) in the amount field and observe validation. + Enter '999999999' (above maximum) and observe validation. + Enter a valid amount like '50' and verify the form allows progression. + + If payment method options exist, switch between them. + + COMPLETE after testing boundary amounts. + expected_result: "Below-min and above-max fiat amounts are rejected with messages" + extraction_schema: + type: object + properties: + below_min_error: + type: string + above_max_error: + type: string + valid_amount_accepted: + type: boolean + + # =========================================================================== + # PHASE 13: SECURITY SETTINGS (P0) + # =========================================================================== + + - id: GW-AUTO-SECX-002 + name: "Seed backup show and confirm lifecycle" + source_manual_id: GW-MAN-SECX-002 + tags: [security, critical, p0] + max_steps: 25 + steps: + - action: | + Navigate to Settings > Security. Look for 'Seed Phrase', 'Backup', + 'View Seed', or 'Recovery Phrase' option. + checkpoint: "A seed/backup option is visible in security settings" + - action: | + Click on the seed/backup option. If a password prompt appears, + enter 'QaTestPass!2026' and submit. + checkpoint: "Seed words are displayed on screen (12 or 24 words)" + - action: | + If a 'Confirm Backup' or seed verification challenge is available, attempt it. + If the flow allows navigating away, go back to settings. + checkpoint: "Seed backup flow completed or navigation returned to settings" + expected_result: "Seed is protected behind password and displays correctly" + extraction_schema: + type: object + properties: + password_required: + type: boolean + seed_displayed: + type: boolean + seed_word_count: + type: integer + + - id: GW-AUTO-SECX-004 + name: "Change password flow" + source_manual_id: GW-MAN-SECX-004 + tags: [security, critical, p0] + max_steps: 25 + steps: + - action: | + Navigate to Settings > Security > Change Password. + Enter the wrong current password 'WrongOldPass' and a new password 'NewPass2026!'. + Click submit/save. + checkpoint: "An error message appears about incorrect current password" + - action: | + Clear the fields. Enter the correct current password 'QaTestPass!2026'. + Enter new password 'NewPass2026!' and confirm it. + Click submit/save. + checkpoint: "A success message appears confirming the password has been changed" + - action: | + IMPORTANT: Change the password back. Go to Change Password again. + Enter current password 'NewPass2026!' and new password 'QaTestPass!2026'. + Confirm and submit. + checkpoint: "Password has been reverted to the original for subsequent tests" + expected_result: "Wrong current password rejected; valid change succeeds; password reverted" + extraction_schema: + type: object + properties: + wrong_current_rejected: + type: boolean + password_changed: + type: boolean + password_reverted: + type: boolean + + # =========================================================================== + # PHASE 14: CUSTOM TOKEN (P1) + # =========================================================================== + + - id: GW-AUTO-CTOK-001 + name: "Import custom token happy path" + source_manual_id: GW-MAN-CTOK-001 + tags: [custom_token, p1] + max_steps: 20 + steps: + - action: | + Navigate to the Coin Manager. Look for an 'Import Token', 'Add Custom Token', + or similar option. Click it. + checkpoint: "A custom token import form appears with network selector and contract address field" + - action: | + Select an EVM network (e.g., Ethereum, BSC, or whatever is available). + Enter the contract address '0xReplaceMeWithTestTokenContract' in the contract field. + Click 'Fetch', 'Search', 'Load', or similar. + checkpoint: "Token metadata appears showing name, symbol, and/or decimals" + - action: | + Click 'Import', 'Add', 'Confirm', or similar to add the token. + checkpoint: "Token is added and appears in the coin list" + expected_result: "Custom token imported and visible in coin list" + extraction_schema: + type: object + properties: + token_fetched: + type: boolean + token_name: + type: string + token_symbol: + type: string + token_imported: + type: boolean + + - id: GW-AUTO-CTOK-002 + name: "Custom token — invalid contract handling" + source_manual_id: GW-MAN-CTOK-002 + tags: [custom_token, p1] + max_steps: 15 + prompt: | + Navigate to the custom token import form. + Select an EVM network. + Enter 'NotARealContract123' as the contract address. + Click fetch/search/load. + + Observe the error message or not-found state. + Report the message shown. + + COMPLETE after observing the error handling. + expected_result: "Invalid contract shows error/not-found message" + extraction_schema: + type: object + properties: + error_shown: + type: boolean + error_message: + type: string + + # =========================================================================== + # PHASE 15: LOCALIZATION (P2) + # =========================================================================== + + - id: GW-AUTO-L10N-001 + name: "Translation completeness check" + source_manual_id: GW-MAN-L10N-001 + tags: [l10n, p2] + max_steps: 25 + prompt: | + Navigate to Settings and change the app language/locale to a non-English option + (e.g., the second language in the list). + + After the language changes, navigate to the Dashboard, then to the Send screen, + then to Settings. + + Look for any text that appears to be untranslated (still in English when + everything else is in the new language) or any text that looks like a raw + localization key (e.g., 'send.button.label' or 'error_invalid_address'). + + Report any untranslated strings found. + + Change the language back to English before completing. + + COMPLETE after checking three screens and reverting to English. + expected_result: "No raw localization keys visible; all text appears translated" + extraction_schema: + type: object + properties: + language_changed_to: + type: string + untranslated_strings_found: + type: array + items: + type: string + language_reverted: + type: boolean + + # =========================================================================== + # PHASE 16: FEATURE GATING (P0/P1) + # =========================================================================== + + - id: GW-AUTO-GATE-001 + name: "Trading-disabled mode tooltips" + source_manual_id: GW-MAN-GATE-001 + tags: [gating, p0] + max_steps: 15 + prompt: | + Look at the main navigation/sidebar menu items. + Identify any menu items that appear disabled, grayed out, or marked with + a lock icon or 'disabled' indicator. + + For each disabled item, hover over it (move cursor to it) and look for + a tooltip or popup message explaining why it is disabled. + + Report which items are disabled and their tooltip text. + + If no items appear disabled, report that all navigation items are active. + + COMPLETE after checking all main navigation items. + expected_result: "Disabled features show explanatory tooltips; active features are accessible" + extraction_schema: + type: object + properties: + disabled_items: + type: array + items: + type: object + properties: + item_name: + type: string + tooltip_text: + type: string + all_active: + type: boolean + + # =========================================================================== + # PHASE 17: SUPPORT, FEEDBACK, MISC (P2) + # =========================================================================== + + - id: GW-AUTO-SUP-001 + name: "Support page content and links" + source_manual_id: GW-MAN-SUP-001 + tags: [support, p2] + max_steps: 15 + prompt: | + Navigate to the Support, Help, or FAQ section from settings or the main menu. + + Verify that content loads (FAQ items, support text, or help articles). + Look for any 'Contact', 'Email', or external support link and note the URL target. + Look for a 'My Coins Missing' or similar help dialog and open it if available. + + COMPLETE when you have reviewed the support content. + expected_result: "Support page loads with content and functioning links" + extraction_schema: + type: object + properties: + support_page_loaded: + type: boolean + faq_content_visible: + type: boolean + contact_link_present: + type: boolean + missing_coins_dialog: + type: boolean + + - id: GW-AUTO-FEED-001 + name: "Feedback entry points" + source_manual_id: GW-MAN-FEED-001 + tags: [feedback, p2] + max_steps: 15 + prompt: | + Look for feedback entry points: + 1. A 'Feedback' option in the Settings menu + 2. A floating bug/feedback button on the screen (usually bottom-right corner) + + If you find either, click it to open the feedback form. + If a feedback form opens, observe its fields (text input, screenshot option). + Close it without submitting. + + COMPLETE after identifying available feedback entry points. + expected_result: "Feedback form accessible from at least one entry point" + extraction_schema: + type: object + properties: + settings_feedback_found: + type: boolean + floating_button_found: + type: boolean + feedback_form_opened: + type: boolean + + # =========================================================================== + # PHASE 18: ADVANCED SETTINGS + WALLET (P1/P2) + # =========================================================================== + + - id: GW-AUTO-SETX-001 + name: "Weak-password toggle enforcement" + source_manual_id: GW-MAN-SETX-001 + tags: [settings, security, p1] + max_steps: 20 + prompt: | + Navigate to Settings > Advanced or Security settings. + Look for a toggle labeled 'Allow Weak Password', 'Weak Password', or similar. + + If found, turn it OFF (disallow weak passwords). + Then attempt to create or import a wallet using the weak password 'abc'. + Observe if the app blocks you with a password strength error. + + Then go back and turn the toggle ON (allow weak passwords). + Retry with the same weak password 'abc' and observe if it is now accepted. + + IMPORTANT: Cancel the wallet creation after testing — don't actually create + a wallet with a weak password. + + COMPLETE after testing both toggle states. + expected_result: "Weak password blocked when toggle is off, accepted when on" + extraction_schema: + type: object + properties: + weak_password_toggle_found: + type: boolean + blocked_when_off: + type: boolean + accepted_when_on: + type: boolean + + - id: GW-AUTO-SETX-007 + name: "Reset activated coins for wallet" + source_manual_id: GW-MAN-SETX-007 + tags: [settings, recovery, p2] + max_steps: 15 + prompt: | + Navigate to Settings > Advanced. + Look for 'Reset Activated Coins', 'Reset Coins', or similar option. + + If found, click it. A wallet selector or confirmation dialog should appear. + Click 'Cancel' or dismiss the dialog without confirming. + + Then open it again, select a wallet if prompted, and confirm the reset. + Observe the completion message. + + COMPLETE after testing cancel and confirm paths. + expected_result: "Reset operation has cancel/confirm safety and shows completion message" + extraction_schema: + type: object + properties: + reset_option_found: + type: boolean + cancel_preserved_state: + type: boolean + reset_completed: + type: boolean + completion_message: + type: string + + - id: GW-AUTO-WALX-001 + name: "Wallet overview cards and privacy toggle" + source_manual_id: GW-MAN-WALX-001 + tags: [wallet, p1] + max_steps: 15 + prompt: | + Navigate to the wallet overview or dashboard. + Look for overview cards showing: current balance, total investment, + profit/loss, or portfolio value. + + If a privacy/eye icon is visible near the cards, click it to toggle privacy mode. + Observe if the values are masked/hidden. + Click it again to reveal the values. + + COMPLETE after testing the privacy toggle on overview cards. + expected_result: "Overview cards display and privacy toggle masks/reveals values" + extraction_schema: + type: object + properties: + overview_cards_visible: + type: boolean + card_types: + type: array + items: + type: string + privacy_toggle_works: + type: boolean + + - id: GW-AUTO-WADDR-001 + name: "Multi-address display and controls" + source_manual_id: GW-MAN-WADDR-001 + tags: [wallet, p1] + max_steps: 20 + prompt: | + Navigate to a coin detail page (e.g., DOC). + Look for an 'Addresses' section, tab, or expandable area. + + If multiple addresses are shown, try any available controls: + - 'Hide zero balance addresses' toggle + - 'Expand all' / 'Collapse all' button + - Copy button (click the copy icon next to an address) + - QR code button (click to view QR) + + Report which controls are available and whether they respond to clicks. + + COMPLETE after reviewing the address controls. + expected_result: "Address list displays with interactive controls" + extraction_schema: + type: object + properties: + multiple_addresses_shown: + type: boolean + address_count: + type: integer + controls_found: + type: array + items: + type: string + description: "Names of controls found (hide-zero, expand, copy, qr, faucet)" + + - id: GW-AUTO-WADDR-002 + name: "Create new address" + source_manual_id: GW-MAN-WADDR-002 + tags: [wallet, p1] + max_steps: 15 + steps: + - action: | + On the coin addresses section, look for a 'Create New Address', 'Generate Address', + or '+' button. + checkpoint: "A create/generate address button is visible" + - action: | + Click the create button. If a confirmation dialog appears, confirm it. + Wait 3 seconds for the new address to be generated. + checkpoint: "A new address appears in the address list that was not there before" + expected_result: "New address generated and visible in the list" + extraction_schema: + type: object + properties: + new_address_created: + type: boolean + address_list_updated: + type: boolean + + - id: GW-AUTO-SECX-003 + name: "Unban pubkeys operation" + source_manual_id: GW-MAN-SECX-003 + tags: [security, p1] + max_steps: 15 + prompt: | + Navigate to Settings > Security. + Look for 'Unban Pubkeys', 'Unban', or 'Banned Keys' option. + + If found, click it. Observe any progress indicator, results dialog, + or snackbar message showing counts of unbanned keys or a no-op result. + + If not found, report that the option is not available. + + COMPLETE after executing or locating the unban option. + expected_result: "Unban operation runs and shows results or is not applicable" + extraction_schema: + type: object + properties: + unban_option_found: + type: boolean + operation_result: + type: string + description: "Success message, count of unbanned keys, or no-op message" + + - id: GW-AUTO-GATE-003 + name: "NFT menu disabled state and route safety" + source_manual_id: GW-MAN-GATE-003 + tags: [gating, p1] + max_steps: 15 + prompt: | + Look at the main navigation menu. Find the NFT menu item. + + If NFT appears disabled (grayed out, has a lock icon, or shows a disabled tooltip): + Hover over it and report the tooltip text. + Try clicking it anyway and report what happens. + + If NFT appears enabled, click it and verify the NFT section loads. + + COMPLETE after checking the NFT menu item state. + expected_result: "NFT disabled state is clear with tooltip; or NFT section loads normally" + extraction_schema: + type: object + properties: + nft_menu_state: + type: string + description: "enabled, disabled, or hidden" + tooltip_text: + type: string + click_result: + type: string + + + # =========================================================================== + # PHASE 19: MISSING GRADE-A TESTS + # =========================================================================== + + - id: GW-AUTO-COIN-003 + name: "Deactivate coin with balance — warning and restore" + source_manual_id: GW-MAN-COIN-003 + tags: [coin, p1] + max_steps: 20 + steps: + - action: | + Navigate to the Coin Manager. Find DOC in the active coins list. + DOC should have a non-zero balance from earlier faucet funding. + checkpoint: "DOC is visible in the active coins list with a balance > 0" + - action: | + Click the toggle or remove button next to DOC to deactivate it. + A warning dialog should appear about deactivating a coin with a balance. + checkpoint: "A warning dialog is shown mentioning the coin has a balance" + - action: | + Click 'Cancel' or 'No' to dismiss the warning. DOC should remain active. + checkpoint: "DOC is still in the active coins list" + - action: | + Click deactivate again. This time confirm the deactivation. + Then re-activate DOC by finding it in the inactive list and enabling it. + checkpoint: "DOC is back in the active coins list" + expected_result: "Warning shown for balance coin; cancel preserves; reactivation works" + extraction_schema: + type: object + properties: + warning_shown: + type: boolean + cancel_preserved: + type: boolean + reactivation_successful: + type: boolean + + - id: GW-AUTO-NFT-002 + name: "NFT send happy path" + source_manual_id: GW-MAN-NFT-002 + tags: [nft, p1] + max_steps: 25 + timeout: 240 + steps: + - action: | + Navigate to the NFT section. If no NFTs are available, COMPLETE and + report that no NFTs exist for testing. + If NFTs exist, click on the first NFT to open its detail page. + checkpoint: "NFT detail page is visible with name, image, and a send/transfer option" + - action: | + Click the 'Send', 'Transfer', or share icon on the NFT detail page. + A send form should appear with a recipient address field. + checkpoint: "NFT send form is visible with a recipient address field" + - action: | + Enter a valid recipient address 'RPDPE1XqGuHXSJn9q6VAaGDoRVMEwAYjT3' in the + recipient field. Click 'Send', 'Confirm', or 'Transfer'. + If a confirmation dialog appears, confirm it. + checkpoint: "A success message, pending indicator, or transaction hash is shown" + expected_result: "NFT transfer initiated with confirmation" + extraction_schema: + type: object + properties: + nfts_available: + type: boolean + transfer_initiated: + type: boolean + status_message: + type: string + + - id: GW-AUTO-NFT-003 + name: "NFT send failure — invalid recipient" + source_manual_id: GW-MAN-NFT-003 + tags: [nft, p1] + max_steps: 20 + steps: + - action: | + Navigate to an NFT detail page and open the send/transfer form. + If no NFTs exist, COMPLETE and report not applicable. + checkpoint: "NFT send form is visible" + - action: | + Enter an invalid address 'RThisIsNotAValidAddress12345' in the recipient field. + Click send/transfer. + checkpoint: "An error message appears about the invalid address" + - action: | + Clear the address field and enter an empty string or leave it blank. + Click send/transfer. + checkpoint: "An error message appears about a required address field" + expected_result: "Invalid and empty addresses are blocked with error messages" + extraction_schema: + type: object + properties: + invalid_address_blocked: + type: boolean + invalid_address_error: + type: string + empty_address_blocked: + type: boolean + + - id: GW-AUTO-L10N-003 + name: "Locale-specific date and number formatting" + source_manual_id: GW-MAN-L10N-003 + tags: [l10n, p2] + max_steps: 25 + prompt: | + Navigate to Settings and change the language to a locale that uses + different number/date formatting (e.g., German or French if available, + which uses commas for decimals and dots for thousands). + + After the language changes: + 1. Navigate to the Dashboard and look at any displayed balance amounts. + Check if the decimal separator matches the locale convention. + 2. Navigate to a coin detail page with transaction history. + Look at any dates displayed and check if they use the locale format + (e.g., DD.MM.YYYY for German, DD/MM/YYYY for French). + 3. Report the formatting you observe. + + Change the language back to English before completing. + + COMPLETE after checking number and date formatting. + expected_result: "Numbers and dates reflect the selected locale formatting" + extraction_schema: + type: object + properties: + locale_selected: + type: string + decimal_separator_observed: + type: string + date_format_observed: + type: string + language_reverted: + type: boolean + + - id: GW-AUTO-CTOK-003 + name: "Cancel/back from custom token import — no side effects" + source_manual_id: GW-MAN-CTOK-003 + tags: [custom_token, p1] + max_steps: 15 + steps: + - action: | + Navigate to the custom token import form. + Select an EVM network and enter any contract address text. + checkpoint: "The import form has some entered data" + - action: | + Click 'Back', 'Cancel', or navigate away from the import form + using the back arrow or a navigation menu item. + checkpoint: "The import form is no longer visible; you are back on the coin list or previous page" + - action: | + Navigate to the coin list and verify that no new unknown token + was added during the cancelled import. + checkpoint: "The coin list is unchanged — no partially imported token is present" + expected_result: "Cancelling import leaves no side effects in the coin list" + extraction_schema: + type: object + properties: + cancel_successful: + type: boolean + no_side_effects: + type: boolean + + # =========================================================================== + # PHASE 20: GRADE-B AUTOMATED PORTIONS + # =========================================================================== + # These tests cover only the UI-automatable steps of Grade-B cases. + # The manual_verification_note field explains what must be checked by a human. + # =========================================================================== + + - id: GW-AUTO-AUTH-002a + name: "Login and logout cycle (within session)" + source_manual_id: GW-MAN-AUTH-002 + tags: [auth, p1] + max_steps: 20 + manual_verification_note: "Session persistence after app restart must be tested manually (MAN-AUTH-002b)" + steps: + - action: | + If on the dashboard, navigate to Settings and look for a 'Logout', + 'Sign Out', or 'Lock' option. Click it. + checkpoint: "The login/unlock screen or welcome screen is visible" + - action: | + Log back in by entering the password 'QaTestPass!2026' and clicking + the login/unlock button. + checkpoint: "The dashboard is visible again after logging in" + expected_result: "Logout returns to login screen; re-login restores dashboard" + extraction_schema: + type: object + properties: + logout_successful: + type: boolean + relogin_successful: + type: boolean + + - id: GW-AUTO-DEX-002a + name: "Taker order form and submission attempt" + source_manual_id: GW-MAN-DEX-002 + tags: [dex, p1] + max_steps: 20 + manual_verification_note: "Actual taker execution depends on orderbook liquidity — verify manually if fill occurs" + prompt: | + Navigate to the DEX section and select the DOC/MARTY pair. + + Look for a 'Taker', 'Market', 'Swap', or 'Simple' tab/mode. + If available, switch to it. + + Enter an amount of '0.01' in the sell/amount field. + Observe if a matching order or price is shown from the orderbook. + + If a 'Swap', 'Trade', or 'Submit' button is available and enabled, click it. + Wait 5 seconds and report the result. + + If no orderbook liquidity exists, the form may show 'No orders available' + or the button may be disabled. Report this state. + + COMPLETE after observing the taker form behavior. + expected_result: "Taker form loads with pair; shows orderbook state or allows submission" + extraction_schema: + type: object + properties: + taker_form_available: + type: boolean + orderbook_has_liquidity: + type: boolean + submission_result: + type: string + + - id: GW-AUTO-DEX-004a + name: "Cancel open maker order" + source_manual_id: GW-MAN-DEX-004 + tags: [dex, p1] + max_steps: 20 + manual_verification_note: "Partial fill depends on external market activity — verify manually" + steps: + - action: | + Navigate to the DEX section. Look for an 'Open Orders', 'My Orders', + or 'Active Orders' tab or section. + checkpoint: "Open orders section is visible" + - action: | + If any open orders exist, click the cancel button (X, trash, or 'Cancel') + on one of them. If a confirmation dialog appears, confirm it. + checkpoint: "The order is removed from the open orders list or a cancellation message appears" + expected_result: "Open order can be cancelled successfully" + extraction_schema: + type: object + properties: + orders_visible: + type: boolean + cancel_successful: + type: boolean + cancel_message: + type: string + + - id: GW-AUTO-DEX-005a + name: "Swap history filtering" + source_manual_id: GW-MAN-DEX-005 + tags: [dex, p1] + max_steps: 20 + manual_verification_note: "File export verification requires filesystem access — verify manually (MAN-VERIFY-DEX-005)" + prompt: | + Navigate to the DEX section and find the 'History', 'Swap History', + or 'Completed' tab/section. + + If history entries exist: + 1. Try any available filters (date range, pair, status). + 2. Verify that filtering changes the displayed entries. + 3. If a sort option exists (by date, amount), try it. + 4. Look for an 'Export' or download button. Report if it exists. + + If no history exists, report that. + + COMPLETE after testing available filters. + expected_result: "History view loads; filters change displayed results" + extraction_schema: + type: object + properties: + history_entries_exist: + type: boolean + filters_available: + type: array + items: + type: string + export_button_exists: + type: boolean + + - id: GW-AUTO-CDET-001a + name: "Coin detail — address display, copy, QR" + source_manual_id: GW-MAN-CDET-001 + tags: [wallet, p1] + max_steps: 20 + manual_verification_note: "Clipboard content and explorer link correctness must be verified manually (MAN-VERIFY-CDET-001)" + steps: + - action: | + Navigate to the DOC coin detail page. + Look for the receiving address displayed on the page. + checkpoint: "A wallet address starting with 'R' is visible on the coin detail page" + - action: | + Look for a copy button (clipboard icon) next to the address. + Click it. Observe any 'Copied' toast or feedback. + checkpoint: "A 'Copied' or clipboard confirmation message appears" + - action: | + Look for a QR code button or icon. Click it. + A QR code image should appear in a dialog or overlay. + checkpoint: "A QR code is displayed" + - action: | + Close the QR dialog and look for an explorer link button. + If present, note its location but do not click (external navigation). + checkpoint: "Explorer link button identified or noted as absent" + expected_result: "Address visible with copy, QR, and explorer link controls" + extraction_schema: + type: object + properties: + address_displayed: + type: boolean + copy_feedback_shown: + type: boolean + qr_code_displayed: + type: boolean + explorer_link_exists: + type: boolean + + - id: GW-AUTO-CDET-002a + name: "Transaction list view and detail" + source_manual_id: GW-MAN-CDET-002 + tags: [wallet, p1] + max_steps: 20 + manual_verification_note: "Pending→confirmed progression and explorer link require real chain time (MAN-VERIFY-CDET-002)" + prompt: | + Navigate to the DOC coin detail page. + Look for a 'Transactions', 'History', or transaction list section. + + If transactions exist: + 1. Note the number of transactions visible. + 2. Click on the first transaction to see its details. + 3. Look for: amount, fee, date/time, transaction hash, status, addresses. + 4. Navigate back to the transaction list. + + Report the fields visible in the transaction detail. + + COMPLETE after reviewing the transaction list and one detail entry. + expected_result: "Transaction list loads; detail view shows amount, hash, status" + extraction_schema: + type: object + properties: + transactions_exist: + type: boolean + transaction_count: + type: integer + detail_fields_visible: + type: array + items: + type: string + + - id: GW-AUTO-CDET-003a + name: "Price chart rendering" + source_manual_id: GW-MAN-CDET-003 + tags: [wallet, p1] + max_steps: 15 + manual_verification_note: "Offline fallback behavior requires network toggle — verify manually" + prompt: | + Navigate to a coin detail page that is likely to have price data + (e.g., KMD, BTC, ETH, or any major coin if active). + + Look for a price chart, graph, or chart area on the page. + + If a chart is visible: + 1. Report whether it shows price data with a line or candlestick graph. + 2. Look for time range selectors (1D, 1W, 1M, 1Y, All) and click one. + 3. Report if the chart updates when switching time ranges. + + If no chart is visible, report that. + + COMPLETE after checking chart functionality. + expected_result: "Price chart renders with data; time range selectors work" + extraction_schema: + type: object + properties: + chart_visible: + type: boolean + chart_has_data: + type: boolean + time_ranges_work: + type: boolean + + - id: GW-AUTO-SEC-001a + name: "Seed phrase reveal (password-gated)" + source_manual_id: GW-MAN-SEC-001 + tags: [security, p1] + max_steps: 20 + manual_verification_note: "Screenshot masking and app-backgrounding behavior are manual (MAN-SEC-001b)" + steps: + - action: | + Navigate to Settings > Security. Look for 'View Seed', 'Show Seed', + 'Recovery Phrase', or 'Backup Seed' option. Click it. + checkpoint: "A password prompt appears before showing the seed" + - action: | + Enter the password 'QaTestPass!2026' and submit. + checkpoint: "Seed words (12 or 24) are now displayed on screen" + - action: | + Navigate back or close the seed view. Verify the seed is no longer visible. + checkpoint: "Seed words are no longer displayed after navigating away" + expected_result: "Seed is password-gated and hidden after navigation" + extraction_schema: + type: object + properties: + password_required: + type: boolean + seed_displayed_after_auth: + type: boolean + seed_hidden_on_exit: + type: boolean + + - id: GW-AUTO-SET-001a + name: "Theme and language switching" + source_manual_id: GW-MAN-SET-001 + tags: [settings, p1] + max_steps: 20 + manual_verification_note: "Persistence after app restart is manual (MAN-SET-004)" + prompt: | + Navigate to Settings. + + Look for a theme switcher (Light/Dark mode toggle). + If found, switch to the opposite theme. Observe if the background + and text colors change. Switch back. + + Look for a language/locale selector. + If found, switch to a different language briefly. + Verify the UI text changes. Switch back to English. + + Report which settings were available and functional. + + COMPLETE after testing available appearance settings. + expected_result: "Theme and/or language changes take effect immediately" + extraction_schema: + type: object + properties: + theme_toggle_found: + type: boolean + theme_change_visible: + type: boolean + language_selector_found: + type: boolean + language_change_visible: + type: boolean + + - id: GW-AUTO-BOT-003a + name: "Edit, stop, and restart market maker bot" + source_manual_id: GW-MAN-BOT-003 + tags: [bot, p1] + max_steps: 25 + manual_verification_note: "Persistence after app restart is manual" + steps: + - action: | + Navigate to the Market Maker Bot section. + If a running bot exists, look for an edit button or settings icon. + Click it to open the bot configuration. + checkpoint: "Bot configuration/edit form is visible" + - action: | + Change the spread value slightly (e.g., from 5 to 6) and save. + checkpoint: "Configuration saved with updated spread value" + - action: | + Look for a 'Stop' or 'Pause' button on the bot. Click it. + checkpoint: "Bot status changes to 'Stopped', 'Paused', or similar" + - action: | + Click 'Start', 'Resume', or 'Restart' to reactivate the bot. + checkpoint: "Bot status returns to 'Running' or 'Active'" + expected_result: "Bot can be edited, stopped, and restarted" + extraction_schema: + type: object + properties: + edit_successful: + type: boolean + stop_successful: + type: boolean + restart_successful: + type: boolean + + - id: GW-AUTO-SECX-001a + name: "Private key export UI" + source_manual_id: GW-MAN-SECX-001 + tags: [security, p1] + max_steps: 20 + manual_verification_note: "Actual download/share action may cross browser boundary — verify file manually" + steps: + - action: | + Navigate to a coin detail page (e.g., DOC). + Look for a 'Private Key', 'Export Key', or key icon option. + If not on the coin page, check Settings > Security. + checkpoint: "A private key export option is found" + - action: | + Click the export option. If a password prompt appears, + enter 'QaTestPass!2026' and submit. + checkpoint: "The private key is displayed on screen or a download/copy option appears" + - action: | + If a copy button is available, click it. Look for copy confirmation. + Navigate away to ensure the key is no longer visible. + checkpoint: "Key is hidden after navigating away" + expected_result: "Private key export is password-gated and displayed securely" + extraction_schema: + type: object + properties: + export_option_found: + type: boolean + password_required: + type: boolean + key_displayed: + type: boolean + + - id: GW-AUTO-SETX-002a + name: "Trading bot master toggles" + source_manual_id: GW-MAN-SETX-002 + tags: [settings, p1] + max_steps: 15 + manual_verification_note: "Whether a running bot actually stops on disable requires active bot verification" + prompt: | + Navigate to Settings > Advanced or Trading settings. + Look for toggles related to the Market Maker Bot feature: + 'Enable Trading Bot', 'Market Maker', or similar. + + If found, toggle it off then on. Observe any confirmation dialog + or immediate state change. + + Report the toggle names, their current states, and whether they + respond to clicks. + + COMPLETE after testing bot-related toggles. + expected_result: "Bot toggles are interactive and state changes visually" + extraction_schema: + type: object + properties: + bot_toggles_found: + type: boolean + toggle_names: + type: array + items: + type: string + toggles_respond: + type: boolean + + - id: GW-AUTO-SETX-004a + name: "View and copy swap data" + source_manual_id: GW-MAN-SETX-004 + tags: [settings, p2] + max_steps: 15 + manual_verification_note: "File export requires filesystem access — verify manually" + prompt: | + Navigate to Settings > Advanced. + Look for 'Show Swap Data', 'Swap Export', or similar option. + + If found, click it. Observe if swap data is displayed in a dialog, + text area, or expandable section. + + If a 'Copy' button is available, click it and observe copy feedback. + + Report what is displayed. + + COMPLETE after reviewing the swap data view. + expected_result: "Swap data is viewable in settings with copy option" + extraction_schema: + type: object + properties: + swap_data_option_found: + type: boolean + data_displayed: + type: boolean + copy_button_exists: + type: boolean + + - id: GW-AUTO-WALX-002a + name: "Wallet overview tabs — Assets, Growth, PnL" + source_manual_id: GW-MAN-WALX-002 + tags: [wallet, p1] + max_steps: 20 + manual_verification_note: "Logged-out fallback behavior requires logout — verify manually" + prompt: | + Navigate to the wallet overview or dashboard. + Look for tabs or toggle buttons labeled 'Assets', 'Growth', + 'Profit & Loss', 'PnL', 'Portfolio', or similar. + + If tabs exist: + 1. Click each tab and verify that content changes. + 2. Report the tab names and whether each loaded content. + + If a chart is shown on any tab, note its type (line, bar, etc.). + + COMPLETE after visiting all available overview tabs. + expected_result: "Overview tabs switch content correctly" + extraction_schema: + type: object + properties: + tabs_found: + type: array + items: + type: string + all_tabs_load_content: + type: boolean + + - id: GW-AUTO-RWD-001a + name: "Rewards section view" + source_manual_id: GW-MAN-RWD-001 + tags: [wallet, p2] + max_steps: 15 + manual_verification_note: "Actual reward claim depends on KMD reward availability — verify manually" + prompt: | + Navigate to the KMD coin detail page (if KMD is active). + Look for a 'Rewards', 'Claim Rewards', or similar section. + + If found: + 1. Observe if a reward amount or status is displayed. + 2. If a 'Claim' or 'Collect' button exists, note whether it is + enabled or disabled. + 3. If a refresh button exists, click it. + + If KMD is not active or rewards are not visible, report that. + + COMPLETE after reviewing the rewards section. + expected_result: "Rewards section accessible with status display" + extraction_schema: + type: object + properties: + rewards_section_found: + type: boolean + reward_amount_displayed: + type: boolean + claim_button_state: + type: string + description: "enabled, disabled, or not_found" + + - id: GW-AUTO-BREF-001a + name: "Bitrefill widget visibility" + source_manual_id: GW-MAN-BREF-001 + tags: [fiat, p2] + max_steps: 15 + manual_verification_note: "Bitrefill widget interaction crosses domain boundaries — verify manually" + prompt: | + Navigate to the Fiat or Buy Crypto section. + Look for a 'Bitrefill', 'Gift Cards', or third-party widget/button. + + If found, report its location and label. + Click it and observe what happens (embedded widget, external redirect, etc.). + + If not found, check Settings for any Bitrefill toggle or option. + + COMPLETE after locating the Bitrefill entry point. + expected_result: "Bitrefill option is visible and clickable" + extraction_schema: + type: object + properties: + bitrefill_found: + type: boolean + location: + type: string + click_result: + type: string + + - id: GW-AUTO-ZHTL-001a + name: "ZHTLC activation dialog" + source_manual_id: GW-MAN-ZHTL-001 + tags: [coin, p2] + max_steps: 20 + manual_verification_note: "Logout during activation must be tested manually" + prompt: | + Navigate to the Coin Manager and search for a ZHTLC coin + (e.g., ARRR, ZOMBIE, or any privacy coin marked as ZHTLC). + + If a ZHTLC coin is found: + 1. Click to activate it. + 2. Observe if a special activation dialog or progress indicator appears + (ZHTLC coins typically require extended activation time). + 3. Report the dialog content and any estimated time shown. + 4. If the activation takes too long, note the progress state. + + If no ZHTLC coin is found, report that. + + COMPLETE after observing the ZHTLC activation flow. + expected_result: "ZHTLC activation shows progress dialog with estimated time" + extraction_schema: + type: object + properties: + zhtlc_coin_found: + type: boolean + activation_dialog_shown: + type: boolean + estimated_time_displayed: + type: string + activation_started: + type: boolean + + # =========================================================================== + # PHASE 21: COMPOSITE TESTS — formerly manual, now automated via + # Playwright (browser-level offline, viewport, lifecycle) + Skyvern + # =========================================================================== + + # --- NETWORK MANIPULATION TESTS --- + + - id: GW-AUTO-DASH-002 + name: "Balance refresh + offline indicator" + source_manual_id: GW-MAN-DASH-002 + tags: [dashboard, network, composite] + timeout: 300 + expected_result: "Offline indicator shown when network disabled; recovery on re-enable" + phases: + - type: skyvern + action: "Verify dashboard loads with coin balances" + prompt: | + Navigate to the dashboard. Verify coin balances are visible and + the page is fully loaded. Report the page state. + expected: "Dashboard with balances visible" + - type: playwright + action: set_offline + - type: os_call + action: wait + args: {seconds: 5} + - type: skyvern + action: "Check for offline indicator" + prompt: | + Look at the current screen. Is there an offline indicator, error + banner, connection warning, or any visual cue that the app has lost + connectivity? Report what you see. + expected: "Offline indicator or error banner visible" + - type: playwright + action: set_online + - type: os_call + action: wait + args: {seconds: 10} + - type: skyvern + action: "Verify recovery after network restore" + prompt: | + The network has been restored. Check if the dashboard has recovered: + are balances visible again? Is there still an offline indicator? + Report the current state. + expected: "Dashboard recovered with balances visible" + + - id: GW-AUTO-SEND-006 + name: "Interrupted send + duplicate prevention" + source_manual_id: GW-MAN-SEND-006 + tags: [send, network, critical, composite] + timeout: 360 + expected_result: "Send initiated; network kill shows pending; recovery reconciles; no duplicates" + phases: + - type: skyvern + action: "Navigate to DOC send and fill form" + prompt: | + Navigate to the DOC coin page and open the send form. + Enter recipient address 'RPDPE1XqGuHXSJn9q6VAaGDoRVMEwAYjT3' + and amount '0.001'. Click Send/Confirm to submit. + Wait 2 seconds. Report the current state (pending, submitted, etc.). + expected: "Transaction submitted or pending" + - type: playwright + action: set_offline + - type: os_call + action: wait + args: {seconds: 5} + - type: skyvern + action: "Observe pending state during outage" + prompt: | + The network is now disabled. Look at the screen. Is there a pending + transaction indicator, error message, or offline warning? + Report exactly what you see. + expected: "Pending state or error visible" + - type: playwright + action: set_online + - type: os_call + action: wait + args: {seconds: 15} + - type: skyvern + action: "Verify recovery and no duplicate" + prompt: | + Network is restored. Navigate to the DOC transaction history. + Check if the transaction status has reconciled (confirmed or failed). + Count how many instances of this transaction appear. + Report status and count. + expected: "Transaction reconciled, exactly 1 instance" + + - id: GW-AUTO-ERR-001 + name: "Global network outage messaging" + source_manual_id: GW-MAN-ERR-001 + tags: [error_handling, network, composite] + timeout: 300 + expected_result: "Offline indicators on all screens; recovery with no stale spinners" + phases: + - type: playwright + action: set_offline + - type: os_call + action: wait + args: {seconds: 5} + - type: skyvern + action: "Check offline state across screens" + prompt: | + The network is disabled. Navigate through these screens in order: + Dashboard, DEX, Bridge, Settings. + For each screen, report whether you see an offline indicator, error + message, or spinner. Note any crashes or blank screens. + expected: "Offline indicators on screens, no crashes" + - type: playwright + action: set_online + - type: os_call + action: wait + args: {seconds: 15} + - type: skyvern + action: "Verify full recovery" + prompt: | + Network is restored. Check Dashboard, DEX, Bridge screens. + Are they showing live data again? Are there any stale spinners + or error messages still stuck on screen? + Report the state of each screen. + expected: "All screens recovered, no stale spinners" + + - id: GW-AUTO-ERR-003 + name: "Stale-state reconciliation after offline" + source_manual_id: GW-MAN-ERR-003 + tags: [error_handling, network, composite] + timeout: 360 + expected_result: "After network restore, local state matches authoritative state" + phases: + - type: skyvern + action: "Initiate a transaction" + prompt: | + Navigate to DOC send. Enter recipient 'RKXzCCaT5ukqnyJBKTr9KyEpCBHR8itEFd' + and amount '0.001'. Click send/confirm. + expected: "Transaction submitted" + - type: playwright + action: set_offline + - type: os_call + action: wait + args: {seconds: 10} + - type: playwright + action: set_online + - type: os_call + action: wait + args: {seconds: 20} + - type: skyvern + action: "Check state reconciliation" + prompt: | + Navigate to DOC transaction history. Check the latest transaction. + Is its status resolved (confirmed or failed, not stuck on pending)? + Are there any duplicate or ghost entries? + Report transaction count and status. + expected: "Transaction reconciled, no duplicates" + + # --- APP LIFECYCLE TESTS (browser restart simulation) --- + + - id: GW-AUTO-AUTH-002b + name: "Session persistence across app restart" + source_manual_id: GW-MAN-AUTH-002 + tags: [auth, lifecycle, composite] + timeout: 300 + expected_result: "Remember-me persists session; logout clears it" + phases: + - type: skyvern + action: "Log in with remember-me enabled" + prompt: | + Log in to the wallet with password 'QaTestPass!2026'. + If there is a 'Remember Me', 'Remember Wallet', or 'Stay Logged In' + checkbox or toggle, enable it before logging in. + Wait until the dashboard loads. + expected: "Logged in with remember-me enabled" + - type: playwright + action: restart_session + - type: skyvern + action: "Check session after restart" + prompt: | + The browser was closed and reopened (simulating app restart). + What do you see? Is there a quick-login prompt, auto-login, + or are you on the full login screen? + Report the current state. + expected: "Session restored or quick-login available" + - type: skyvern + action: "Log out and verify session clear" + prompt: | + If logged in, go to Settings and click Logout. + Verify you are on the login/welcome screen. + expected: "Logged out" + - type: playwright + action: restart_session + - type: skyvern + action: "Verify session cleared after logout+restart" + prompt: | + After logout and app restart, what do you see? + Is it the full login screen with no auto-login? + expected: "Full login screen, no auto-login" + + - id: GW-AUTO-WAL-003 + name: "Wallet selection persistence across restart" + source_manual_id: GW-MAN-WAL-003 + tags: [wallet, lifecycle, composite] + timeout: 300 + expected_result: "Selected wallet persists after restart" + phases: + - type: skyvern + action: "Switch to a non-default wallet" + prompt: | + Open the wallet manager. If multiple wallets exist, switch to + a non-default wallet. Note its name. If only one wallet exists, + note the wallet name. + Report the active wallet name. + expected: "Wallet selected and name noted" + extraction_schema: + type: object + properties: + wallet_name: {type: string} + - type: playwright + action: restart_session + - type: skyvern + action: "Verify wallet persisted" + prompt: | + After app restart, log in if needed with password 'QaTestPass!2026'. + Check which wallet is active. Report the active wallet name. + expected: "Same wallet is active after restart" + extraction_schema: + type: object + properties: + wallet_name: {type: string} + + - id: GW-AUTO-DASH-003 + name: "Dashboard preferences persist across restart" + source_manual_id: GW-MAN-DASH-003 + tags: [dashboard, lifecycle, composite] + timeout: 300 + expected_result: "Dashboard customizations persist after logout and restart" + phases: + - type: skyvern + action: "Customize dashboard" + prompt: | + On the dashboard, toggle 'Hide Balances' (or similar privacy toggle) ON. + If a 'Hide Zero Balances' toggle exists, enable it too. + Note which toggles you changed. + expected: "Dashboard customized" + - type: skyvern + action: "Logout and re-login" + prompt: | + Navigate to Settings and click Logout. Then log back in with + password 'QaTestPass!2026'. Check the dashboard. + Are the toggles still in the state you set them? + expected: "Dashboard preferences preserved after re-login" + - type: playwright + action: restart_session + - type: skyvern + action: "Verify after restart" + prompt: | + After app restart, log in with 'QaTestPass!2026'. + Check the dashboard toggles. Are they still in the customized state? + expected: "Dashboard preferences preserved after restart" + + - id: GW-AUTO-SET-004 + name: "Settings persistence across logout/restart" + source_manual_id: GW-MAN-SET-004 + tags: [settings, lifecycle, composite] + timeout: 300 + expected_result: "Settings persist after logout and app restart" + phases: + - type: skyvern + action: "Change multiple settings" + prompt: | + Navigate to Settings. Change at least 2 settings: + 1. Toggle test coins ON (if off) or OFF (if on). + 2. Toggle any privacy/analytics setting. + Note the settings you changed and their new states. + expected: "Settings changed" + - type: skyvern + action: "Logout and re-login, verify" + prompt: | + Logout via Settings > Logout. Log back in with 'QaTestPass!2026'. + Navigate to Settings. Are the settings you changed still in the + new state? Report each setting and its current value. + expected: "Settings preserved after re-login" + - type: playwright + action: restart_session + - type: skyvern + action: "Verify after restart" + prompt: | + After restart, log in with 'QaTestPass!2026'. Check Settings. + Are the changed settings still in the new state? + expected: "Settings preserved after restart" + + - id: GW-AUTO-FIAT-005 + name: "Fiat form reset across logout" + source_manual_id: GW-MAN-FIAT-005 + tags: [fiat, lifecycle, composite] + timeout: 240 + expected_result: "Fiat form resets cleanly after logout and re-login" + phases: + - type: skyvern + action: "Partially fill fiat form" + prompt: | + Navigate to the Fiat section. Select a currency and enter an amount + like '100'. Do NOT submit. Note what you entered. + expected: "Fiat form partially filled" + - type: skyvern + action: "Logout and re-login, check fiat" + prompt: | + Logout via Settings. Log back in with 'QaTestPass!2026'. + Navigate to the Fiat section. Is the form clean/empty, + or does it still have the previous values? + expected: "Fiat form re-initialized cleanly" + + - id: GW-AUTO-QLOG-001 + name: "Quick-login persistence across restart" + source_manual_id: GW-MAN-QLOG-001 + tags: [auth, lifecycle, composite] + timeout: 300 + expected_result: "Quick-login prompt appears after restart when remember-me is on" + phases: + - type: skyvern + action: "Enable remember-me and log out" + prompt: | + Log in with 'QaTestPass!2026' with the remember-me option enabled. + Once on the dashboard, go to Settings and logout. + expected: "Logged in with remember-me, then logged out" + - type: playwright + action: restart_session + - type: skyvern + action: "Check for quick-login prompt" + prompt: | + After app restart, look at the screen. Is there a quick-login prompt + showing the remembered wallet? Or a password-only screen? + Report what you see. + expected: "Quick-login or remembered wallet prompt appears" + + # --- DEEP LINK TEST --- + + - id: GW-AUTO-NAV-002 + name: "Deep link handling with auth gating" + source_manual_id: GW-MAN-NAV-002 + tags: [navigation, security, composite] + timeout: 240 + expected_result: "Deep link while logged out enforces auth; redirect after login" + phases: + - type: skyvern + action: "Ensure logged out" + prompt: | + If you see a dashboard, navigate to Settings and click Logout. + If already on the login/welcome screen, COMPLETE. + expected: "On login screen" + - type: playwright + action: navigate + args: {url_suffix: "#/dex"} + - type: skyvern + action: "Check auth gating on deep link" + prompt: | + A direct URL to the DEX page was opened while logged out. + What do you see? Are you on the login screen (auth gating enforced)? + Or did the DEX page load without login? + expected: "Auth gating enforced, login required" + - type: skyvern + action: "Login and check redirect" + prompt: | + Log in with 'QaTestPass!2026'. After login, which page loaded? + Are you on the DEX page (intended deep link destination) or + the default dashboard? + expected: "Redirected to DEX after login" + + # --- RESPONSIVE / BREAKPOINT TESTS --- + + - id: GW-AUTO-RESP-001 + name: "Responsive breakpoint behavior" + source_manual_id: GW-MAN-RESP-001 + tags: [responsive, composite] + timeout: 360 + expected_result: "Layout adapts correctly at mobile, tablet, and desktop breakpoints" + phases: + - type: playwright + action: set_viewport + args: {width: 375, height: 812} + - type: skyvern + action: "Check mobile layout" + prompt: | + The viewport is now mobile width (375px). Look at the dashboard. + Are navigation, coin cards, and action buttons all visible and + properly stacked? Is there a hamburger menu or bottom nav? + Report any overflow, cutoff, or broken layout. + expected: "Mobile layout renders correctly" + - type: playwright + action: set_viewport + args: {width: 768, height: 1024} + - type: skyvern + action: "Check tablet layout" + prompt: | + The viewport is now tablet width (768px). Does the layout adapt? + Is there more content visible than at mobile width? + Report the layout style (sidebar visible? cards in grid?). + expected: "Tablet layout adapts correctly" + - type: playwright + action: set_viewport + args: {width: 1440, height: 900} + - type: skyvern + action: "Check desktop layout" + prompt: | + The viewport is now desktop width (1440px). Is the full layout + displayed with sidebar, main content area, and proper spacing? + Report any issues. + expected: "Desktop layout fully displayed" + + - id: GW-AUTO-RESP-002 + name: "Form state retention during viewport resize" + source_manual_id: GW-MAN-RESP-002 + tags: [responsive, composite] + timeout: 240 + expected_result: "Form data preserved after viewport resize" + phases: + - type: skyvern + action: "Fill form with test data" + prompt: | + Navigate to the DOC send screen. Enter recipient address + 'RPDPE1XqGuHXSJn9q6VAaGDoRVMEwAYjT3' and amount '0.5'. + Do NOT submit. Report that the form is filled. + expected: "Form filled with test data" + - type: playwright + action: set_viewport + args: {width: 375, height: 812} + - type: os_call + action: wait + args: {seconds: 3} + - type: skyvern + action: "Check form after resize" + prompt: | + The viewport was just resized to mobile width. Check the send form. + Is the recipient address and amount still filled in? + Was there any accidental submission? + Report the form state. + expected: "Form data preserved after resize" + - type: playwright + action: set_viewport + args: {width: 1440, height: 900} + + # --- CLOCK MANIPULATION --- + + - id: GW-AUTO-WARN-001 + name: "Clock warning banner under invalid time" + source_manual_id: GW-MAN-WARN-001 + tags: [system_health, composite] + timeout: 240 + expected_result: "No banner with valid clock; warning banner with invalid clock" + phases: + - type: skyvern + action: "Check with valid clock" + prompt: | + Navigate to the DEX or Bridge section. Is there any clock warning + banner or time-related warning message visible on screen? + Report what you see. + expected: "No clock warning banner" + - type: playwright + action: mock_clock + args: {offset_hours: 8760} + - type: skyvern + action: "Check with invalid clock" + prompt: | + The system clock has been mocked to a future date. Reload the page + and navigate to the DEX or Bridge. Is there now a clock warning + banner or time synchronization warning visible? + Report any warning text. + expected: "Clock warning banner appears" + - type: playwright + action: reset_clock + + # --- FILESYSTEM OPERATIONS --- + + - id: GW-AUTO-SETX-003 + name: "Export/import maker orders JSON" + source_manual_id: GW-MAN-SETX-003 + tags: [settings, filesystem, composite] + timeout: 300 + expected_result: "Export creates valid JSON; import succeeds" + phases: + - type: skyvern + action: "Navigate to export option" + prompt: | + Navigate to Settings > Advanced. Find the 'Export Maker Orders' + or similar export option. Report its exact label and location. + expected: "Export option found" + - type: playwright + action: capture_download + args: {click_text: "Export"} + - type: skyvern + action: "Verify export and try import" + prompt: | + If an export was triggered, navigate to the import option. + If an import/upload button exists, report its label. + If there is a text area for pasting JSON, report that. + expected: "Import option identified" + + - id: GW-AUTO-SETX-006 + name: "Download logs" + source_manual_id: GW-MAN-SETX-006 + tags: [settings, filesystem, composite] + timeout: 240 + expected_result: "Log file downloads successfully" + phases: + - type: skyvern + action: "Navigate to download logs" + prompt: | + Navigate to Settings > Advanced. Find the 'Download Logs' or + 'Export Logs' option. Report its exact label. + expected: "Download logs option found" + - type: playwright + action: capture_download + args: {click_text: "Download"} + + # --- CLIPBOARD VERIFICATION --- + + - id: GW-AUTO-SEC-003 + name: "Clipboard verification after address copy" + source_manual_id: GW-MAN-SEC-003 + tags: [security, clipboard, composite] + timeout: 240 + expected_result: "Copied address matches displayed address" + phases: + - type: skyvern + action: "Copy wallet address" + prompt: | + Navigate to the DOC coin detail page. Find the receiving address. + Click the copy button (clipboard icon) next to the address. + Report the address text you see on screen. + expected: "Address copied" + extraction_schema: + type: object + properties: + displayed_address: {type: string} + - type: os_call + action: read_clipboard + - type: skyvern + action: "Verify clipboard matches" + prompt: | + Report the displayed DOC address on screen one more time. + expected: "Address confirmed" + + # --- KEYBOARD ACCESSIBILITY --- + + - id: GW-AUTO-A11Y-001 + name: "Keyboard-only navigation audit" + source_manual_id: GW-MAN-A11Y-001 + tags: [accessibility, composite] + timeout: 300 + expected_result: "All elements reachable via Tab; no keyboard traps; logical focus order" + phases: + - type: playwright + action: navigate + - type: os_call + action: wait + args: {seconds: 5} + - type: playwright + action: keyboard_audit + args: {max_tabs: 80} + + # --- CONTRAST / ACCESSIBILITY AUDIT --- + + - id: GW-AUTO-A11Y-003 + name: "Contrast and accessibility audit (axe-core)" + source_manual_id: GW-MAN-A11Y-003 + tags: [accessibility, composite] + timeout: 180 + expected_result: "No critical or serious accessibility violations" + phases: + - type: playwright + action: navigate + - type: os_call + action: wait + args: {seconds: 5} + - type: playwright + action: accessibility_audit + + # --- REMAINING NETWORK TESTS (DEX, Bridge) --- + + - id: GW-AUTO-DEX-006 + name: "DEX recovery after network drop" + source_manual_id: GW-MAN-DEX-006 + tags: [dex, network, composite] + timeout: 360 + expected_result: "DEX orders/history reconcile after network drop and restore" + phases: + - type: skyvern + action: "Place a maker order" + prompt: | + Navigate to the DEX. Select DOC/MARTY pair. Place a maker order + with price '1' and amount '0.01'. Wait for order confirmation. + expected: "Order placed" + - type: playwright + action: set_offline + - type: os_call + action: wait + args: {seconds: 8} + - type: playwright + action: set_online + - type: os_call + action: wait + args: {seconds: 15} + - type: skyvern + action: "Check DEX state after recovery" + prompt: | + Navigate to DEX > Open Orders and History. + Is the order still present? Are there duplicates or ghost orders? + Report order count and statuses. + expected: "No duplicate or ghost orders" + + - id: GW-AUTO-BRDG-004 + name: "Bridge failure/recovery after network drop" + source_manual_id: GW-MAN-BRDG-004 + tags: [bridge, network, composite] + timeout: 360 + expected_result: "Bridge history reflects correct status after network recovery" + phases: + - type: skyvern + action: "Initiate bridge transfer" + prompt: | + Navigate to Bridge. Select a supported pair and enter a small amount. + Click confirm to initiate the transfer. Report the initial status. + expected: "Bridge transfer initiated" + - type: playwright + action: set_offline + - type: os_call + action: wait + args: {seconds: 8} + - type: playwright + action: set_online + - type: os_call + action: wait + args: {seconds: 20} + - type: skyvern + action: "Check bridge history after recovery" + prompt: | + Navigate to bridge history. Check the latest transfer status. + Is it resolved (completed/failed) or stuck? Any duplicates? + expected: "Bridge status reconciled" + + - id: GW-AUTO-SETX-005 + name: "Import swaps from JSON" + source_manual_id: GW-MAN-SETX-005 + tags: [settings, filesystem, composite] + timeout: 240 + expected_result: "Valid JSON imports; malformed JSON shows error" + phases: + - type: skyvern + action: "Navigate to import swaps" + prompt: | + Navigate to Settings > Advanced. Find 'Import Swaps' or + 'Paste Swap Data' option. If a text area is shown, enter the + text 'not valid json' and click import/submit. + Report the error message shown. + expected: "Error shown for invalid JSON" + - type: skyvern + action: "Try valid empty array" + prompt: | + Clear the text area and enter '[]' (empty JSON array). + Click import/submit. Report the result (error, success, or no-op). + expected: "Empty array handled gracefully" + +# ============================================================================= +# REGRESSION PACKS (tag-based filtering) +# ============================================================================= +# +# Smoke Pack: python -m runner.runner --tag smoke +# Critical Pack: python -m runner.runner --tag critical +# P0 Pack: python -m runner.runner --tag p0 +# Full Pack: python -m runner.runner (no filter) +# Composite: python -m runner.runner --tag composite +# With manual: python -m runner.runner --include-manual +# Manual only: python -m runner.runner --manual-only +# +# ============================================================================= diff --git a/docs/BUILD_RELEASE.md b/docs/BUILD_RELEASE.md index 0acfb0ef77..34dd1610fa 100644 --- a/docs/BUILD_RELEASE.md +++ b/docs/BUILD_RELEASE.md @@ -14,13 +14,24 @@ Optionally, you can enable Firebase Analytics for the app. To do so, follow the ## Build for Web +### Standard build + ```bash flutter pub get --enforce-lockfile flutter build web --csp --no-web-resources-cdn --no-pub ``` +### WebAssembly build (recommended) + +```bash +flutter pub get --enforce-lockfile +flutter build web --csp --no-web-resources-cdn --no-pub --wasm +``` + The release version of the app will be located in `build/web` folder. Specifying the `--release` flag is not necessary, as it is the default behavior. +WebAssembly builds require COEP/COOP headers on the hosting layer (see [WebAssembly hosting headers](#webassembly-hosting-headers)). + ## Native builds Run `flutter build {TARGET}` command with one of the following targets: @@ -63,7 +74,7 @@ docker build -f .docker/android-sdk.dockerfile . -t komodo/android-sdk:34 docker build -f .docker/gleec-wallet-android.dockerfile . -t gleec/gleec-wallet # Build the app mkdir -p build -docker run --rm -v ./build:/app/build gleec/gleec-wallet:latest bash -c "flutter pub get --enforce-lockfile && flutter build web --no-pub --release" +docker run --rm -v ./build:/app/build gleec/gleec-wallet:latest bash -c "flutter pub get --enforce-lockfile && flutter build web --no-pub --release --wasm" ``` ### Build for Android @@ -83,3 +94,15 @@ docker build -f .docker/gleec-wallet-android.dockerfile . -t gleec/gleec-wallet mkdir -p build docker run --rm -v ./build:/app/build gleec/gleec-wallet:latest bash -c "flutter pub get --enforce-lockfile && flutter build apk --no-pub --release" ``` + +## WebAssembly hosting headers + +For Flutter web `--wasm` builds with multi-threading, set these response headers: + +- `Cross-Origin-Embedder-Policy: credentialless` (or `require-corp`) +- `Cross-Origin-Opener-Policy: same-origin` + +Project defaults already include these headers in: + +- `firebase.json` (Firebase Hosting) +- `roles/nginx/templates/airdex.conf.j2` (nginx deployment template) diff --git a/docs/BUILD_RUN_APP.md b/docs/BUILD_RUN_APP.md index 3767737663..14f74cdc05 100644 --- a/docs/BUILD_RUN_APP.md +++ b/docs/BUILD_RUN_APP.md @@ -104,12 +104,32 @@ Run in release mode: flutter run -d chrome --release ``` +Run with WebAssembly: + +```bash +flutter run -d chrome --wasm +``` + +Run with WebAssembly in release mode: + +```bash +flutter run -d chrome --wasm --release +``` + Running on web-server (useful for testing/debugging in different browsers): ```bash flutter run -d web-server --web-port=8080 ``` +Running on web-server with WebAssembly: + +```bash +flutter run -d web-server --web-port=8080 --wasm +``` + +`--wasm` builds require COEP/COOP headers in deployment. See `docs/BUILD_RELEASE.md`. + ## Desktop ### macOS desktop diff --git a/docs/BUILD_SECURITY_ADVISORY.md b/docs/BUILD_SECURITY_ADVISORY.md index 6a4c8d01ae..f9b90c2542 100644 --- a/docs/BUILD_SECURITY_ADVISORY.md +++ b/docs/BUILD_SECURITY_ADVISORY.md @@ -8,6 +8,7 @@ When building the Gleec Wallet for production, **always** use the following flag --enforce-lockfile # When running 'flutter pub get' --no-pub # When running 'flutter build' --no-web-resources-cdn # When building for web +--wasm # Recommended for Flutter web WASM builds ``` ## Security Justification @@ -69,7 +70,7 @@ For web builds: ```bash flutter pub get --enforce-lockfile -flutter build web --csp --no-web-resources-cdn --no-pub +flutter build web --csp --no-web-resources-cdn --no-pub --wasm ``` For Docker builds: diff --git a/docs/FLUTTER_VERSION.md b/docs/FLUTTER_VERSION.md index af9c76142e..4e3357f175 100644 --- a/docs/FLUTTER_VERSION.md +++ b/docs/FLUTTER_VERSION.md @@ -2,7 +2,7 @@ ## Supported Flutter Version -This project supports Flutter `3.35.3`. We aim to keep the project up-to-date with the most recent stable Flutter versions. +This project supports Flutter `3.41.4`. We aim to keep the project up-to-date with the most recent stable Flutter versions. ## Recommended Approach: Multiple Flutter Versions @@ -15,14 +15,14 @@ See our guide on [Multiple Flutter Versions](MULTIPLE_FLUTTER_VERSIONS.md) for d While it's possible to pin your global Flutter installation to a specific version, **this approach is not recommended** due to: - Lack of isolation between projects -- Known issues with `flutter pub get` when using Flutter 3.35.3 +- Known issues with `flutter pub get` when using Flutter 3.41.4 - Difficulty switching between versions for different projects If you still choose to use this method, you can run: ```bash cd ~/flutter -git checkout 3.35.3 +git checkout 3.41.4 flutter doctor ``` diff --git a/docs/GLEEC_SERVICE_AUDIT_MATRIX.md b/docs/GLEEC_SERVICE_AUDIT_MATRIX.md new file mode 100644 index 0000000000..6f6e679a8b --- /dev/null +++ b/docs/GLEEC_SERVICE_AUDIT_MATRIX.md @@ -0,0 +1,201 @@ +# Gleec Service Audit Matrix (Exchange, Pay, Card) + +Date: March 2, 2026 +Owner: Product + Engineering +Status: Draft v1 +Related documents: +- [GLEEC_UNIFIED_APP_PLAN.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/GLEEC_UNIFIED_APP_PLAN.md) +- [GLEEC_UNIFIED_APP_PRD.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/GLEEC_UNIFIED_APP_PRD.md) +- [GLEEC_UNIFIED_APP_UX_SPEC.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/GLEEC_UNIFIED_APP_UX_SPEC.md) + +## 1. Purpose + +This matrix is the starting audit artifact for the unified app program. It is meant to answer one question before front-end commitments are locked: + +Can Exchange, Pay, and Card systems support the user experience we are designing? + +This matrix uses current public evidence plus product requirements. Where internal documentation or service contracts are not available in this workspace, status is intentionally conservative. + +## 2. Status Legend + +- `Verified (public)`: current public documentation strongly evidences capability. +- `Partially evidenced`: public evidence exists, but key fields or lifecycle details are not confirmed. +- `Needs internal verification`: public evidence is insufficient; internal contract review required. +- `Blocked`: missing contract visibility prevents credible front-end planning. +- `Remediation required`: capability exists in principle, but the current contract is unlikely to support the target UX without changes. + +## 3. Owner Model + +Because named internal owners are not present in this workspace, owners below are role-based and should be converted to actual names during Phase 0. + +Suggested role owners: +1. `Exchange backend lead` +2. `Exchange platform/SRE lead` +3. `Pay backend lead` +4. `Card integration lead` +5. `Issuer operations lead` +6. `Identity/KYC lead` +7. `Trust/compliance lead` +8. `Core app integration lead` +9. `Support tooling lead` + +## 4. Evidence Base + +Primary sources used: +- Exchange API docs: [https://api.exchange.gleec.com/](https://api.exchange.gleec.com/) +- Exchange system monitor: [https://exchange.gleec.com/system-monitor](https://exchange.gleec.com/system-monitor) +- Gleec verification guide: [https://exchange.gleec.com/Verifyingaccount](https://exchange.gleec.com/Verifyingaccount) +- Gleec licenses and regulations: [https://exchange.gleec.com/licenses-regulations](https://exchange.gleec.com/licenses-regulations) +- Gleec Pay: [https://www.gleec.com/pay](https://www.gleec.com/pay) +- Gleec Card: [https://www.gleec.com/card/](https://www.gleec.com/card/) + +## 5. Cross-System Blockers First + +| Area | Why it matters | Suggested owner | Current status | Blocker | Remediation status | +| --- | --- | --- | --- | --- | --- | +| Unified identity mapping | App cannot unify wallet, exchange, Pay, and card without a clear cross-system user identity model | Identity/KYC lead | Needs internal verification | No internal identity-link contract in workspace | Not started | +| Capability matrix | UX requires early gating by region, KYC tier, provider, and issuer | Trust/compliance lead | Needs internal verification | No server-driven capability schema in workspace | Not started | +| Correlation IDs | Activity and support need one traceable model across all systems | Core app integration lead | Needs internal verification | No unified cross-system correlation model documented | Not started | +| Event/webhook coverage | Good Activity UX depends on timely status changes | Core app integration lead | Partially evidenced | Exchange streaming is public; Pay/Card event coverage not evidenced | In discovery | +| Shared limits model | Trade, transfer, Pay, and Card actions need pre-submit limits and restriction visibility | Trust/compliance lead | Needs internal verification | No combined limits contract evidenced | Not started | +| Support metadata handoff | Support workflows depend on resolvable identifiers and consistent lifecycle states | Support tooling lead | Needs internal verification | No documented support handoff payload model | Not started | + +## 6. Exchange Audit Matrix + +### Summary + +Exchange has the strongest public evidence. Trading, wallet balance, transfer, transaction history, and wallet streaming appear to be publicly documented. The main remaining questions are about identity, KYC, internal transfer relationships to Pay/Card, and whether the public API surface is the same one intended for the consumer app. + +| Capability | Public evidence | Evidence level | Suggested owner | Current status | Main blocker | UX dependency | Remediation status | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Spot trading orders | Public API docs show spot order endpoints and trading websockets | Strong | Exchange backend lead | Verified (public) | Need internal auth/session strategy | Trade hub, advanced trade | None known | +| Wallet balances | Public API docs show `/wallet/balance` | Strong | Exchange backend lead | Verified (public) | Need app auth strategy | Portfolio, trade funding | None known | +| Wallet transaction history | Public API docs show wallet transactions with statuses and subtypes | Strong | Exchange backend lead | Verified (public) | Need normalization strategy | Activity timeline | None known | +| Wallet to spot/derivatives transfer | Public API docs show `/wallet/transfer` | Strong | Exchange backend lead | Verified (public) | Need internal business rules for consumer app | Transfer flow | None known | +| Internal user-to-user wallet transfer | Public API docs show `/wallet/internal/withdraw` | Medium | Exchange backend lead | Partially evidenced | Unclear if consumer app should expose | Transfers, recovery | Needs product decision | +| Address ownership check | Public API docs show `address/check-mine` | Strong | Exchange backend lead | Verified (public) | Need app integration decision | Transfer confirmation safety | None known | +| Real-time wallet events | Public websocket docs show transaction and balance subscription | Strong | Exchange platform/SRE lead | Verified (public) | Need app-safe adapter layer | Activity freshness | None known | +| Currency/network capability flags | Public docs show `payin_enabled`, `payout_enabled`, `transfer_enabled` | Strong | Exchange backend lead | Verified (public) | Need mapping to UX capability system | Gating, funding source selection | Needs adapter only | +| Exchange system operational status | Public system monitor shows transfer/deposit/withdrawal health | Medium | Exchange platform/SRE lead | Partially evidenced | Need machine-consumable interface or internal equivalent | Pre-submit reliability messaging | Likely remediation | +| KYC/account-state API | Public verification help exists, but no public KYC state contract found | Weak | Identity/KYC lead | Needs internal verification | No documented API | Capability gates, onboarding | Discovery required | +| 2FA/security-state API | Sign-in surface shows 2FA/YubiKey flows, but no documented app-facing state contract | Weak | Identity/KYC lead | Needs internal verification | No app-facing security-state contract found | Profile, trust messaging | Discovery required | +| Exchange to Pay transfer | No public evidence found | None | Exchange backend lead + Pay backend lead | Blocked | Unknown cross-system contract | Unified transfer flow | Discovery required | +| Exchange to Card funding | No public evidence found | None | Exchange backend lead + Card integration lead | Blocked | Unknown cross-system contract | Top-up funding selection | Discovery required | + +## 7. Pay Audit Matrix + +### Summary + +Public evidence confirms product positioning but not API maturity. Marketing copy supports the product thesis: crypto-friendly IBAN, send/receive payments, global access. However, no public API or lifecycle documentation was found for account state, beneficiaries, bank transfers, statements, or eventing. Pay should be treated as `high discovery / likely remediation`. + +| Capability | Public evidence | Evidence level | Suggested owner | Current status | Main blocker | UX dependency | Remediation status | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Pay account existence/product concept | Gleec Pay page describes a fully-digital bank account with crypto-friendly IBAN | Medium | Pay backend lead | Partially evidenced | Product exists; contract unknown | Spend hub, onboarding | Discovery required | +| Balance retrieval | No public API found | None | Pay backend lead | Blocked | Missing contract visibility | Spend hub, portfolio | Discovery required | +| IBAN details retrieval | Marketing confirms IBAN concept, not API | Weak | Pay backend lead | Needs internal verification | No documented payload | Pay overview, receive flow | Discovery required | +| Send bank transfer | Marketing confirms send/receive capability, not initiation contract | Weak | Pay backend lead | Needs internal verification | No documented transfer contract | Bank transfer flow | Discovery required | +| Receive bank transfer state | Marketing confirms receive capability, not event/status model | Weak | Pay backend lead | Needs internal verification | No documented status feed | Activity, receive flow | Discovery required | +| Beneficiary management | No public evidence found | None | Pay backend lead | Blocked | Unknown beneficiary object and validation rules | Bank transfer composer | Discovery required | +| Transfer status lifecycle | No public evidence found | None | Pay backend lead | Blocked | Unknown status model and review states | Activity detail, recovery | Discovery required | +| Statements/history access | No public evidence found | None | Pay backend lead | Blocked | Unknown statement/history contract | Spend hub, support | Discovery required | +| Pay-to-crypto conversion | Product vision supports it; no public contract found | None | Pay backend lead + Exchange backend lead | Blocked | Unknown conversion workflow and ledger model | Buy flow, transfer flow | Discovery required | +| Crypto-to-Pay conversion | Product vision supports it; no public contract found | None | Pay backend lead + Exchange backend lead | Blocked | Unknown conversion workflow and compliance checks | Sell/off-ramp, transfer flow | Discovery required | +| Pay-to-Card funding | Product/product-plan assumption strong; no public contract found | Weak | Pay backend lead + Card integration lead | Needs internal verification | Unknown funding orchestration | Card top-up | Discovery required | +| KYC/account restrictions | Pay likely depends on verification and region rules; no contract found | Weak | Identity/KYC lead | Needs internal verification | No capability schema | Spend gating | Discovery required | +| Limits and compliance review states | No public evidence found | None | Trust/compliance lead | Blocked | Unknown limits/review contract | Pre-submit warnings, error states | Discovery required | +| Event/webhook coverage | No public evidence found | None | Pay backend lead | Blocked | Unknown push/poll model | Activity freshness | Discovery required | +| Support references and reconciliation IDs | No public evidence found | None | Support tooling lead | Blocked | Unknown transaction-reference model | Activity, support handoff | Discovery required | + +## 8. Card Audit Matrix + +### Summary + +Public evidence confirms core card value proposition: virtual and plastic cards, Apple Pay compatibility, wallet top-up, broad merchant acceptance. However, no public API or lifecycle documentation was found for balance, top-up operations, transaction history, controls, or disputes. Card should also be treated as `high discovery / likely remediation`. + +| Capability | Public evidence | Evidence level | Suggested owner | Current status | Main blocker | UX dependency | Remediation status | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Card product existence | Gleec Card page confirms product exists | Medium | Card integration lead | Partially evidenced | Product exists; contract unknown | Spend hub | Discovery required | +| Virtual + physical card states | Card page mentions both plastic and virtual cards | Medium | Card integration lead | Partially evidenced | No lifecycle/status contract | Card overview | Discovery required | +| Wallet-to-card top-up concept | Card page says users can send crypto from wallet to card in seconds | Medium | Card integration lead | Partially evidenced | No top-up API or quote model | Top-up flow | Discovery required | +| Card balance retrieval | No public API found | None | Card integration lead | Blocked | Missing balance contract | Spend hub, card overview | Discovery required | +| Top-up initiation | No public API found | None | Card integration lead | Blocked | Missing initiation contract | Top-up flow | Discovery required | +| Top-up quote / conversion breakdown | No public API found | None | Card integration lead | Blocked | Missing FX/fee/ETA contract | Top-up review | Discovery required | +| Card transaction history | No public API found | None | Card integration lead | Blocked | Missing ledger contract | Activity, card overview | Discovery required | +| Freeze / unfreeze | No public evidence found | None | Card integration lead + Issuer operations lead | Blocked | Unknown issuer control contract | Card controls | Discovery required | +| Limits / controls | No public evidence found | None | Card integration lead + Issuer operations lead | Blocked | Unknown issuer control contract | Card settings | Discovery required | +| Sensitive card details reveal | No public evidence found | None | Issuer operations lead | Blocked | Unknown PCI/tokenization boundary | Card detail | Discovery required | +| Replace / report lost card | No public evidence found | None | Issuer operations lead | Blocked | Unknown issuer workflow | Card management | Discovery required | +| Disputes / chargeback initiation | No public evidence found | None | Issuer operations lead + Support tooling lead | Blocked | Unknown dispute initiation and case model | Recovery, support | Discovery required | +| Apple Pay / wallet tokenization state | Marketing mentions Apple Pay support | Weak | Card integration lead | Needs internal verification | No platform integration contract found | Card overview, setup | Discovery required | +| Card eligibility / KYC tiering | Likely required, but no public contract found | Weak | Identity/KYC lead | Needs internal verification | No capability schema | Spend gating | Discovery required | +| Card activity references for support | No public evidence found | None | Support tooling lead | Blocked | Unknown reference and reconciliation model | Activity detail | Discovery required | + +## 9. Suggested Remediation Priorities + +### P0: Must know before experience lock + +| Item | Why it is P0 | Suggested owner | Target phase | Current status | +| --- | --- | --- | --- | --- | +| Unified identity mapping | Every cross-domain flow depends on it | Identity/KYC lead | Phase 0 | Not started | +| Pay balance + transfer contract | Spend hub cannot exist credibly without it | Pay backend lead | Phase 1 | Not started | +| Card balance + top-up contract | Card top-up is a flagship flow | Card integration lead | Phase 1 | Not started | +| Capability matrix schema | Prevents dead-end UX | Trust/compliance lead | Phase 0 | Not started | +| Correlation ID model | Required for Activity and support | Core app integration lead | Phase 1 | Not started | + +### P1: Needed for v1 quality + +| Item | Why it matters | Suggested owner | Target phase | Current status | +| --- | --- | --- | --- | --- | +| Event/webhook coverage for Pay/Card | Needed for high-quality Activity UX | Pay backend lead + Card integration lead | Phase 1 | Not started | +| Limits/review-state contract | Needed for good error prevention and compliance UX | Trust/compliance lead | Phase 1 | Not started | +| System-status feed integration | Needed for pre-submit reliability messaging | Exchange platform/SRE lead | Phase 1-2 | In discovery | +| Card freeze/unfreeze and status model | Needed for credible card management | Issuer operations lead | Phase 4 | Not started | + +### P2: Needed for deeper maturity + +| Item | Why it matters | Suggested owner | Target phase | Current status | +| --- | --- | --- | --- | --- | +| Statements and export metadata | Important for banking credibility | Pay backend lead | Phase 4-6 | Not started | +| Dispute initiation and case references | Important for support quality | Issuer operations lead + Support tooling lead | Phase 4-6 | Not started | +| Rich card controls and channel-level limits | Nice-to-have for v1, strong for maturity | Card integration lead | Phase 6 | Not started | + +## 10. Suggested Service Audit Checklist + +For each domain, audit owners should answer: +1. What is the canonical account identifier? +2. What is the canonical balance model? +3. What actions are synchronous vs async? +4. What statuses are possible, and which are terminal? +5. What IDs can support and users see? +6. What events exist, and how quickly are they emitted? +7. What capability flags are available before submission? +8. What region/KYC restrictions apply? +9. What fields are safe to expose to mobile/web clients? +10. What manual operations still exist behind the scenes? + +## 11. Recommended Deliverable Artifacts for Phase 0-1 + +1. Contract inventory by domain +2. Gap severity register +3. Correlation-ID map across systems +4. Capability matrix schema draft +5. Status normalization draft for Activity +6. Support metadata handoff payload +7. Service readiness scorecard by phase + +## 12. Initial Service Readiness View + +| Domain | Overall readiness for unified app | Reason | +| --- | --- | --- | +| Exchange | Medium-High | Strong public API evidence for trading, balances, transfers, and transaction states | +| Pay | Low | Product capability is visible publicly, but service contracts are not evidenced | +| Card | Low | Product capability is visible publicly, but lifecycle, top-up, ledger, and issuer controls are not evidenced | +| Cross-system orchestration | Low | Identity, capability, correlation, and event normalization are not yet documented | + +## 13. Recommended Next Actions + +1. Assign named owners for every row marked `Blocked` or `Needs internal verification`. +2. Run 90-minute audit sessions separately for Exchange, Pay, and Card. +3. Produce one-page contract summaries for each domain. +4. Freeze v1 UX assumptions only after P0 rows have clear answers. +5. Re-baseline timelines if Pay/Card remediation proves deeper than moderate. diff --git a/docs/GLEEC_UNIFIED_APP_EXECUTIVE_BRIEF.md b/docs/GLEEC_UNIFIED_APP_EXECUTIVE_BRIEF.md new file mode 100644 index 0000000000..8056b70a26 --- /dev/null +++ b/docs/GLEEC_UNIFIED_APP_EXECUTIVE_BRIEF.md @@ -0,0 +1,405 @@ +# Gleec Unified App Executive Brief (DEX + CEX + Pay + Card) + +Date: March 2, 2026 +Audience: Executive team, product leadership, engineering leadership, design leadership +Status: Draft v2 +Related documents: +- [GLEEC_UNIFIED_APP_PLAN.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/GLEEC_UNIFIED_APP_PLAN.md) +- [GLEEC_UNIFIED_APP_PRD.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/GLEEC_UNIFIED_APP_PRD.md) +- [GLEEC_UNIFIED_APP_UX_SPEC.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/GLEEC_UNIFIED_APP_UX_SPEC.md) +- [UNIFIED_GLEEC_APP_PRODUCT_PLAN.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/UNIFIED_GLEEC_APP_PRODUCT_PLAN.md) + +## Executive Summary + +Gleec should move toward a single consumer app that unifies wallet, DEX, CEX, Gleec Pay, Gleec Card, fiat rails, bridge, and support into one coherent experience. The current ecosystem already has broad capability coverage, but it is distributed across separate app surfaces, separate flows, and separate mental models. That makes the ecosystem feel more powerful than seamless. + +The recommended path is still not a full rewrite now. It is a phased unification on top of the current Flutter codebase, with clean domain foundations built in parallel where the current architecture is weakest. The difference from the earlier plan is material: the official scope should now explicitly include `Banking and Spend` as a first-class domain, plus a dedicated `API discovery and remediation` phase for Exchange, Pay, and Card systems. + +Recommendation: +1. Keep the current app and services as the delivery vehicle for the staged rollout. +2. Reframe the product around `Home`, `Trade`, `Spend`, `Activity`, and `Profile`. +3. Build orchestration domains for portfolio, routing, banking/spend, capability gating, and unified activity. +4. Add an explicit API discovery and remediation phase for Exchange, Pay, and Card before front-end commitments are treated as reliable. +5. Use KPI gates after each phase to decide whether to continue iterative migration or escalate to a broader rebuild. + +## Visual Summary + +```mermaid +flowchart LR + A["Current Gleec: wallet, exchange, Pay, and card are fragmented"] --> B["Hybrid strategy"] + B --> C["Iterate on current Flutter app"] + B --> D["Build clean domain foundations in parallel"] + C --> E["Ship unified shell, Home, Trade, Spend, Activity"] + D --> F["Portfolio, routing, banking, capability, activity domains"] + E --> G["KPI gates after each phase"] + F --> G + G --> H["Continue iterative migration"] + G --> I["Escalate to broader rebuild if needed"] +``` + +## Why This Matters Now + +Current user-facing problem: +- The ecosystem exposes many capabilities, but users have to understand Gleec’s product architecture to use them well. +- Wallet, exchange, fiat, Pay, card, bridge, and support are discoverable as separate destinations rather than one connected financial journey. +- Users likely experience friction when trying to answer simple intent-level questions such as: +- How do I buy this asset? +- What is the best way to convert it? +- Where did my order go? +- Is this in my wallet, my exchange account, my Pay balance, or my card? +- Do I need KYC for this action or not? + +Market and product pressure: +- Exodus continues to set user expectations for unified self-custody wallet experiences with integrated swap, fiat, portfolio, support, and trust UX. +- Users increasingly expect route abstraction, clear fee transparency, and one activity timeline across all value movement. +- Gleec has a differentiated opportunity because it can combine DEX, CEX, banking, and card-spend capabilities in one app if the UX removes subsystem boundaries. + +## Benchmark Insight From Exodus and Current Gleec Services + +Key lessons from current Exodus materials and official Gleec materials: +1. One app shell matters more than one backend model. +2. Fiat and exchange flows work when they are framed as simple user intents. +3. Support content and security education are product features, not documentation afterthoughts. +4. Trust is built through clarity about fees, custody, and transaction status. +5. Region, provider, issuer, and compliance constraints must be designed into the product, not patched in later. +6. Gleec’s own ecosystem creates a larger opportunity than Exodus because Pay and Card can close the loop from earning to spending, but that also increases integration and compliance complexity. + +Relevant sources: +- [Exodus mobile wallet](https://www.exodus.com/mobile/) +- [Getting started with Exodus](https://www.exodus.com/support/en/articles/8598609-getting-started-with-exodus) +- [How can I buy Bitcoin and crypto](https://www.exodus.com/support/en/articles/8598616-how-can-i-buy-bitcoin-and-crypto) +- [How do I swap crypto in Exodus](https://www.exodus.com/support/en/articles/8598618-how-do-i-swap-crypto-in-exodus) +- [How do I buy crypto with XO Pay in Exodus](https://www.exodus.com/support/en/articles/10776618-how-do-i-buy-crypto-with-xo-pay-in-exodus) +- [Getting started with Trezor on Exodus Desktop](https://www.exodus.com/support/en/articles/8598656-getting-started-with-trezor-on-exodus-desktop) +- [Gleec Card](https://www.gleec.com/card/) +- [Gleec Pay](https://www.gleec.com/pay) +- [Gleec Exchange API v3](https://api.exchange.gleec.com/) +- [Gleec system monitor](https://exchange.gleec.com/system-monitor) +- [Gleec licenses and regulations](https://exchange.gleec.com/licenses-regulations) + +## Strategic Decision + +### Recommended strategy: Hybrid unification + +Near-term: +- Use the current codebase to ship a new shell and unified flows quickly. + +Mid-term: +- Build new domain layers under the shell so the UX becomes clean even if legacy modules still exist behind it. + +Long-term: +- Decide on a complete rebuild only if the new unified shell proves demand but the old architecture becomes the main bottleneck. + +### Why not a full rewrite now + +A rewrite would produce a cleaner architecture, but it would delay learning and duplicate parity work. Gleec already has meaningful wallet, exchange, bridge, Pay, and Card capability. The highest-value move is to improve orchestration, navigation, recovery UX, and service contracts first. + +### Strategic options at a glance + +```mermaid +flowchart TD + A["Decision: how to build the next Gleec app"] --> B["Option A: iterate current app"] + A --> C["Option B: full rewrite now"] + A --> D["Recommended: hybrid"] + + B --> B1["Fastest path to market learning"] + B --> B2["Lower delivery risk"] + B --> B3["Carries some UX and architecture debt"] + + C --> C1["Cleanest architecture"] + C --> C2["Longest time to parity"] + C --> C3["Highest migration risk"] + + D --> D1["Ship near-term UX wins"] + D --> D2["Create clean domain boundaries"] + D --> D3["Use KPI gates to decide rebuild timing"] +``` + +## Product Outcome We Want + +North Star: +- A user can open Gleec and complete Buy, Swap, Transfer, Add Money, Top Up Card, Send Bank Transfer, or Track Activity without needing to understand whether the flow uses DEX, CEX, Pay, Card, bridge, or fiat-provider rails. + +User-visible outcomes: +1. One Home with portfolio, watchlist, and quick actions. +2. One Trade area for Buy/Sell/Convert/Advanced. +3. One Spend area for Gleec Pay, Gleec Card, bank transfers, and card top-ups. +4. One Activity feed for on-chain, fiat, bank, card, internal transfer, and trading states. +5. One portfolio model with custody-aware breakdowns across wallet, CEX, Pay, and Card. +6. One trust layer for fees, risk warnings, support, KYC, and failed-flow recovery. + +## Proposed Top-Level Experience + +Mobile-first navigation: +1. Home +2. Trade +3. Spend +4. Activity +5. Profile + +Design note: +- `Earn` remains in scope, but should initially live as a secondary destination under Home/Portfolio and asset detail instead of competing with Spend for a primary mobile tab. + +Core product shifts: +1. Replace subsystem-first navigation with intent-first navigation. +2. Add route ranking so Gleec chooses between DEX, CEX, Pay-funded, card-funded, and provider paths where relevant. +3. Add a unified confirmation card with fees, ETA, route, funding source, and custody impact. +4. Add a unified activity timeline with issue states and recovery actions. +5. Add progressive KYC and capability gating so users can enter with self-custody but unlock CEX, Pay, and Card features when needed. + +### Target product model + +```mermaid +flowchart TB + U["User intents"] --> H["Home"] + U --> T["Trade"] + U --> S["Spend"] + U --> A["Activity"] + U --> P["Profile"] + + T --> O["Trade orchestration"] + H --> PO["Portfolio domain"] + S --> BS["Banking and spend domain"] + A --> AC["Activity domain"] + P --> CA["Capability and trust domain"] + + O --> DEX["DEX liquidity"] + O --> CEX["CEX liquidity"] + O --> FP["Fiat / external providers"] + O --> BR["Bridge paths"] + + BS --> GP["Gleec Pay account"] + BS --> GC["Gleec Card lifecycle"] + BS --> BT["Bank transfers"] + BS --> TU["Top-up and spend flows"] + + PO --> W["Wallet balances"] + PO --> CB["CEX balances"] + PO --> PB["Pay balances"] + PO --> CR["Card balances"] + + AC --> TX["On-chain transactions"] + AC --> OR["Orders and swaps"] + AC --> TR["Transfers and fiat orders"] + AC --> CT["Card transactions and bank transfers"] +``` + +### Example user journey + +```mermaid +flowchart LR + A["Open app"] --> B["Home"] + B --> C["Tap Buy / Swap / Top Up / Transfer"] + C --> D["Unified trade, transfer, or spend ticket"] + D --> E["Route, funding source, and fee transparency"] + E --> F["Confirm"] + F --> G["Execution"] + G --> H["Unified Activity timeline"] + H --> I["Completed or recovery action"] +``` + +## Business Impact Hypothesis + +Expected upside: +1. Higher buy and swap conversion through simpler decision paths. +2. Better retention through a more coherent portfolio and activity experience. +3. Lower support volume because users can see status, route, fees, and next steps. +4. Stronger differentiation because Gleec can unify self-custody, exchange, banking, and card-spend behavior in one product. +5. Better cross-sell because wallet users can discover Pay/Card and Pay/Card users can discover trading and self-custody. + +## KPI Targets + +Primary KPIs: +1. New-user activation rate +2. Quote-to-execution trade conversion rate +3. 30-day retention for funded users +4. Spend activation rate for verified users + +Secondary KPIs: +1. Support tickets per 1,000 transactions/orders +2. Median time to successful first buy/swap +3. Weekly combined-portfolio view usage +4. Card top-up completion rate +5. Gleec Pay funded-account activation rate + +Guardrails: +1. Execution failure rate +2. KYC drop-off rate +3. Crash-free sessions +4. Failed bank/card operation rate + +## Delivery Recommendation + +### Phase 0: Product discovery and ecosystem audit +- 6 weeks +- Validate support pain points, analytics gaps, user journeys, and product architecture across wallet, exchange, Pay, and Card + +### Phase 1: API remediation and orchestration foundation +- 8-12 weeks +- Audit Exchange, Pay, and Card APIs; close missing contract gaps; define event, ledger, and identity models + +### Phase 2: Unified shell, Home, Activity v1 +- 8 weeks +- New navigation and read-only unified activity timeline across trading, funding, banking, and card surfaces + +### Phase 3: Trade and funding unification v1 +- 10 weeks +- Unified Buy/Sell/Convert flows with route selection, funding-source selection, and fee transparency + +### Phase 4: Banking and Spend v1 +- 10 weeks +- Gleec Pay account surfaces, bank transfers, card overview, and top-up orchestration + +### Phase 5: Portfolio, transfers, and Earn unification +- 8 weeks +- Combined portfolio across wallet/CEX/Pay/Card and internal transfer flows across domains + +### Phase 6: Advanced and growth +- 8 weeks +- Advanced trading, deeper card controls, statements/disputes, personalization, and notifications + +### Timeline assumptions + +The current roadmap is now a baseline human-team plan for the broader `wallet + exchange + Pay + card` scope. It does include time for discovery and likely service remediation. + +Baseline planning case: +1. Total duration: about 50-58 weeks end to end. +2. Assumes a strong cross-functional team using normal engineering automation and parallel pod delivery. +3. Assumes normal product, design, engineering, QA, analytics, and release workflows. +4. Assumes API discovery and moderate remediation for Exchange, Pay, and Card are included. +5. Assumes no extraordinary provider, issuer, compliance, or security-review blockage beyond normal review cycles. + +AI-assisted planning case: +1. Total duration: about 38-46 weeks end to end. +2. Assumes active use of AI agents for PRD-to-ticket expansion, design-spec elaboration, UI scaffolding, analytics instrumentation, QA case generation, regression review, and documentation. +3. Assumes engineering still owns architecture, code review, and release quality. +4. Assumes provider integrations, issuer operations, legal/compliance approval, security signoff, and production rollout remain human-gated. + +### Timeline comparison + +```mermaid +flowchart LR + A["Baseline roadmap
~50-58 weeks"] --> A1["Normal cross-functional execution"] + A --> A2["No AI-agent dependency"] + + B["AI-assisted roadmap
~38-46 weeks"] --> B1["Agents used for specs, scaffolding, QA, review"] + B --> B2["Human approval still gates critical decisions"] + + A2 --> C["Hard constraints remain"] + B2 --> C + C --> C1["Exchange, Pay, and Card API gaps"] + C --> C2["Compliance and legal review"] + C --> C3["Security signoff"] + C --> C4["Issuer and production validation"] +``` + +### Team model and phase assumptions + +1. Phase 0 assumes a small definition group: product lead, design lead, engineering lead, analytics lead, and compliance/operations input. +2. Phase 1 assumes a dedicated integration workstream for Exchange, Pay, and Card contracts can move in parallel with shared platform/design-system support. +3. Phase 2 assumes a Core Experience pod can move in parallel with continued integration and platform support. +4. Phase 3 assumes Trade and Funding workstreams run in parallel with continued Core Experience support. +5. Phase 4 assumes a Banking and Spend pod owns card, Pay, issuer workflows, and transaction models. +6. Phase 5 assumes portfolio and transfer work require tighter coordination between wallet, CEX, Pay, Card, and activity domains. +7. Phase 6 assumes a growth-oriented pod can focus on Earn, notifications, and personalization while core teams stabilize prior launches. + +### Rollout sequence and decision gates + +```mermaid +flowchart LR + P0["Phase 0
Discovery + ecosystem audit"] --> P1["Phase 1
API remediation + foundation"] + P1 --> G1{"Gate 1
Critical service gaps understood?"} + G1 -->|Yes| P2["Phase 2
Shell + Home + Activity"] + G1 -->|No| R1["Re-baseline integration scope"] + R1 --> P1 + + P2 --> G2{"Gate 2
Discoverability up?"} + G2 -->|Yes| P3["Phase 3
Trade + funding unification"] + G2 -->|No| R2["Refine IA and flows"] + R2 --> P2 + + P3 --> G3{"Gate 3
Conversion up and support down?"} + G3 -->|Yes| P4["Phase 4
Banking + Spend v1"] + G3 -->|No| R3["Refine routing and confirmation UX"] + R3 --> P3 + + P4 --> G4{"Gate 4
Spend adoption validates path?"} + G4 -->|Yes| P5["Phase 5
Portfolio + transfers + Earn"] + G4 -->|No| R4["Refine banking/card model"] + R4 --> P4 + + P5 --> G5{"Gate 5
Cross-domain adoption validates scale?"} + G5 -->|Yes| P6["Phase 6
Advanced and growth"] + G5 -->|No| RB["Escalate rebuild decision"] +``` + +### KPI model + +```mermaid +flowchart TD + A["North Star: funded user retention"] --> B["Activation"] + A --> C["Trade conversion"] + A --> D["Portfolio engagement"] + A --> E["Support reduction"] + A --> S["Spend activation"] + + B --> B1["First funded action within 24h"] + C --> C1["Quote to execution rate"] + D --> D1["Combined portfolio weekly use"] + E --> E1["Tickets per 1,000 transactions"] + S --> S1["Card top-up and Pay activation"] + + A --> F["Guardrails"] + F --> F1["Execution failure rate"] + F --> F2["KYC drop-off"] + F --> F3["Crash-free sessions"] + F --> F4["Failed bank/card operations"] +``` + +## Investment Areas Required + +1. Product and design +- IA redesign +- New trade, activity, banking, spend, and portfolio flows + +2. Engineering +- Orchestration layer +- Unified activity model +- Banking and spend domain +- Capability matrix and regional gating +- Shared transaction identity model + +3. Operations and compliance +- Region-by-region capability ownership +- Card issuer and banking-partner operations +- Provider escalation and support runbooks + +4. Analytics +- Funnel and failure instrumentation +- KPI dashboarding + +## Main Risks + +1. Users may be confused by custody differences if labeling is weak. +2. Regional restrictions may create uneven product availability across trading, banking, and card features. +3. Exchange APIs appear mature, but Pay/Card API maturity and event coverage may be weaker or less standardized. +4. Legacy architecture may slow shipping if orchestration boundaries are not enforced. +5. Support may spike during migration if transaction identity remains fragmented across wallet, exchange, Pay, and Card systems. + +## Required Executive Decisions + +1. Approve the hybrid strategy instead of an immediate full rewrite. +2. Fund the unified shell, banking/spend domain, and API remediation work as top product priorities. +3. Establish a cross-functional pod structure around Core Experience, Trade, Banking/Spend, Funding/Transfers, and Trust. +4. Hold delivery to KPI gates rather than output-only milestones. + +## 90-Day Ask + +Approve the following for the next 90 days: +1. Discovery, UX architecture, and service contract definition across wallet, exchange, Pay, and Card. +2. Exchange, Pay, and Card API audit with a remediation plan and critical-gap register. +3. Unified shell and Home prototype implementation. +4. Activity timeline and capability matrix groundwork. +5. Route-ranking proof of concept for Buy, Swap, and Card top-up funding selection. + +If approved, the result will not just be a nicer wallet. It will be Gleec’s first credible unified consumer product across DEX, CEX, Pay, and Card behavior. diff --git a/docs/GLEEC_UNIFIED_APP_PLAN.md b/docs/GLEEC_UNIFIED_APP_PLAN.md new file mode 100644 index 0000000000..3daf611474 --- /dev/null +++ b/docs/GLEEC_UNIFIED_APP_PLAN.md @@ -0,0 +1,829 @@ +# Gleec Unified App Plan (DEX + CEX + Pay + Card) + +Date: March 2, 2026 +Owner: Product + Design + Mobile/Web Engineering +Status: Draft v2 + +## 1. Objective + +Define the next major iteration of Gleec as one seamless app that combines: +- Non-custodial wallet + DEX flows +- Custodial CEX account flows +- Gleec Pay banking/account flows +- Gleec Card lifecycle and spend flows +- Unified portfolio, funding, transfer, support, and compliance experience + +This document includes two delivery options: +- Option A: next iteration on current codebase +- Option B: clean-slate app with migration path + +It also includes a recommended path, roadmap, UX architecture, service assumptions, and execution model. + +## 2. Research Base + +## 2.1 External benchmark: Exodus + +Verified observations from current Exodus materials: +1. Exodus positions itself as a multi-platform self-custody wallet with exchange, portfolio, fiat rails, and trust education inside one app shell. +2. Exodus explicitly frames exchange as a user intent, not a protocol choice. +3. Exodus buy/sell is powered by provider rails and varies by region and KYC state. +4. Exodus uses trust UX heavily: fee explanation, support education, security boundaries, and custody clarity. +5. Exodus demonstrates that product simplicity can sit on top of complex backend/provider routing. + +Implication for Gleec: +- Users should choose goals like `Buy`, `Swap`, `Move`, or `Spend`, not subsystems like `DEX`, `CEX`, `Pay`, or `Card`. + +## 2.2 Current official Gleec ecosystem findings + +From current official Gleec materials and public endpoints: +1. Gleec Pay is positioned as a crypto-friendly IBAN account for requesting, sending, and receiving funds. +2. Gleec Card is positioned as a virtual and plastic card product that can be topped up directly from the Gleec wallet. +3. Gleec Exchange exposes a current public API surface and publishes system status and regulatory information. +4. Exchange API maturity appears higher and more publicly documented than Pay/Card API maturity. +5. Publicly visible product positioning confirms that Pay and Card are not side features. They are part of the consumer value proposition. + +Implication for Gleec: +- The official unified-app plan should treat `Banking and Spend` as a first-class domain, not as a later add-on. +- The roadmap must include API discovery and likely remediation for Pay/Card before front-end dates can be trusted. + +## 2.3 Sources used + +External benchmark sources: +- Exodus mobile wallet: [https://www.exodus.com/mobile/](https://www.exodus.com/mobile/) +- Exodus support: “Getting started with Exodus”: [https://www.exodus.com/support/en/articles/8598609-getting-started-with-exodus](https://www.exodus.com/support/en/articles/8598609-getting-started-with-exodus) +- Exodus support: “How can I buy Bitcoin and crypto”: [https://www.exodus.com/support/en/articles/8598616-how-can-i-buy-bitcoin-and-crypto](https://www.exodus.com/support/en/articles/8598616-how-can-i-buy-bitcoin-and-crypto) +- Exodus support: “How do I swap crypto in Exodus?”: [https://www.exodus.com/support/en/articles/8598618-how-do-i-swap-crypto-in-exodus](https://www.exodus.com/support/en/articles/8598618-how-do-i-swap-crypto-in-exodus) +- Exodus support: “How do I buy crypto with XO Pay in Exodus?”: [https://www.exodus.com/support/en/articles/10776618-how-do-i-buy-crypto-with-xo-pay-in-exodus](https://www.exodus.com/support/en/articles/10776618-how-do-i-buy-crypto-with-xo-pay-in-exodus) +- Exodus support: “Getting started with Trezor on Exodus Desktop”: [https://www.exodus.com/support/en/articles/8598656-getting-started-with-trezor-on-exodus-desktop](https://www.exodus.com/support/en/articles/8598656-getting-started-with-trezor-on-exodus-desktop) + +Official Gleec sources: +- Gleec Card: [https://www.gleec.com/card/](https://www.gleec.com/card/) +- Gleec Pay: [https://www.gleec.com/pay](https://www.gleec.com/pay) +- Gleec Exchange API v3: [https://api.exchange.gleec.com/](https://api.exchange.gleec.com/) +- Gleec system monitor: [https://exchange.gleec.com/system-monitor](https://exchange.gleec.com/system-monitor) +- Gleec licenses and regulations: [https://exchange.gleec.com/licenses-regulations](https://exchange.gleec.com/licenses-regulations) + +Internal repository references used to ground the plan: +- [README.md](/Users/charl/Code/UTXO/gleec-wallet-dev/README.md) +- [fiat_page.dart](/Users/charl/Code/UTXO/gleec-wallet-dev/lib/views/fiat/fiat_page.dart) +- [main_menu_value.dart](/Users/charl/Code/UTXO/gleec-wallet-dev/lib/model/main_menu_value.dart) +- [UNIFIED_GLEEC_APP_PRODUCT_PLAN.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/UNIFIED_GLEEC_APP_PRODUCT_PLAN.md) + +## 3. Current Gleec Baseline + +## 3.1 Existing app baseline + +The current Flutter app already contains major crypto domains: +- Wallet +- Fiat +- DEX +- Bridge +- Market Maker Bot +- NFT +- Settings/Support + +Known product constraints visible in code: +- Fiat provider stack currently appears limited in active configuration. +- Fiat purchase-history flow appears intentionally deferred/TODO. +- Trading and wallet modes are partially separated by routing/menu logic. + +Implication: +- The current app can still act as the shell for a unified product, but it does not currently represent the full consumer ecosystem. + +## 3.2 Ecosystem baseline beyond the current app + +The broader Gleec ecosystem includes: +- Self-custody wallet capabilities in the current app +- Exchange capabilities in separate product surfaces and APIs +- Gleec Pay banking/account behavior outside the current app shell +- Gleec Card behavior outside the current app shell + +Implication: +- The unified-app effort is not only a UI redesign. It is also a cross-system product integration program. + +## 4. Product Vision + +"One Gleec app where users can hold, trade, bank, and spend across custody models without context-switching." + +North Star: +- A user can complete any of these from Home in under 90 seconds: +- Buy crypto with fiat or Pay balance +- Swap between assets with best-route selection +- Move funds between wallet, CEX, Pay, and Card +- Top up a card from the best funding source +- Understand all activity in one coherent timeline + +## 5. Strategic Options + +## 5.1 Option A: next iteration on existing app + +What it is: +- Keep the current Flutter codebase and existing crypto services +- Introduce unified IA shell and orchestration layer +- Integrate Exchange, Pay, and Card progressively behind feature flags + +Pros: +- Faster learning and earlier release path +- Reuses existing wallet/DEX/bridge implementation +- Lower product-delivery risk than a full rewrite now + +Cons: +- Transitional complexity is high +- Legacy UX and architecture constraints remain during migration +- Cross-system contract work is still required + +## 5.2 Option B: clean-slate app + +What it is: +- New app shell, new routing, new domain architecture +- Existing crypto services and new Exchange/Pay/Card services consumed through clean interfaces + +Pros: +- Cleanest long-term product architecture +- Best long-term maintainability +- Strongest opportunity to design without legacy baggage + +Cons: +- Longest path to parity +- Higher migration risk +- Expensive before demand and contract assumptions are validated + +## 5.3 Recommendation + +Use a hybrid strategy: +1. Deliver Option A over the next staged releases to validate unified flows and retention lift. +2. In parallel, build clean foundation domains where debt is highest: portfolio, activity, routing, banking/spend, and capability gating. +3. Decide on full migration only after KPI evidence and service-contract maturity are both validated. + +## 6. Target Users and Jobs To Be Done + +## 6.1 Segments + +1. Beginner investor +- Wants easy buy/sell and confidence +- Needs guided flows, clear fees, and trust + +2. Crypto-native self-custody user +- Wants routing quality, control, and chain tooling +- Needs transparent execution and advanced controls + +3. Hybrid active trader +- Moves between CEX and on-chain often +- Needs one portfolio and low-friction transfers + +4. Earner/spender/freelancer +- Receives value in fiat or crypto and wants to spend it quickly +- Needs Pay, card top-up, banking, statements, and predictable custody labeling + +## 6.2 Core jobs + +1. "Help me get exposure quickly with low friction." +2. "Help me trade at fair execution with clear costs." +3. "Help me move assets safely across wallet, exchange, and banking balances." +4. "Help me spend or transfer money without leaving the app." +5. "Help me understand what happened when something fails." + +## 7. Product Principles + +1. One intent, one flow: users choose `Buy`, `Swap`, `Move`, `Top Up`, or `Spend`, not subsystem names. +2. Custody clarity everywhere: wallet, exchange, Pay, card, and provider states are always visible. +3. Transparent execution: show route, fees, ETA, funding source, and fallback before confirm. +4. Progressive complexity: simple defaults, advanced controls on demand. +5. Recovery by design: every failed operation has a next-best action. +6. Region-aware UX: never dead-end users in unavailable flows. +7. Money graph over product graph: one coherent view of where value sits and how it moves. + +## 8. Experience Architecture (IA) + +## 8.1 Proposed top-level navigation + +Mobile primary navigation: +1. Home +2. Trade +3. Spend +4. Activity +5. Profile + +Desktop/sidebar expansion: +1. Home +2. Assets +3. Trade +4. Spend +5. Earn +6. NFTs +7. Settings +8. Support + +Design decision: +- `Spend` becomes a first-class primary destination because Pay/Card is part of the official scope. +- `Earn` remains important, but should initially live as a secondary destination on mobile to avoid overcrowding the primary nav. + +## 8.2 Core navigation model + +1. One global asset picker used across all domains. +2. Intent-led entry points from Home and asset detail. +3. Contextual sub-tabs within Trade and Spend. +4. Unified activity and support access from anywhere. + +## 9. Unified Trade and Funding Model + +## 9.1 Trade intent engine + +User inputs: +- Source asset/account +- Destination asset/account +- Amount +- Preference profile: best price, fastest, lowest slippage, non-custodial-only + +Engine output: +- Ranked routes across: +- Internal CEX books/liquidity +- External DEX routes/aggregators +- Bridge + swap combinations +- Provider buy/sell rails +- Pay-funded purchase paths when available + +## 9.2 Pre-trade confirmation card + +Must show: +- Route/provider path +- Effective rate +- Fee breakdown by component +- Estimated completion time +- Min received / slippage bound +- Funding source and custody transition warning + +## 9.3 Post-trade lifecycle + +1. Pending: clear status and checkpoints +2. Partial failure: fallback actions, retry, support, or alternate route +3. Complete: receipt + what changed across balances and identifiers + +## 10. Unified Banking and Spend Model + +## 10.1 Spend intent model + +User intents include: +- Add money +- Top up card +- Send bank transfer +- Receive via IBAN +- Spend and track card activity + +System responsibilities: +- Recommend best funding source for card top-up +- Explain FX or crypto-sale impact before top-up +- Keep bank/card operations in the same activity model as crypto operations + +## 10.2 Card funding hierarchy + +Default recommendation order: +1. Stablecoin wallet balance +2. Available exchange balance +3. Available Pay balance +4. Volatile crypto balance with explicit price-impact warning + +## 10.3 Banking operations in scope + +1. View Pay balance +2. View/share IBAN details +3. Send bank transfer +4. Receive bank transfer +5. Convert Pay balance to crypto +6. Convert crypto to Pay balance +7. Fund card from Pay balance +8. View banking transaction history and statements metadata + +## 10.4 Card operations in scope + +1. Card overview and balance +2. Virtual/physical card status +3. Top up card +4. Freeze/unfreeze card +5. Set spending controls and limits where supported +6. Card transaction history +7. Replace/report card issue and dispute entry points where supported + +## 11. Unified Portfolio Model + +## 11.1 Portfolio layers + +1. On-chain wallet balances +2. CEX account balances +3. Gleec Pay balances +4. Card balances +5. Earn positions +6. Pending/locked funds + +## 11.2 Display logic + +1. Default: combined total with clear labels by custody and account type +2. Filters: +- Combined +- Wallet +- CEX +- Pay +- Card + +3. Asset detail includes: +- Total position +- Split by account type +- Available actions based on custody and capability state + +## 12. Key User Flows + +## 12.1 First-run onboarding + +1. Choose starting path: +- Create/import self-custody wallet +- Start with account services +- Connect both + +2. Explain custody models in plain language +3. Set security baseline: +- Passcode/biometric +- Recovery setup for wallet +- 2FA/passkey for account services + +4. Unlock additional services progressively: +- CEX +- Pay +- Card + +Success metric: +- >65% onboarding completion for new users + +## 12.2 Buy crypto + +1. Tap Buy +2. Input amount and target asset +3. See ranked routes by total received and ETA +4. Select payment/funding source +- card provider +- Pay balance +- exchange balance where supported + +5. Complete KYC only when required +6. Track order in Activity + +Success metric: +- +20% buy conversion vs current baseline + +## 12.3 Convert/swap + +1. Tap Swap +2. Choose assets and amount +3. See recommended route and why it was chosen +4. Confirm with full fee/ETA transparency +5. Track execution in Activity + +Success metric: +- -25% support tickets related to status uncertainty + +## 12.4 Internal transfers + +1. Tap Transfer +2. Choose direction: +- Wallet -> CEX +- CEX -> Wallet +- Wallet -> Pay +- Pay -> Wallet +- CEX -> Pay +- Pay -> Card + +3. System determines internal vs on-chain vs provider-mediated path +4. Show fees, network, ETA, and ownership context + +Success metric: +- >95% transfer success without support contact + +## 12.5 Card top-up + +1. Tap Top Up Card +2. Enter amount +3. System recommends funding source +4. Show conversion, fees, and timing +5. Confirm and track in Activity + +Success metric: +- >85% card top-up completion after intent start + +## 12.6 Send bank transfer + +1. Tap Send Bank Transfer +2. Select source Pay balance +3. Enter beneficiary and amount +4. Show settlement expectations and fee details +5. Confirm and track in Activity + +Success metric: +- >90% successful submission for eligible users + +## 13. Security, Trust, and Risk Controls + +1. Distinct safeguards by custody mode +- Self-custody: key/seed education, signing prompts, recovery health checks +- CEX/Pay/Card: 2FA/passkey, device/session management, withdrawal and transfer controls + +2. Transaction risk signals +- New address warnings +- High-volatility and slippage alerts +- Suspicious asset/contract warnings +- High-risk spend or transfer warnings when required by issuer/compliance rules + +3. In-app anti-scam controls +- Verified support channels only +- Clear warning that support never asks for seed/private keys +- Clear separation between wallet security and custodial account security + +4. Audit and observability +- Correlate order ID, tx hash, transfer reference, bank transfer ID, and card transaction reference in one activity entity + +## 14. Compliance and Regionalization + +1. Capability matrix service must cover: +- Country/state availability +- Provider availability +- KYC tier requirements +- Card ordering eligibility +- Pay account eligibility +- Transfer limits and restrictions + +2. UX behavior +- Gray out unavailable actions with explanation and alternatives +- Never dead-end users inside unavailable flows +- Explain which capability is missing: region, KYC, issuer, provider, or account state + +3. Policy model +- Action-level legal and compliance copy generated by region + service + provider/issuer + +## 15. Design System Direction + +1. Visual hierarchy +- Portfolio and primary actions above market noise +- Strong typography for balances, rates, and status + +2. Component priorities +- Unified asset row +- Unified confirmation card +- Unified activity timeline item +- Unified capability gate component +- Unified funding-source selector + +3. Motion +- Tie motion to state progression, not decoration +- Keep financial status readable at all times + +4. Accessibility +- WCAG AA contrast +- Screen-reader labels for balances and statuses +- Dynamic type on mobile + +## 16. Service Discovery and Remediation Plan + +## 16.1 Why this is a dedicated workstream + +A front-end unification plan is not credible unless the underlying services can provide: +- Stable account and balance models +- Transfer primitives and funding-source visibility +- Transaction status and event/webhook coverage +- Unified identifiers for support and activity +- Capability and compliance-state queries + +Exchange public documentation suggests a meaningful API surface already exists. Pay/Card API maturity is less publicly documented and should be treated as a discovery item, not a silent assumption. + +## 16.2 Required service audit outputs + +1. Exchange API audit +- balances +- orders +- deposits/withdrawals +- internal transfers +- account state +- event/status coverage + +2. Pay service audit +- balance model +- IBAN and beneficiary objects +- transfer initiation and status +- statements/history access +- FX/conversion operations +- KYC/account restrictions + +3. Card service audit +- card lifecycle states +- top-up operations +- card balance and ledger +- freeze/unfreeze controls +- limits/controls +- disputes and support hooks +- tokenization/sensitive-data handling boundaries + +4. Cross-system audit +- identity linkage +- activity normalization +- correlation IDs +- entitlement/capability model +- regional gating source of truth + +## 16.3 Remediation categories + +1. Thin adapter only +- Existing APIs are sufficient; app layer needs normalization only + +2. Contract extension +- Missing fields, statuses, filters, or references need service changes + +3. Event model remediation +- Polling or webhook/state coverage is insufficient for good Activity UX + +4. Ledger and identity remediation +- Transaction IDs do not correlate cleanly across systems + +5. Compliance/capability remediation +- Region/KYC state is not queryable early enough for UX gating + +## 17. Technical Execution Plan + +## 17.1 New product domains (BLoC aligned) + +1. `portfolio_domain` +- combines wallet + CEX + Pay + Card + Earn balances + +2. `trade_orchestration_domain` +- intent parsing, quote ranking, pre-trade model, execution state machine + +3. `banking_spend_domain` +- Pay account state, card state, bank transfers, top-up orchestration + +4. `activity_domain` +- unified activity entity model and status tracking + +5. `capability_domain` +- region/provider/issuer availability matrix and policy gating + +6. `identity_session_domain` +- progressive KYC state, custody-boundary messaging, session and security context + +## 17.2 Integration constraints and opportunities + +1. Keep existing wallet/DEX/bridge modules functional while introducing an orchestration layer. +2. Reuse current fiat components where possible, but move provider ranking and order tracking into unified flows. +3. Prefer shared transaction identity format across CEX orders, fiat orders, bank transfers, card top-ups, and on-chain tx. +4. Treat issuer/provider webviews or hosted flows as interim steps, not final UX states. + +## 17.3 Instrumentation + +Funnel events: +- onboarding_started/completed +- buy_initiated/completed/failed +- swap_initiated/completed/failed +- transfer_initiated/completed/failed +- pay_transfer_initiated/completed/failed +- card_topup_initiated/completed/failed + +Quality events: +- quote_shown vs quote_accepted +- execution_latency_ms +- support_entry_from_activity +- capability_gate_seen + +Trust events: +- security_setup_completed +- risk_warning_seen/overridden +- KYC_started/completed/abandoned + +## 18. Delivery Roadmap + +### 18.1 Roadmap assumptions + +This roadmap is the baseline planning case for the broader `wallet + exchange + Pay + card` scope. It includes service discovery and likely remediation. + +Baseline assumptions: +1. Delivery is led by normal cross-functional product, design, engineering, QA, analytics, and release workflows. +2. Multiple pods can work in parallel with shared platform and design-system support. +3. Exchange service contracts are partially reusable from public/current APIs. +4. Pay/Card services will require formal audit and likely at least moderate contract remediation. +5. Compliance, provider, issuer, and security reviews occur within normal operating windows. + +### 18.2 Baseline timeline + +1. Phase 0: 6 weeks +2. Phase 1: 8-12 weeks +3. Phase 2: 8 weeks +4. Phase 3: 10 weeks +5. Phase 4: 10 weeks +6. Phase 5: 8 weeks +7. Phase 6: 8 weeks +8. Total: about 50-58 weeks end to end with overlap and pod parallelization + +### 18.3 AI-assisted timeline + +If Gleec deliberately adopts AI agents across product, design, engineering, QA, and analytics workflows, the roadmap can compress materially. + +AI-assisted assumptions: +1. Agents are used for requirements expansion, story decomposition, UX copy generation, UI scaffolding, analytics support, QA case generation, regression review, and documentation. +2. Human owners still approve architecture, code quality, compliance-sensitive behavior, and release readiness. +3. Provider integrations, issuer operations, legal/compliance approval, and security signoff remain the least compressible workstreams. + +Accelerated range: +1. Total: about 38-46 weeks end to end + +### 18.4 Where AI helps most + +1. PRD-to-epic-to-ticket decomposition +2. Screen-spec expansion and design documentation +3. UI scaffolding and repetitive component implementation +4. Analytics event wiring and coverage audits +5. Test-case generation and regression checklist preparation +6. Documentation and internal enablement materials + +### 18.5 Where AI helps least + +1. Exchange/Pay/Card contract negotiation and remediation +2. Regulatory and compliance interpretation +3. Security signoff for custody-sensitive and spend-sensitive flows +4. Production rollout decisions and incident response +5. Final trust validation for money-moving experiences + +### 18.6 Team model and per-phase assumptions + +1. Phase 0 assumes a definition team: product lead, design lead, engineering lead, analytics lead, and compliance/operations representatives. +2. Phase 1 assumes a dedicated integration workstream for Exchange, Pay, and Card contracts. +3. Phase 2 assumes a Core Experience pod focused on shell, Home, and Activity. +4. Phase 3 assumes Trade and Funding pods work in parallel. +5. Phase 4 assumes a Banking and Spend pod owns card, Pay, issuer workflows, and transaction models. +6. Phase 5 assumes deep coordination across wallet, CEX, Pay, Card, and activity ownership. +7. Phase 6 assumes a growth-oriented workstream for Earn, notifications, and personalization. + +## 19. Phase Detail + +## Phase 0 (6 weeks): discovery and ecosystem audit + +1. Validate user pain points across wallet, exchange, Pay, and Card +2. Finalize unified IA and component model +3. Produce service audit plan and critical unknowns list +4. Define initial capability matrix schema + +Exit criteria: +- Approved PRD, UX flows, service-audit scope, and integration assumptions + +## Phase 1 (8-12 weeks): API remediation and foundation + +1. Audit Exchange, Pay, and Card contracts +2. Close blocking contract gaps +3. Define unified activity and correlation-ID model +4. Define capability and KYC state contracts + +Exit criteria: +- Critical service gaps understood and remediation path approved + +## Phase 2 (8 weeks): unified shell, Home, Activity v1 + +1. New nav shell and Home dashboard +2. Unified read-only Activity timeline +3. Global capability gating and service-status surfaces + +Exit criteria: +- 80% of core tasks discoverable from new shell + +## Phase 3 (10 weeks): trade and funding unification v1 + +1. Unified Swap/Convert/Buy flow with DEX+CEX+provider routing +2. Funding-source selection and clearer checkout states +3. Confirmation card with full fee/ETA/funding transparency + +Exit criteria: +- +10% trade conversion and -15% trade-related support tickets + +## Phase 4 (10 weeks): banking and spend v1 + +1. Spend hub with Pay and Card entry points +2. Card overview and top-up flow +3. Pay account overview and bank transfer flow +4. Banking/card operations added to Activity timeline + +Exit criteria: +- Verified users can complete key Pay/Card tasks in-app with acceptable support load + +## Phase 5 (8 weeks): portfolio, transfers, and Earn unification + +1. Combined portfolio model with wallet/CEX/Pay/Card filters +2. Internal transfer orchestration across domains +3. Earn integrated into combined balance and action model + +Exit criteria: +- >30% of active funded users use combined portfolio view weekly + +## Phase 6 (8 weeks): advanced and growth + +1. Advanced trading mode maturation +2. Deeper card controls, statements, and dispute entry points where supported +3. Notifications, personalization, and optimization + +Exit criteria: +- +15% 30-day retention for funded users + +## 20. KPI Framework + +Primary: +1. New-user activation rate +2. Trade conversion rate +3. Spend activation rate for verified users +4. 30-day retention for funded users + +Secondary: +1. Support tickets per 1,000 orders/transfers/top-ups +2. Average resolution time for failed operations +3. Portfolio engagement across combined and filtered views +4. Card top-up completion rate +5. Pay funded-account activation rate + +Guardrails: +1. Failed execution rate +2. KYC drop-off rate +3. Failed bank/card operation rate +4. Crash-free sessions + +## 21. Operating Model + +Required cross-functional pods: +1. Core Experience pod +- shell, Home, Activity + +2. Trade and Liquidity pod +- routing, quote, execution, exchange integration + +3. Banking and Spend pod +- Pay, Card, issuer interactions, statements, spend controls + +4. Funding and Transfers pod +- money movement between wallet, exchange, Pay, and Card + +5. Trust, Compliance, and Support pod +- capability gating, KYC, policy, support workflows, escalation design + +Cadence: +1. Weekly product/engineering/design triage +2. Biweekly KPI and funnel review +3. Biweekly integration-risk review for Exchange/Pay/Card +4. Monthly regional rollout decision review + +## 22. Risks and Mitigations + +1. Risk: DEX/CEX/Pay/Card route complexity confuses users +- Mitigation: recommended path by default with advanced details on demand + +2. Risk: Regional restrictions create uneven capability sets +- Mitigation: capability matrix and clear alternatives from day one + +3. Risk: Pay/Card contract maturity is weaker than Exchange contract maturity +- Mitigation: dedicated audit/remediation phase before experience commitments + +4. Risk: Legacy architecture slows shipping +- Mitigation: domain-layer strangler approach with explicit module ownership + +5. Risk: Support burden spikes during migration +- Mitigation: unified timeline IDs, in-flow issue recovery, provider/issuer-specific escalation paths + +## 23. Decision Gates + +Gate 1 (after Phase 1): +- If critical Exchange/Pay/Card service gaps are not understood and accepted, re-baseline the entire program. + +Gate 2 (after Phase 2): +- If activation and discoverability metrics improve, continue phased unification. + +Gate 3 (after Phase 3): +- If trade conversion improves >=10% and support load drops >=15%, scale rollout. + +Gate 4 (after Phase 4): +- If Pay/Card activation and support load hit targets, proceed to full portfolio and transfer unification. + +Gate 5 (after Phase 5): +- If cross-domain adoption hits targets, continue iterative path; otherwise trigger broader rebuild decision. + +## 24. Immediate Next 30 Days + +1. Run 12-15 user interviews split across beginner, self-custody, trader, and spender personas. +2. Audit current analytics against required funnel events and close gaps. +3. Produce high-fidelity prototypes for: +- Home +- Unified Trade ticket +- Spend hub +- Activity timeline with issue recovery + +4. Audit Exchange, Pay, and Card service contracts and document gaps. +5. Define unified correlation-ID and activity-event model. +6. Define region/capability matrix schema and rollout ownership. + +## 25. Final Recommendation + +Proceed with the hybrid strategy now: +1. Start with iterative unification on the current app to win speed and validate demand. +2. Build clean foundation domains in parallel where debt is highest. +3. Treat Pay and Card as first-class scope, not as future embellishments. +4. Re-baseline timelines around explicit service discovery and remediation instead of assuming CEX/Pay/Card APIs are already sufficient. +5. Use strict KPI and service-readiness gates to decide whether to continue iteration or move to a broader rebuild. + +This creates a credible path to a truly unified Gleec product across wallet, DEX, CEX, Pay, and Card behavior. diff --git a/docs/GLEEC_UNIFIED_APP_PRD.md b/docs/GLEEC_UNIFIED_APP_PRD.md new file mode 100644 index 0000000000..2538ecb0e2 --- /dev/null +++ b/docs/GLEEC_UNIFIED_APP_PRD.md @@ -0,0 +1,528 @@ +# Gleec Unified App PRD (DEX + CEX + Pay + Card) + +Date: March 2, 2026 +Owner: Product +Status: Draft v2 +Related documents: +- [GLEEC_UNIFIED_APP_PLAN.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/GLEEC_UNIFIED_APP_PLAN.md) +- [GLEEC_UNIFIED_APP_EXECUTIVE_BRIEF.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/GLEEC_UNIFIED_APP_EXECUTIVE_BRIEF.md) +- [GLEEC_UNIFIED_APP_UX_SPEC.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/GLEEC_UNIFIED_APP_UX_SPEC.md) +- [UNIFIED_GLEEC_APP_PRODUCT_PLAN.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/UNIFIED_GLEEC_APP_PRODUCT_PLAN.md) + +## 1. Problem Statement + +Gleec currently offers meaningful capabilities across wallet, fiat, DEX, bridge, exchange, Pay, and Card. However, the user experience is fragmented because those capabilities are distributed across separate app surfaces and separate mental models. + +Users should not need to think in terms of: +- wallet vs fiat vs dex vs exchange vs Pay vs card +- custodial vs non-custodial implementation details +- provider-specific or issuer-specific states +- separate histories and balances for each Gleec surface + +They should be able to think in terms of: +- buy +- swap +- move +- top up +- spend +- track activity + +## 2. Product Goal + +Create a single Gleec app experience where users can move across wallet, DEX, CEX, Pay, Card, fiat, and support flows without context-switching or losing transaction clarity. + +## 3. Goals + +1. Reduce navigation and flow fragmentation. +2. Increase first funded action completion. +3. Increase quote-to-execution conversion. +4. Increase verified-user activation into Pay/Card flows. +5. Reduce support tickets caused by hidden status, unclear fees, unclear custody boundaries, and split histories. +6. Create a scalable architecture for future Earn, advanced trading, and spend operations. + +## 4. Non-Goals + +1. Replace all backend services in the first release. +2. Achieve full feature parity rewrite before shipping a new unified shell. +3. Launch every advanced card feature in Phase 1. +4. Introduce new chains, providers, or issuers purely for roadmap optics. +5. Promise spend/banking timelines before Exchange/Pay/Card contract audits are complete. + +## 5. Personas + +### Persona A: Beginner investor +- Usually starts from fiat +- Prefers guided flows and clear outcomes +- Gets blocked by technical language or transaction ambiguity + +### Persona B: Self-custody crypto user +- Wants transparency and control +- Cares about route quality, fees, and asset movement details +- Will tolerate complexity only if it provides value + +### Persona C: Hybrid trader/investor +- Uses both exchange and on-chain flows +- Wants to move quickly between accounts and positions +- Expects one view of balances and one activity trail + +### Persona D: Earner/spender/freelancer +- Receives fiat or crypto and wants to spend quickly +- Needs banking, top-up, statements, and predictable custody labeling +- Values card and IBAN flows as much as trading flows + +## 6. Success Metrics + +### Primary +1. New-user activation rate: first funded action within 24 hours +2. Quote acceptance rate: quotes accepted / quotes shown +3. Trade execution rate: completed trades / confirmed trade attempts +4. Spend activation rate: verified users who complete first Pay or Card action +5. 30-day retention for funded users + +### Secondary +1. Median time to first successful buy +2. Median time to first successful swap +3. Median time to first successful card top-up +4. Weekly active usage of Activity tab +5. Weekly active usage of combined portfolio view +6. Funded Pay-account activation rate + +### Guardrails +1. Execution failure rate +2. KYC abandonment rate +3. Failed bank/card operation rate +4. Crash-free sessions +5. Duplicate or conflicting balance states + +## 7. Users and Jobs To Be Done + +1. "Help me buy quickly with low friction and no surprises." +2. "Help me convert assets using the best route without making me compare systems myself." +3. "Help me understand where my money is across wallet, exchange, Pay, and card." +4. "Help me top up or move money without leaving the app." +5. "Help me recover when something is delayed, rejected, or partially complete." + +## 8. Product Principles + +1. Intent first +2. Custody visible everywhere +3. Fees, route, and funding-source transparency before confirmation +4. Progressive disclosure for advanced controls +5. Region-aware and issuer-aware gating +6. Recovery UX is part of the core product +7. One activity model across crypto, banking, and spend flows + +## 9. Scope + +## 9.1 In scope for unified app initiative + +1. New app shell and navigation +2. Home dashboard +3. Unified Trade area +4. Unified Spend area +5. Unified Activity timeline +6. Unified portfolio model +7. Wallet<->CEX<->Pay<->Card transfer flow orchestration +8. Capability matrix for region/provider/issuer gating +9. Shared instrumentation and transaction identity model +10. API discovery and remediation work for Exchange, Pay, and Card + +## 9.2 Out of scope for initial phases + +1. Full NFT redesign +2. Full market-maker-bot redesign +3. Social/community product layers +4. Institutional account features +5. Guaranteed support for every possible card-lifecycle or issuer-admin feature in v1 + +## 10. Functional Requirements + +### FR1. Unified navigation +The app must expose a simplified primary navigation based on user intent. + +Acceptance criteria: +1. Mobile primary navigation contains `Home`, `Trade`, `Spend`, `Activity`, `Profile`. +2. Existing module features remain reachable during migration. +3. Deep links and legacy routes continue to resolve without blocking user actions. + +### FR2. Unified Home +The app must provide a single entry screen for portfolio overview and key actions. + +Acceptance criteria: +1. Home shows total portfolio value and 24h delta. +2. Home shows quick actions for `Buy`, `Swap`, `Transfer`, `Top Up`, and `Add Money` where eligible. +3. Home shows custody-aware balances or a clear split toggle. +4. Home shows watchlist or market movers. +5. Home surfaces incomplete setup tasks such as KYC, wallet backup, and card/pay onboarding. + +### FR3. Unified Trade intent engine +The app must support a single trade entry point that can route across CEX, DEX, and providers. + +Acceptance criteria: +1. User selects source asset/account, destination asset/account, and amount in one ticket. +2. System returns ranked routes with best route marked. +3. User can inspect route details before confirming. +4. If only one route is available, the system still shows why it was selected. +5. Funding source and custody impact are visible before confirm. + +### FR4. Confirmation transparency +The app must show clear fee, ETA, route, and funding-source detail before execution. + +Acceptance criteria: +1. Confirmation includes route/provider path. +2. Confirmation includes effective price/rate. +3. Confirmation includes fee components. +4. Confirmation includes estimated completion time. +5. Confirmation includes custody-change warning if applicable. +6. Confirmation includes funding-source label when the source is Pay, Card, or provider-mediated. + +### FR5. Unified Activity timeline +The app must provide one place to track all user-visible activity. + +Acceptance criteria: +1. Timeline includes on-chain transactions, fiat orders, swaps, transfers, account actions, bank transfers, card top-ups, and card transactions where available. +2. Each item has a normalized status model. +3. Each item exposes identifiers relevant to support and tracing. +4. Delayed or failed items expose next-best actions. + +### FR6. Unified portfolio model +The app must support combined and segmented balance views. + +Acceptance criteria: +1. Portfolio can be viewed as `Combined`, `Wallet`, `CEX`, `Pay`, or `Card`. +2. Asset or balance detail shows account breakdown. +3. Locked or pending funds are separately labeled. +4. Price and fiat values update consistently across views. + +### FR7. Unified transfers +The app must support movement between wallet, custodial account, Pay, and Card surfaces. + +Acceptance criteria: +1. User can choose transfer direction. +2. App determines whether movement is internal, on-chain, provider-mediated, or issuer-mediated. +3. Network/fee/timing details are shown before confirm. +4. Final state appears in Activity with traceable identifiers. + +### FR8. Spend hub +The app must provide a first-class destination for banking and spend actions. + +Acceptance criteria: +1. `Spend` includes Pay account and Card entry points. +2. Spend surfaces both balances and action CTAs. +3. Spend entry respects KYC and regional capability state. +4. Users can reach card top-up and bank transfer in at most two taps from Spend. + +### FR9. Gleec Pay banking operations +The app must support core Pay actions needed for consumer utility. + +Acceptance criteria: +1. User can view Pay balance and account status. +2. User can view/share IBAN details where eligible. +3. User can initiate bank transfer where supported. +4. User can track Pay-related activity in Activity. +5. Conversion between Pay balance and crypto is exposed when supported. + +### FR10. Gleec Card operations +The app must support core card actions needed for day-to-day use. + +Acceptance criteria: +1. User can view card status and balance where supported. +2. User can top up card from eligible funding sources. +3. User can freeze/unfreeze card where supported. +4. User can access card transaction history or issuer-backed transaction states. +5. Card-related activity appears in Activity. + +### FR11. Capability gating +The app must support server-driven restrictions by geography, provider, issuer, asset, and account state. + +Acceptance criteria: +1. Unavailable actions are visibly disabled with explanation. +2. Users are not allowed into dead-end flows. +3. Capability checks are available before CTA tap and before final confirm. +4. Changes in provider or issuer availability can be reflected without app release. + +### FR12. Security and trust messaging +The app must adapt risk and security messaging to custody mode. + +Acceptance criteria: +1. Self-custody screens warn users not to share seed/private keys. +2. Account, Pay, and Card screens promote 2FA/passkey and session review where applicable. +3. High-risk destinations or volatile route conditions trigger warnings. +4. Support entry points are clearly verified and in-app. +5. The app clearly distinguishes what Gleec controls vs what the user controls. + +### FR13. Service audit and remediation +The program must validate that required Exchange, Pay, and Card contracts exist before downstream commitments are frozen. + +Acceptance criteria: +1. Exchange, Pay, and Card service audits are completed and documented. +2. Missing critical fields or statuses are categorized by severity. +3. Blocking service gaps are either remediated or explicitly accepted into scope/roadmap changes. +4. Activity and correlation-ID requirements have named service owners. + +## 11. Non-Functional Requirements + +1. Cross-platform support remains mobile, desktop, and web where feasible. +2. Core flows must remain responsive on low-memory devices. +3. All new screens meet WCAG AA contrast standards. +4. Analytics events must be emitted for all major state transitions. +5. Service timeouts and partial failures must degrade gracefully. +6. Sensitive card/banking data exposure must respect issuer/security boundaries. +7. Capability and KYC state must be queryable early enough to support pre-CTA gating. + +## 12. Dependencies + +1. CEX service interfaces and balance/order data access +2. DEX route and execution services +3. Fiat provider APIs and region rules +4. Pay service interfaces and account/transfer data access +5. Card service interfaces and lifecycle/top-up transaction data +6. Capability matrix backend or config service +7. Unified transaction identity and support tooling +8. Design system updates for shared components +9. Compliance and issuer/legal review + +## 13. Risks + +1. Legacy module boundaries may leak into the UX. +2. Route-quality logic may be difficult to explain if overly complex. +3. Region-specific legal, provider, and issuer rules may create uneven experiences. +4. Partial data quality across wallet, CEX, Pay, and Card ledgers may undermine trust. +5. Card/Pay API maturity may be lower than Exchange API maturity. + +## 14. Release Strategy + +1. Feature-flag all new shell and orchestration work. +2. Dogfood internally with staff and support team. +3. Roll out to a limited cohort before defaulting to all users. +4. Keep legacy paths accessible during migration. +5. Do not lock phase dates until Exchange/Pay/Card service audits are complete. + +### 14.1 Delivery assumptions + +The roadmap referenced by this PRD is the baseline planning case for the full `wallet + exchange + Pay + card` scope. It does include discovery and likely service remediation. + +Baseline assumptions: +1. Cross-functional pods execute with normal engineering automation and standard QA/release workflows. +2. Product, design, engineering, analytics, and support operations can work in parallel where dependencies allow. +3. Exchange contracts are partially reusable from current/public APIs. +4. Pay/Card service gaps may require moderate remediation. +5. Major provider, issuer, compliance, or security blockers do not extend beyond normal review windows. + +AI-assisted planning case: +1. If AI agents are adopted deliberately, the overall roadmap can compress by roughly 15% to 25%. +2. The most likely acceleration areas are story decomposition, design-spec expansion, UI scaffolding, analytics wiring, QA case generation, and regression-review support. +3. The least compressible areas remain provider integration, issuer integration, compliance approval, service remediation, security signoff, and rollout governance. + +### 14.2 Team model by phase + +1. Phase 0 assumes a definition team led by product, design, engineering, analytics, and compliance/operations stakeholders. +2. Phase 1 assumes a dedicated integration workstream for Exchange, Pay, and Card services. +3. Phase 2 assumes a Core Experience pod delivers shell, Home, and Activity with shared design-system/platform support. +4. Phase 3 assumes Trade and Funding pods work in parallel on route orchestration and buy/sell/provider flows. +5. Phase 4 assumes a Banking and Spend pod owns Pay, Card, issuer, and statement/transaction experience. +6. Phase 5 assumes tight coordination across wallet, CEX, Pay, Card, portfolio, transfer, and activity ownership. +7. Phase 6 assumes a growth-oriented workstream for Earn, notifications, and optimization while core flows stabilize. + +### 14.3 Timeline reference + +1. Baseline roadmap: about 50-58 weeks end to end. +2. AI-assisted roadmap: about 38-46 weeks end to end. +3. The baseline roadmap should remain the default planning case until Gleec commits to AI-assisted delivery as an explicit operating model. + +## 15. Epics + +### Epic 1: Unified shell and navigation +Outcome: +- Users can access most primary jobs through a simple, intent-first shell. + +Stories: +1. As a new user, I want to land on a simple Home screen so I know what to do next. +2. As a returning user, I want one place to start Buy, Swap, Top Up, Transfer, or Add Money so I do not need to hunt across apps. +3. As a migration user, I want legacy screens to remain reachable so new navigation does not strand me. + +Engineering notes: +- Introduce new navigation scaffold and route adapter layer. +- Preserve legacy route compatibility. + +### Epic 2: Home and unified portfolio +Outcome: +- Users can understand their total position and take the next action quickly. + +Stories: +1. As a user, I want to see my total portfolio with clear custody breakdown. +2. As a user, I want to filter between Combined, Wallet, CEX, Pay, and Card views. +3. As a user, I want detail views to show where the balance lives and what I can do with it. + +Engineering notes: +- New portfolio aggregation domain. +- Shared asset/balance row and detail models. + +### Epic 3: Unified Trade ticket +Outcome: +- Users can buy, sell, or convert from one entry point. + +Stories: +1. As a user, I want the app to recommend the best route without making me compare subsystems manually. +2. As a power user, I want to inspect route details before execution. +3. As a user, I want to see fees, ETA, and minimum receive before I confirm. +4. As a user, I want meaningful fallback guidance if the preferred route fails. + +Engineering notes: +- New trade orchestration domain. +- Quote normalization and ranking. +- Confirmation model shared across routes. + +### Epic 4: Unified Activity timeline +Outcome: +- Users can track all activity and recover from issues in one place. + +Stories: +1. As a user, I want one timeline for orders, swaps, transfers, bank operations, and card actions. +2. As a support user, I want normalized identifiers and states so issues are easier to diagnose. +3. As a user, I want failed or delayed items to show actionable next steps. + +Engineering notes: +- New activity aggregation domain. +- Normalized status/state machine. +- Identifier correlation model. + +### Epic 5: Transfers and funding orchestration +Outcome: +- Users can move value between custody and payment surfaces confidently. + +Stories: +1. As a user, I want to choose transfer direction and see where funds are going. +2. As a user, I want to know whether the movement is internal, on-chain, provider-mediated, or issuer-mediated. +3. As a user, I want to understand fees, arrival time, and status checkpoints. + +Engineering notes: +- Transfer orchestration with destination/account validation. +- Timeline integration. + +### Epic 6: Banking and Spend +Outcome: +- Users can bank and spend without leaving the app. + +Stories: +1. As a verified user, I want a dedicated Spend destination for Pay and Card. +2. As a user, I want to top up my card from the best available funding source. +3. As a user, I want to view my Pay balance and send a bank transfer. +4. As a user, I want card and bank activity to appear in the same history as my crypto activity. + +Engineering notes: +- Banking and spend domain. +- Pay/account models, card models, and issuer/provider adapters. + +### Epic 7: Capability gating and trust +Outcome: +- Users understand what is available to them and why. + +Stories: +1. As a user in a restricted region, I want unavailable actions explained so I know what alternatives remain. +2. As a user, I want risk warnings before high-risk actions. +3. As a user, I want verified support paths in context if something goes wrong. +4. As a user, I want KYC progression explained only when needed. + +Engineering notes: +- Capability matrix domain. +- Server-driven copy and policy gating. + +### Epic 8: Service audit and remediation +Outcome: +- The program has credible backend assumptions. + +Stories: +1. As a product team, we need audited Exchange/Pay/Card contracts before locking experience commitments. +2. As an engineering team, we need explicit remediation owners for missing fields, statuses, and identifiers. +3. As a support team, we need unified correlation IDs before scaling rollout. + +Engineering notes: +- Contract matrix and severity register. +- Named service owners and remediation acceptance criteria. + +## 16. Prioritized User Stories Backlog + +### P0 +1. Exchange/Pay/Card service audit and gap register +2. New shell with `Home`, `Trade`, `Spend`, `Activity`, `Profile` +3. Home dashboard with total portfolio and quick actions +4. Unified Activity read model +5. Buy/Swap route selection and confirmation transparency +6. Capability matrix and disabled-CTA reasons + +### P1 +1. Card top-up flow +2. Pay overview and bank transfer flow +3. Combined/Wallet/CEX/Pay/Card portfolio filter +4. Cross-domain transfer orchestration +5. Support handoff from Activity item + +### P2 +1. Advanced trading mode +2. Earn integration into portfolio model +3. Deeper card controls, statements, and disputes +4. Personalized watchlist and notifications + +## 17. Analytics Requirements + +Events: +1. `home_viewed` +2. `quick_action_tapped` +3. `quote_shown` +4. `quote_accepted` +5. `trade_submitted` +6. `trade_completed` +7. `trade_failed` +8. `activity_item_opened` +9. `support_opened_from_activity` +10. `portfolio_filter_changed` +11. `transfer_submitted` +12. `transfer_completed` +13. `capability_gate_seen` +14. `pay_transfer_submitted` +15. `pay_transfer_completed` +16. `card_topup_submitted` +17. `card_topup_completed` +18. `kyc_prompt_seen` +19. `kyc_completed` + +Properties: +- asset pair +- route type +- custody source/destination +- funding source +- provider +- issuer +- region +- amount band +- failure reason category + +## 18. Rollout Gates + +Gate 1: +- Exchange/Pay/Card service gaps are understood and blocking issues are accepted or remediated. + +Gate 2: +- Home and Activity increase discoverability and reduce route confusion in moderated testing. + +Gate 3: +- Quote acceptance improves by at least 10% in beta cohort. + +Gate 4: +- Trade-related support tickets per 1,000 transactions fall by at least 15%. + +Gate 5: +- Verified-user Pay/Card activation reaches agreed threshold without unacceptable support load. + +Gate 6: +- Combined portfolio is used weekly by at least 30% of funded users. + +## 19. Open Questions + +1. Which CEX services and balances are available to the app today via stable APIs? +2. Which Pay and Card APIs exist today, and which behaviors depend on non-public or manual workflows? +3. Can wallet<->CEX<->Pay<->Card transfers be internalized for some assets, or are they always mediated/on-chain? +4. Which compliance rules differ by country vs state vs provider vs issuer? +5. Which card features are truly available for in-app control vs external issuer surfaces? +6. How much support metadata can be surfaced directly in Activity without exposing internal systems? diff --git a/docs/GLEEC_UNIFIED_APP_UX_SPEC.md b/docs/GLEEC_UNIFIED_APP_UX_SPEC.md new file mode 100644 index 0000000000..f8ca1794a4 --- /dev/null +++ b/docs/GLEEC_UNIFIED_APP_UX_SPEC.md @@ -0,0 +1,724 @@ +# Gleec Unified App UX Spec (DEX + CEX + Pay + Card) + +Date: March 2, 2026 +Owner: Product Design +Status: Draft v2 +Related documents: +- [GLEEC_UNIFIED_APP_PLAN.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/GLEEC_UNIFIED_APP_PLAN.md) +- [GLEEC_UNIFIED_APP_PRD.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/GLEEC_UNIFIED_APP_PRD.md) +- [UNIFIED_GLEEC_APP_PRODUCT_PLAN.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/UNIFIED_GLEEC_APP_PRODUCT_PLAN.md) + +## 1. UX Goals + +1. Reduce product-module thinking. +2. Make custody and account status visible without overwhelming users. +3. Make every high-value action start from one obvious place. +4. Reduce uncertainty during pending, failed, and delayed states. +5. Support both beginner and advanced users through progressive disclosure. +6. Make banking and spend behavior feel native to the app, not bolted on. + +## 2. Global UX Rules + +1. Every asset or balance picker shows account context. +2. Every money-moving confirmation shows fees, ETA, route/provider/issuer, and funding source. +3. Every pending item must have a status explanation. +4. Every failed item must have at least one recovery action. +5. Every screen with custody implications must label `Wallet`, `CEX`, `Pay`, `Card`, or `External provider`. +6. Every capability-gated action must show why it is unavailable. +7. KYC prompts should appear only when an action actually requires them. + +## 3. Navigation Shell + +### Screen: App Shell + +Purpose: +- Provide a unified, stable navigation model across wallet, trading, banking, and spending. + +Primary elements: +- Bottom nav on mobile: `Home`, `Trade`, `Spend`, `Activity`, `Profile` +- Persistent top title and account state +- Global notification center entry +- Global asset/balance search or command entry on desktop/web + +Behavior: +1. Active tab persists across app restarts if the user is logged in. +2. Deep links open the relevant screen inside the new shell. +3. Legacy routes can still be presented within the shell during migration. +4. If the user is not eligible for Spend, the tab still exists but clearly explains capability requirements. + +Acceptance criteria: +1. User can switch between all primary tabs in one tap. +2. Legacy screens do not break shell navigation state. +3. Shell supports signed-out, wallet-only, CEX-only, Pay/Card-eligible, and hybrid users. + +## 4. Home + +### Screen: Home Dashboard + +Purpose: +- Give users a fast answer to "what do I have" and "what can I do next". + +Sections: +1. Portfolio header +- total balance +- 24h change +- privacy toggle for hiding balances + +2. Balance split control +- `Combined` +- `Wallet` +- `CEX` +- `Pay` +- `Card` + +3. Quick actions +- `Buy` +- `Swap` +- `Transfer` +- `Top Up` +- `Add Money` + +4. Asset and balance preview +- top holdings +- top balances by account type +- gains/losses or balance deltas + +5. Market module +- watchlist or movers + +6. Incomplete setup card +- KYC incomplete +- wallet backup incomplete +- security setup incomplete +- Spend unavailable until verification complete + +7. Needs-attention card +- delayed transfer +- failed top-up +- pending bank transfer +- quote expired + +Behavior: +1. If the user has no funds, Home prioritizes onboarding and first action. +2. If the user has unresolved money movement, a summary card appears above the fold. +3. The split filter changes all visible balance blocks consistently. +4. Spend-related actions appear even for ineligible users, but open clear gating instead of dead ends. + +Acceptance criteria: +1. Home loads with a meaningful empty state for unfunded users. +2. Quick actions remain visible above the fold on common mobile sizes. +3. If balances are hidden, all balance and value blocks are consistently masked. +4. User can reach Buy, Swap, Transfer, Top Up, or Add Money within one tap. + +## 5. Trade + +### Screen: Trade Hub + +Purpose: +- Centralize all trading and conversion intents in one place. + +Structure: +- top segmented control or tabs: +- `Simple` +- `Advanced` +- `History` + +Default tab: +- `Simple` + +Acceptance criteria: +1. Simple trade is the default for all new users. +2. Advanced trading is accessible without leaving the Trade area. +3. Trade history can be filtered without leaving the tab. + +### Screen: Simple Trade Ticket + +Purpose: +- Handle Buy, Sell, and Convert in one unified ticket. + +Fields: +1. Intent selector +- `Buy` +- `Sell` +- `Convert` + +2. From field +- asset or funding source +- account context +- available balance + +3. To field +- asset +- account context +- expected receive + +4. Amount entry +- source or destination mode + +5. Route summary card +- recommended route label +- effective rate +- total fees +- ETA +- funding source + +6. Expandable route details +- route path +- provider(s) +- custody changes +- min receive/slippage +- settlement expectations + +7. CTA +- `Review` +- disabled with reason when blocked + +Behavior: +1. The ticket re-quotes on amount and asset changes. +2. If multiple routes are valid, the recommended route is selected by default. +3. If the route requires KYC or account setup, the blocker is shown before confirm. +4. If the user selects non-custodial-only mode, custodial routes are filtered out. +5. If Pay balance can fund the action, it appears as a source option when supported. + +Acceptance criteria: +1. Quote refresh is visibly in progress and does not create stale confirm states. +2. Disabled CTA always has a human-readable reason. +3. User can inspect route details without losing the quote state. +4. Ticket supports wallet-only, CEX-only, Pay-funded, and hybrid account states. + +### Screen: Trade Confirmation + +Purpose: +- Confirm value movement with no ambiguity. + +Content: +1. From and to assets with account labels +2. Rate and expected receive +3. Fee breakdown +4. Route/provider path +5. ETA +6. Funding-source label +7. Risk or slippage warnings +8. Final CTA: `Confirm` + +Behavior: +1. If the quote expires, the screen forces a refresh before confirmation. +2. If capability checks fail after review, the screen explains the change and returns to the ticket. +3. High-volatility or high-slippage conditions surface warnings inline. + +Acceptance criteria: +1. User cannot confirm an expired quote. +2. Confirmation shows all fee components available to the system. +3. Custody changes are explicitly labeled. +4. Funding source is always explicit. +5. Warnings do not obscure or replace the primary financial details. + +### Screen: Trade Status + +Purpose: +- Track submitted trade progress and show next steps. + +States: +1. Submitted +2. Awaiting provider action +3. Awaiting exchange action +4. On-chain confirmation +5. Completed +6. Delayed +7. Failed +8. Partial completion + +Actions by state: +- view details +- copy identifiers +- retry when allowed +- change funding source when allowed +- contact support/provider + +Acceptance criteria: +1. Every status has explanatory copy. +2. Every failed or delayed state has at least one next action. +3. Order ID, tx hash, provider reference, or transfer reference are copyable when available. + +### Screen: Advanced Trade + +Purpose: +- Expose expert features without polluting simple trade. + +Content: +1. Orderbook/chart area +2. Advanced order forms +3. Open orders and history +4. Market details and depth +5. Funding actions from wallet/Pay where supported + +Acceptance criteria: +1. Advanced mode is separated visually from Simple mode. +2. User can return to Simple without losing app-level navigation state. +3. Advanced actions still route into unified Activity where possible. + +## 6. Spend + +### Screen: Spend Hub + +Purpose: +- Make banking and spending first-class product actions. + +Sections: +1. Spend balances +- Pay balance +- Card balance +- available top-up sources summary + +2. Primary actions +- `Top Up Card` +- `Send Bank Transfer` +- `Add Money` +- `View Card` +- `View IBAN` + +3. Eligibility state +- KYC required +- region restricted +- card not ordered +- Pay not yet activated + +4. Recent spend activity +- card transactions +- bank transfers +- top-ups + +Behavior: +1. If the user is not yet eligible, Spend shows a clear unlock path instead of an empty state. +2. If card exists but Pay does not, Spend still remains useful and vice versa. +3. The most urgent spend-related issue appears at the top. + +Acceptance criteria: +1. Spend makes both Pay and Card discoverable in one screen. +2. Eligibility blockers are explicit and actionable. +3. Primary spend tasks are reachable within two taps. + +### Screen: Card Overview + +Purpose: +- Centralize card state, spend balance, and controls. + +Content: +1. Card status +- not ordered +- pending approval +- active +- frozen +- blocked + +2. Card balance +3. CTAs +- `Top Up` +- `Freeze/Unfreeze` +- `Details` +- `Replace` + +4. Spending controls +- monthly or daily limit where supported +- channel controls where supported + +5. Recent card transactions + +Behavior: +1. Virtual and physical card states are distinct when both exist. +2. Sensitive details are protected behind auth and only shown when allowed. +3. Unsupported issuer controls are hidden and replaced with explanatory copy. + +Acceptance criteria: +1. Card status is always visible. +2. Balance and recent activity are accessible without leaving the screen. +3. Freeze/unfreeze actions require confirmation and reflect updated state. + +### Screen: Card Top-Up + +Purpose: +- Let users fund the card from the best source. + +Fields: +1. Amount +2. Funding source selector +- Wallet +- CEX +- Pay +- eligible provider path if needed + +3. Asset/currency selector when applicable +4. Conversion summary +- rate +- fees +- ETA +- source recommendation rationale + +Behavior: +1. The app recommends the best funding source by default. +2. Stable and direct funding options rank above volatile sell paths. +3. If top-up requires conversion, price-impact or FX detail is shown before confirm. +4. If KYC or issuer checks block top-up, the screen explains this before submission. + +Acceptance criteria: +1. Funding source is never ambiguous. +2. User sees rate, fees, and timing before confirm. +3. Top-up creates a timeline item immediately after submission. + +### Screen: Pay Overview + +Purpose: +- Give users a banking home for Gleec Pay. + +Content: +1. Pay balance +2. IBAN details +3. CTAs +- `Send Transfer` +- `Receive` +- `Convert to Crypto` +- `Top Up Card` + +4. Recent banking activity +5. Account restrictions or limit notices + +Behavior: +1. IBAN details can be copied/shared when allowed. +2. If the user is not yet eligible, a clear verification setup path appears. +3. Pay-to-crypto and Pay-to-card actions are cross-linked. + +Acceptance criteria: +1. User can understand the Pay account state at a glance. +2. IBAN and transfer actions are accessible without hidden navigation. +3. Capability restrictions are explicit. + +### Screen: Send Bank Transfer + +Purpose: +- Let users move funds from Pay to an external bank beneficiary. + +Fields: +1. From balance +2. Beneficiary selector or entry +3. Amount +4. Reference/memo +5. Summary +- fees +- ETA +- limits +- compliance note when relevant + +Behavior: +1. Validation happens inline. +2. The user sees expected settlement timing before confirm. +3. If compliance review is triggered, the screen explains the state. + +Acceptance criteria: +1. Invalid beneficiary or amount errors are specific. +2. Submission creates a timeline item with normalized status. +3. Pending-review states are represented clearly. + +### Screen: Receive / Add Money + +Purpose: +- Help users receive funds via Pay or supported provider rails. + +Options: +1. Share IBAN +2. Copy account details +3. Add payment method for supported provider flow +4. Direct users to `Buy` when banking is not available + +Acceptance criteria: +1. The screen explains what type of incoming money it supports. +2. Unsupported paths are replaced with alternatives, not silent omission. + +## 7. Transfers + +### Screen: Transfer Selector + +Purpose: +- Let users move value between wallet, CEX, Pay, and Card surfaces. + +Controls: +1. Direction selector +- `Wallet -> CEX` +- `CEX -> Wallet` +- `Wallet -> Pay` +- `Pay -> Wallet` +- `CEX -> Pay` +- `Pay -> Card` + +2. Asset or balance selector +3. Network selector when required +4. Amount input +5. Summary panel +- destination ownership +- fees +- ETA +- movement type: internal, on-chain, provider, issuer + +Behavior: +1. If an internal movement is possible, it is recommended by default. +2. If on-chain transfer is required, network fees are shown clearly. +3. Destination ownership and custody context are always visible. + +Acceptance criteria: +1. User always sees transfer direction and destination context. +2. App never presents an ambiguous destination. +3. Timeline entry is created immediately after submission. + +## 8. Activity + +### Screen: Activity Timeline + +Purpose: +- Give users one place to understand everything that happened. + +Filters: +1. All +2. Trades +3. Transfers +4. Fiat +5. Banking +6. Card +7. Failed / needs action + +List item content: +1. action type +2. amount and asset/currency +3. status badge +4. timestamp +5. account/custody labels +6. issue indicator when applicable + +Behavior: +1. Failed or delayed items rise toward the top when unresolved. +2. Pull-to-refresh or manual refresh is available on mobile. +3. Tapping an item opens a full detail view. + +Acceptance criteria: +1. Timeline can represent all major movement types in one normalized design. +2. Filters update results without leaving the screen. +3. Empty states explain what will appear here and offer next steps. + +### Screen: Activity Detail + +Purpose: +- Provide detailed tracking and support-ready references. + +Content: +1. normalized status +2. step timeline +3. amount and balance impact +4. route/provider/issuer +5. identifiers +6. recovery actions +7. support entry point + +Acceptance criteria: +1. Status stepper matches the actual lifecycle for that activity type. +2. Support entry point includes enough metadata to reduce manual lookup. +3. User can copy all relevant identifiers. + +## 9. Portfolio and Detail Views + +### Screen: Portfolio View + +Purpose: +- Help users understand total value and distribution. + +Content: +1. total portfolio block +2. split filter +3. asset and balance list +4. pending/locked balances section + +Behavior: +1. Combined view merges holdings by asset or account-currency as appropriate while preserving account breakdown. +2. Wallet, CEX, Pay, and Card filters show only relevant balances. +3. Sorting supports balance, 24h performance, and alphabetical. + +Acceptance criteria: +1. No balance appears duplicated without context. +2. Locked and pending funds are visually distinct from spendable balance. +3. Sorting is consistent across filters. + +### Screen: Asset / Balance Detail + +Purpose: +- Give one detailed page per asset or account balance regardless of where value sits. + +Content: +1. total balance +2. split by wallet/CEX/Pay/Card/earn/locked when relevant +3. price chart or performance block for assets +4. quick actions +5. recent activity for that asset or balance + +Behavior: +1. Actions are context-aware to available balances and account type. +2. If the item is only available in one context, hidden actions are explained. +3. Recent activity filters the global activity model. + +Acceptance criteria: +1. User can understand where all balance for that item is held. +2. User can start Buy, Sell, Swap, Send, Transfer, Top Up, or Spend from this screen when available. +3. Unavailable actions are explained, not silently absent. + +## 10. Profile + +### Screen: Profile Overview + +Purpose: +- Centralize security, account, support, and legal settings. + +Sections: +1. account summary +2. verification status +3. payment methods and funding setup +4. security settings +5. wallet recovery health +6. support +7. legal and regional information + +Acceptance criteria: +1. User can see KYC status without starting a transaction. +2. User can access support from Profile and from Activity. +3. Security actions are grouped by custody type where relevant. + +### Screen: Security Center + +Purpose: +- Make trust operations visible and actionable. + +Content: +1. biometric/passcode status +2. 2FA/passkey status for account services +3. seed/recovery backup health for wallet layer +4. device/session management where applicable +5. anti-scam guidance +6. card/pay security notes where relevant + +Acceptance criteria: +1. Wallet and account security are clearly separated. +2. The screen explains why some controls differ between custody models. +3. Critical security actions are never buried more than one level deep. + +## 11. Onboarding + +### Screen: Welcome and Path Selection + +Purpose: +- Let users choose a starting path without forcing full product understanding. + +Options: +1. `Start with Wallet` +2. `Start with Account` +3. `Connect Both` + +Acceptance criteria: +1. All three paths are available on first run. +2. Each path includes plain-language explanation. +3. User can change or add the other path later. + +### Screen: Custody Explanation + +Purpose: +- Set trust expectations early. + +Content: +1. what Gleec can access in wallet mode +2. what Gleec controls in CEX mode +3. what Gleec controls in Pay/Card mode +4. what recovery and security responsibilities differ + +Acceptance criteria: +1. Copy is simple enough for beginner comprehension. +2. The screen avoids jargon unless explained inline. +3. The user can proceed without being forced into advanced details. + +### Screen: Setup Checklist + +Purpose: +- Guide user to funded and secure state. + +Checklist items: +1. create/import wallet +2. finish account setup +3. verify identity when required +4. activate Pay/Card when desired +5. add payment method or funding source +6. back up recovery phrase +7. enable biometric/passcode or 2FA/passkey + +Acceptance criteria: +1. Checklist reflects user’s chosen path. +2. Completed items remain visibly complete. +3. The user can skip non-blocking steps and return later. + +## 12. Empty, Error, and Edge States + +### Empty states +1. No portfolio yet +- encourage Buy, Deposit, or Create wallet + +2. No activity yet +- explain what will appear here + +3. No Spend access yet +- explain verification or regional requirements + +4. No card yet +- explain ordering or activation path + +Acceptance criteria: +1. Empty states are action-oriented, not decorative. +2. Empty states match user account/custody context. + +### Error states +1. quote unavailable +2. provider unavailable +3. issuer unavailable +4. KYC required +5. network congestion +6. execution delayed +7. transfer under review +8. partial failure + +Acceptance criteria: +1. Each error state provides a cause category when known. +2. Each error state offers retry, fallback, or support. +3. User data entry is preserved where safe. + +## 13. Content and Copy Principles + +1. Prefer direct verbs: Buy, Swap, Transfer, Top Up, Send, Track. +2. Avoid exposing internal architecture names unless needed. +3. Use `Wallet`, `CEX`, `Pay`, `Card`, and `Provider` consistently. +4. Use short, operational microcopy in status screens. +5. Explain restrictions rather than hiding them silently. + +## 14. Accessibility Requirements + +1. All primary actions meet touch target guidelines. +2. Numeric values are screen-reader friendly. +3. Status badges are not color-only. +4. Dynamic type does not break confirmation or timeline layouts. +5. Reduced-motion mode avoids animated status distractions. +6. Sensitive-data reveal patterns remain accessible while preserving security. + +## 15. Design Deliverables Needed + +1. Mobile shell and tab structure +2. Home dashboard +3. Simple trade ticket +4. Trade confirmation +5. Trade status screens +6. Spend hub +7. Card overview and top-up flow +8. Pay overview and bank transfer flow +9. Activity timeline and detail +10. Portfolio and detail views +11. Profile and security center +12. Onboarding path selection and checklist diff --git a/docs/GLEEC_UNIFIED_APP_WIREFRAME_BRIEFS.md b/docs/GLEEC_UNIFIED_APP_WIREFRAME_BRIEFS.md new file mode 100644 index 0000000000..e7a5958112 --- /dev/null +++ b/docs/GLEEC_UNIFIED_APP_WIREFRAME_BRIEFS.md @@ -0,0 +1,548 @@ +# Gleec Unified App Wireframe Briefs and Flow Diagrams + +Date: March 2, 2026 +Owner: Product Design +Status: Draft v1 +Related documents: +- [GLEEC_UNIFIED_APP_UX_SPEC.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/GLEEC_UNIFIED_APP_UX_SPEC.md) +- [GLEEC_UNIFIED_APP_PRD.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/GLEEC_UNIFIED_APP_PRD.md) +- [GLEEC_UNIFIED_APP_PLAN.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/GLEEC_UNIFIED_APP_PLAN.md) +- [UNIFIED_GLEEC_APP_PRODUCT_PLAN.md](/Users/charl/Code/UTXO/gleec-wallet-dev/docs/UNIFIED_GLEEC_APP_PRODUCT_PLAN.md) + +## 1. Purpose + +This document translates the UX spec into design-production briefs. It is meant for wireframing, low-fidelity flow review, and design sequencing. + +This is not a visual-style guide. It is a structural and behavioral brief for: +- frame creation +- state coverage +- flow mapping +- review sequencing +- handoff readiness + +## 2. Design Principles for Wireframes + +1. Lead with user intent, not subsystem branding. +2. Always show custody and account context. +3. Balance-first design: users need to know where funds are before choosing an action. +4. Any money movement flow needs pre-confirm transparency. +5. Delays, failures, and restricted states must be designed at the same fidelity as success paths. +6. Spend flows should feel equal to trading flows, not buried in settings. + +## 3. Navigation Model to Design + +Mobile primary tabs: +1. `Home` +2. `Trade` +3. `Spend` +4. `Activity` +5. `Profile` + +Desktop secondary expansion: +1. `Assets` +2. `Earn` +3. `NFTs` +4. `Support` + +## 4. Deliverable Order + +Design package order: +1. Shell and Home +2. Trade simple flow +3. Spend hub and card/pay overview +4. Activity timeline and detail +5. Transfers and top-up flows +6. Portfolio and detail screens +7. Profile, security, and KYC gating +8. Advanced trading and secondary modules + +## 5. Required Flow Diagrams + +### 5.1 App entry to action + +```mermaid +flowchart LR + A["Open app"] --> B["Home"] + B --> C{"Primary intent?"} + C -->|Buy / Swap| D["Trade"] + C -->|Top Up / Add Money| E["Spend"] + C -->|Move funds| F["Transfer"] + C -->|Track status| G["Activity"] + C -->|Fix setup| H["Profile / Setup checklist"] +``` + +### 5.2 Progressive unlock model + +```mermaid +flowchart TD + A["New user"] --> B["Wallet-only entry"] + B --> C{"Attempts CEX / Pay / Card action?"} + C -->|No| D["Continue self-custody use"] + C -->|Yes| E["Capability gate"] + E --> F["KYC / setup explanation"] + F --> G["Verification flow"] + G --> H["Unlocked account services"] +``` + +### 5.3 Buy / convert flow + +```mermaid +flowchart LR + A["Trade ticket"] --> B["Select source and destination"] + B --> C["Get ranked routes"] + C --> D["Review route and fees"] + D --> E{"Eligible and quote valid?"} + E -->|No| F["Show blocker / refresh quote"] + E -->|Yes| G["Confirm"] + G --> H["Execution status"] + H --> I["Activity detail"] +``` + +### 5.4 Card top-up flow + +```mermaid +flowchart LR + A["Spend hub"] --> B["Top Up Card"] + B --> C["Select amount"] + C --> D["Rank funding sources"] + D --> E["Review conversion, fees, ETA"] + E --> F{"Eligible and sufficient?"} + F -->|No| G["Show KYC / limit / balance blocker"] + F -->|Yes| H["Confirm top-up"] + H --> I["Top-up status"] + I --> J["Activity detail"] +``` + +### 5.5 Bank transfer flow + +```mermaid +flowchart LR + A["Spend hub"] --> B["Send Bank Transfer"] + B --> C["Select beneficiary and amount"] + C --> D["Review limits, fees, ETA"] + D --> E{"Eligible and compliant?"} + E -->|No| F["Show review or restriction state"] + E -->|Yes| G["Confirm transfer"] + G --> H["Pending / in review / completed"] + H --> I["Activity detail"] +``` + +### 5.6 Unified transfer flow + +```mermaid +flowchart TD + A["Transfer entry"] --> B["Select direction"] + B --> C["Wallet ↔ CEX"] + B --> D["Wallet ↔ Pay"] + B --> E["CEX ↔ Pay"] + B --> F["Pay → Card"] + C --> G["Review movement type and fees"] + D --> G + E --> G + F --> G + G --> H["Confirm"] + H --> I["Activity status and recovery"] +``` + +### 5.7 Failure and recovery pattern + +```mermaid +flowchart TD + A["Any operation fails or delays"] --> B["Activity item flagged"] + B --> C["Open detail view"] + C --> D{"Actionable?"} + D -->|Retry| E["Retry path"] + D -->|Alternative route| F["Suggest fallback"] + D -->|Needs support| G["Support handoff with metadata"] + E --> H["Updated status"] + F --> H + G --> H +``` + +## 6. Wireframe Package A: Shell and Home + +### Objective + +Establish the global product model, cross-domain discoverability, and balance-first landing experience. + +### Required frames + +1. Mobile app shell, default state +2. Mobile app shell with Spend gated +3. Desktop shell with expanded sidebar +4. Home, funded user +5. Home, unfunded user +6. Home, hidden-balances state +7. Home, unresolved issue state +8. Home, verified user with Spend enabled + +### Required components + +1. Top app bar with account state +2. Bottom nav +3. Portfolio hero +4. Balance split filter +5. Quick actions row +6. Setup checklist card +7. Needs-attention card +8. Holdings preview +9. Market preview + +### Required decisions + +1. Whether balance split uses tabs, chips, or segmented control +2. Whether quick actions are fixed or horizontally scrollable on smaller devices +3. How prominently unresolved issues appear +4. Where `Earn` is surfaced without taking a primary tab slot + +### Review focus + +1. Does Home explain the entire product in one glance? +2. Is Spend discoverable enough for eligible and ineligible users? +3. Is setup guidance useful without overwhelming the first session? + +## 7. Wireframe Package B: Trade Simple Flow + +### Objective + +Design one consumer-grade trade experience that can abstract DEX, CEX, provider, and Pay-funded paths. + +### Required frames + +1. Trade hub, default simple state +2. Empty ticket +3. Quoted ticket with recommended route +4. Ticket with multiple route options expanded +5. Ticket with KYC gate +6. Ticket with insufficient balance +7. Confirmation state +8. Quote expired state +9. Submitted status +10. Delayed/failed status + +### Required components + +1. Intent selector: Buy / Sell / Convert +2. Source selector with account context +3. Destination selector +4. Amount input +5. Route summary card +6. Expandable route details +7. Confirmation sheet/card +8. Status progress pattern + +### Required decisions + +1. Whether route comparison is inline or sheet-based +2. Whether source and destination are separate full-screen selectors or bottom sheets +3. How much route detail to expose by default for beginners +4. Where to surface Pay balance as a valid funding source + +### Review focus + +1. Can a beginner understand why one route is recommended? +2. Does the user always know where funds are coming from and going to? +3. Can the same pattern handle both provider buy and crypto-to-crypto conversion? + +## 8. Wireframe Package C: Spend Hub and Overview Screens + +### Objective + +Make Gleec Pay and Gleec Card feel like native, primary product surfaces. + +### Required frames + +1. Spend hub, locked/gated +2. Spend hub, eligible but no card yet +3. Spend hub, eligible with active Pay and Card +4. Card overview, active card +5. Card overview, frozen card +6. Card overview, no card ordered +7. Pay overview, active account +8. Pay overview, verification required + +### Required components + +1. Spend hero with Pay and Card balances +2. Spend action cluster +3. Eligibility/verification card +4. Card status panel +5. Pay status panel +6. Recent spend activity list +7. Cross-linking between Pay and Card actions + +### Required decisions + +1. Whether Card and Pay live in one tab with segmented views or stacked cards +2. How to differentiate `Add Money`, `Top Up`, and `Send Transfer` +3. How to visualize card status without using issuer-specific jargon + +### Review focus + +1. Does Spend feel like a primary destination, not a buried financial settings page? +2. Is the unlock path clear when Pay/Card are not yet available? +3. Are top-up and bank-transfer entry points obvious enough? + +## 9. Wireframe Package D: Top-Up, Bank Transfer, and Receive Flows + +### Objective + +Design money movement flows that feel trustworthy and operationally clear. + +### Required frames + +1. Card top-up source selection +2. Card top-up quote/review +3. Card top-up blocked by KYC/limits +4. Bank transfer composer +5. Bank transfer review +6. Bank transfer under-review state +7. Receive / Add Money page +8. Pay-to-crypto conversion entry where supported + +### Required components + +1. Funding source selector +2. Source recommendation card +3. Conversion/FX summary +4. Fees and ETA block +5. Beneficiary selector/entry +6. IBAN details block +7. Capability gate pattern + +### Required decisions + +1. Whether top-up source selection is list-based or account-card based +2. Whether bank-transfer review is a dedicated screen or sticky bottom summary +3. How to label internal vs external movement clearly +4. Whether to show card and Pay balances together on these flows + +### Review focus + +1. Does the user understand the tradeoff between funding sources? +2. Are limits and compliance-review states understandable before confirmation? +3. Can these flows scale to multiple currencies and regions without redesign? + +## 10. Wireframe Package E: Activity and Recovery + +### Objective + +Create one activity model that can hold crypto, bank, and card behaviors without looking inconsistent. + +### Required frames + +1. Activity list, all items +2. Activity filtered to failed/needs action +3. Activity filtered to card +4. Activity filtered to banking +5. Activity detail, successful trade +6. Activity detail, delayed top-up +7. Activity detail, bank transfer under review +8. Activity detail, failed movement with support CTA + +### Required components + +1. Normalized item row +2. Status badge system +3. Lifecycle stepper +4. Identifier module +5. Support handoff module +6. Recovery CTA group + +### Required decisions + +1. How much variance item rows can show by activity type +2. Whether unresolved items pin above recent successful items +3. How to display multiple identifiers without clutter + +### Review focus + +1. Does the user always know what happened and what to do next? +2. Can support rely on this layout to reduce lookup work? +3. Is the timeline still easy to scan with mixed card, bank, and crypto actions? + +## 11. Wireframe Package F: Portfolio and Detail Screens + +### Objective + +Show one coherent money graph across wallet, CEX, Pay, Card, and Earn. + +### Required frames + +1. Combined portfolio +2. Wallet-only portfolio +3. CEX-only portfolio +4. Pay-only balances +5. Card-only balances/state +6. Detail screen for crypto asset +7. Detail screen for Pay balance +8. Detail screen for Card balance/state + +### Required components + +1. Portfolio hero +2. Split filter +3. Holdings/balance list +4. Account breakdown rows +5. Action cluster +6. Recent activity module + +### Required decisions + +1. Whether Pay/Card appear inside the same list model as crypto assets or as account-level rows +2. How to prevent apparent duplication when value exists across multiple surfaces +3. How much charting is relevant for non-asset balances like Pay/Card + +### Review focus + +1. Can users understand where all money sits without reading docs? +2. Does the model help users decide what to do next? +3. Is the combined view comprehensible for hybrid users? + +## 12. Wireframe Package G: Profile, Security, and KYC + +### Objective + +Make trust operations clear while preserving progressive onboarding. + +### Required frames + +1. Profile overview +2. Verification status page +3. Security center +4. Capability-gated entry state from Spend +5. KYC explanation sheet/page +6. Setup checklist state progression + +### Required components + +1. Verification summary card +2. Security summary card +3. Wallet recovery health module +4. Account-services security module +5. Region/legal info module +6. Support entry point + +### Required decisions + +1. Whether KYC prompting is modal, page-based, or step-sheet based +2. How to explain custody differences without too much copy +3. How to separate wallet security from account/Pay/Card security visually + +### Review focus + +1. Does Profile explain what the user has unlocked? +2. Are security responsibilities between self-custody and custodial services clearly separated? +3. Is KYC framed as feature unlock rather than generic bureaucracy? + +## 13. Suggested Low-Fidelity Layout Sketches + +### 13.1 Home + +```text +┌─────────────────────────────────────┐ +│ Total Balance │ +│ $24,580.12 +2.4% │ +│ [Combined][Wallet][CEX][Pay][Card] │ +├─────────────────────────────────────┤ +│ [Buy] [Swap] [Transfer] [Top Up] │ +│ [Add Money] │ +├─────────────────────────────────────┤ +│ Setup checklist / Needs attention │ +├─────────────────────────────────────┤ +│ Holdings preview │ +├─────────────────────────────────────┤ +│ Market movers │ +└─────────────────────────────────────┘ +``` + +### 13.2 Trade ticket + +```text +┌─────────────────────────────────────┐ +│ [Buy][Sell][Convert] │ +│ From │ +│ USDT · Pay balance $1,250 │ +│ To │ +│ BTC · Wallet │ +│ Amount │ +│ $500 │ +├─────────────────────────────────────┤ +│ Recommended route │ +│ CEX instant conversion │ +│ Fees $1.25 · ETA < 30 sec │ +│ [See route details] │ +├─────────────────────────────────────┤ +│ [Review] │ +└─────────────────────────────────────┘ +``` + +### 13.3 Spend hub + +```text +┌─────────────────────────────────────┐ +│ Spend │ +│ Pay €3,450 Card $1,247 │ +├─────────────────────────────────────┤ +│ [Top Up Card] [Send Transfer] │ +│ [Add Money] [View IBAN] │ +├─────────────────────────────────────┤ +│ Eligibility / setup │ +├─────────────────────────────────────┤ +│ Recent spend activity │ +└─────────────────────────────────────┘ +``` + +### 13.4 Card top-up + +```text +┌─────────────────────────────────────┐ +│ Top Up Card │ +│ Amount: $200 │ +├─────────────────────────────────────┤ +│ Recommended source │ +│ USDT · Wallet Best rate │ +│ Other sources │ +│ EUR · Pay │ +│ BTC · Wallet │ +├─────────────────────────────────────┤ +│ Fees / FX / ETA │ +├─────────────────────────────────────┤ +│ [Confirm Top Up] │ +└─────────────────────────────────────┘ +``` + +### 13.5 Activity detail + +```text +┌─────────────────────────────────────┐ +│ Card top-up │ +│ Pending │ +├─────────────────────────────────────┤ +│ Step 1 Submitted Done │ +│ Step 2 Conversion Done │ +│ Step 3 Card funding Pending │ +├─────────────────────────────────────┤ +│ Amount $200 │ +│ Source USDT · Wallet │ +│ Reference abc-123 │ +├─────────────────────────────────────┤ +│ [Retry] [Contact Support] │ +└─────────────────────────────────────┘ +``` + +## 14. Handoff Checklist for Design + +A wireframe package is ready for visual design when: +1. Happy path, empty state, blocked state, and failure state all exist. +2. All money movement screens show custody and funding context. +3. KYC and regional gating are represented before submission, not after. +4. Activity detail has identifiers and recovery actions. +5. Profile/Security explains wallet vs account responsibilities. + +## 15. Review Cadence Recommendation + +1. Review Package A and B first with Product, Design, and Engineering. +2. Review Spend and Activity together, because they share the trust/recovery model. +3. Review Portfolio only after Home and Activity model are stable. +4. Review Profile/KYC with compliance and support in the room. diff --git a/docs/MULTIPLE_FLUTTER_VERSIONS.md b/docs/MULTIPLE_FLUTTER_VERSIONS.md index 561083386a..1636c7181e 100644 --- a/docs/MULTIPLE_FLUTTER_VERSIONS.md +++ b/docs/MULTIPLE_FLUTTER_VERSIONS.md @@ -57,7 +57,7 @@ sudo pacman -R flutter # for Arch 2. Launch Flutter Sidekick -3. Click on "Versions" in the sidebar and download Flutter version `3.35.3` +3. Click on "Versions" in the sidebar and download Flutter version `3.41.4` 4. Set this version as the global default by clicking the "Set as Global" button @@ -92,11 +92,11 @@ sudo pacman -R flutter # for Arch curl -fsSL https://fvm.app/install.sh | bash ``` -2. Install and use Flutter 3.35.3: +2. Install and use Flutter 3.41.4: ```bash - fvm install 3.35.3 - fvm global 3.35.3 + fvm install 3.41.4 + fvm global 3.41.4 ``` 3. Add FVM's default Flutter version to your PATH by adding the following to your `~/.bashrc`, `~/.zshrc`, or equivalent: @@ -131,11 +131,11 @@ sudo pacman -R flutter # for Arch choco install fvm ``` -3. Install and use Flutter 3.35.3: +3. Install and use Flutter 3.41.4: ```powershell - fvm install 3.35.3 - fvm global 3.35.3 + fvm install 3.41.4 + fvm global 3.41.4 ``` 4. Add FVM's Flutter version to your PATH: @@ -158,7 +158,7 @@ To use a specific Flutter version for a project: 2. Run: ```bash - fvm use 3.35.3 + fvm use 3.41.4 ``` This will create a `.fvmrc` file in your project, which specifies the Flutter version to use for this project. diff --git a/docs/POLISH_ISSUES_GAME_PLAN.md b/docs/POLISH_ISSUES_GAME_PLAN.md new file mode 100644 index 0000000000..dfea257737 --- /dev/null +++ b/docs/POLISH_ISSUES_GAME_PLAN.md @@ -0,0 +1,516 @@ +# App Polish Issue Game Plan + +_Reviewed 159 open issues._ + +_Review update (February 9, 2026): Statuses below were re-validated against the current workspace implementation; checkbox states now mirror implementation status (`[x]` for Done), and several items were reclassified based on code evidence. Follow-up fixes were applied for high-risk regressions in swap history, transaction ordering, private key visibility controls, ARRR cancel flow, and mobile tab/address dialogs._ + +## RPC efficiency plan (implemented) +- Goal: reduce avoidable RPC volume in app + SDK without slowing UX-critical paths. +- Scope date: February 12, 2026. + +### Phase 1: stream-first updates with polling fallback +- [x] Expose managed stream subscriptions in SDK for `orderbook`, `swap_status`, and `order_status`. +- [x] Convert orderbook refresh flow to stream-first with stale-guard fallback polling. +- [x] Replace 1-second trading details polling with stream-triggered updates and slower fallback polling. + +### Phase 2: request deduping and payload shaping +- [x] Add short-lived in-flight/result cache for `trade_preimage`, `max_taker_vol`, `max_maker_vol`, and `min_trading_vol`. +- [x] Reduce recurring `my_recent_swaps` payload pressure by using smaller periodic limits and merged incremental state. +- [x] Add adaptive swaps/orders polling cadence when user is outside DEX/Bridge routes. + +### Phase 3: bridge/balance validation and fallback hardening +- [x] Add bridge orderbook depth request cache + in-flight dedupe and reduce retry fan-out pressure. +- [x] Add taker preimage cache parity with bridge validator. +- [x] Keep minute-level balance sweeping as fallback only when real-time balance watchers are unavailable. + +### Verification checklist +- [ ] Confirm no visible regression in orderbook responsiveness on DEX. +- [ ] Confirm swap/order details update immediately on stream events and still recover via fallback polling. +- [ ] Confirm reduced RPC traffic for repeated fee/volume requests during rapid form edits. +- [ ] Confirm balance updates still propagate for both streaming and non-streaming assets. + +## Scope and selection +Included issues that directly affect UI/UX polish (layout, styling, copy, error messaging, perceived performance, and small workflow improvements). Excluded build/release/infra work and major product features that materially expand scope (for example: Payment Requests, Coin Control, Expanded Import Types). Items marked as awaiting design or awaiting API are noted. + +## Prioritization approach +- P0/P1 polish blockers: address first to eliminate confusing states, incorrect UI data, and disruptive modal behavior. +- P2/P3: batch by theme (layout, messaging, responsiveness) to minimize UI churn and QA overhead. +- Dependencies: keep design and API-bound items moving in parallel so fixes can land quickly when ready. + +## Status legend +- Done (verified in codebase): Evidence found in the current codebase. +- Partially addressed (needs verification): Evidence found, but coverage is incomplete or needs QA. +- Not found in codebase: No evidence found in the current codebase; needs verification. +- Blocked (design): Waiting on design guidance. +- Blocked (API): Waiting on API support. + +## Out of scope (not polish) +Examples reviewed but excluded due to feature scope or backend dependency: #2518 (Payment Requests), #2487 (Coin control), #3077 (Expanded wallet import types), #3072 (WIF/private key import), #2717 (Legacy desktop wallet migration). + +## Visual and layout consistency + +- [x] [#3368](https://github.com/GLEECBTC/gleec-wallet/issues/3368) Mobile Keys display layout needs better alignment + - Status: Done (verified in codebase) + - Details: Mobile security key rows now use aligned label/value columns with fixed label width and responsive wrapping. + - Evidence: `lib/views/wallet/wallet_page/common/expandable_private_key_list.dart`. + +- [x] [#3367](https://github.com/GLEECBTC/gleec-wallet/issues/3367) Mobile coin addresses layout overflow + - Status: Done (verified in codebase) + - Details: Mobile address rows now truncate safely, wrap action buttons, and provide a full-address dialog fallback. + - Evidence: `lib/views/wallet/coin_details/coin_details_info/coin_addresses.dart`. + +- [x] [#3366](https://github.com/GLEECBTC/gleec-wallet/issues/3366) Mobile keyboard blocks coin activation view + - Status: Done (verified in codebase) + - Details: Keyboard covers activation list and invites misclicks on mobile. + - Plan: Add keyboard-aware padding and scrollability; reposition or minimize the custom token CTA while typing. + +- [x] [#3365](https://github.com/GLEECBTC/gleec-wallet/issues/3365) Design required for swap export in mobile swaps/orders/history list views + - Status: Done (verified in codebase) + - Details: Mobile swap rows now expose copy UUID/export actions through a compact per-row overflow menu with loading feedback. + - Evidence: `lib/views/dex/entities_list/common/swap_actions_menu.dart`, `lib/views/dex/entities_list/history/history_item.dart`, `lib/views/dex/entities_list/in_progress/in_progress_item.dart`. + +- [x] [#3322](https://github.com/GLEECBTC/gleec-wallet/issues/3322) Swap coin selection dropdown alignment and text overflow on mobile + - Status: Done (verified in codebase) + - Details: Dropdown rows misalign and long names/balances overflow. + - Plan: Standardize row layout (icon/name/balance columns), use ellipsis, tighten padding. + +- [x] [#3299](https://github.com/GLEECBTC/gleec-wallet/issues/3299) Swap page tabs shoehorned on mobile + - Status: Done (verified in codebase) + - Details: Tabs and overflow behavior are mobile-safe, and hidden-tab selected state now remains correct. + - Plan: Use scrollable tabs or a "more" menu; move destructive actions into overflow. + +- [x] [#3337](https://github.com/GLEECBTC/gleec-wallet/issues/3337) Coin page UX enhancements + - Status: Done (verified in codebase) + - Details: HD address list pushes transaction history out of view. + - Plan: Collapse/limit address list by default; add section toggles or tabs to keep history visible. + +- [x] [#3218](https://github.com/GLEECBTC/gleec-wallet/issues/3218) No padding in swap details status view + - Status: Done (verified in codebase) + - Details: Icons and values touch each other; lacks whitespace. + - Plan: Add padding and spacing between icons/values; verify on narrow widths. + +- [x] [#3212](https://github.com/GLEECBTC/gleec-wallet/issues/3212) Makerbot in mobile lacks important elements present in web/desktop + - Status: Done (verified in codebase) + - Details: Mobile Makerbot flow now includes key desktop parity controls and responsive sectioning. + - Evidence: `lib/views/market_maker_bot/market_maker_bot_tab_content_wrapper.dart`, `lib/views/market_maker_bot/market_maker_bot_form.dart`, `lib/views/market_maker_bot/market_maker_bot_form_content.dart`. + +- [x] [#3183](https://github.com/GLEECBTC/gleec-wallet/issues/3183) Memo input punctuation renders strangely + - Status: Done (verified in codebase) + - Details: Punctuation glyphs render incorrectly (likely font fallback). + - Plan: Ensure memo input uses a font with full punctuation glyphs; validate on Linux. + +- [x] [#3157](https://github.com/GLEECBTC/gleec-wallet/issues/3157) Button text overflow in DEX when blocked + - Status: Done (verified in codebase) + - Details: Error text overflows the DEX action button in blocked regions. + - Plan: Allow text wrap or shrink; add max lines and ensure readability. + +- [x] [#3147](https://github.com/GLEECBTC/gleec-wallet/issues/3147) UI: Statistics coin list text color ignores theme (unreadable) + - Status: Done (verified in codebase) + - Details: Text color does not match theme, causing low contrast. + - Plan: Replace hard-coded colors with theme tokens; run contrast checks. + +- [x] [#3136](https://github.com/GLEECBTC/gleec-wallet/issues/3136) Full address display UI issue + - Status: Done (verified in codebase) + - Details: Full-address dialog is available and constrained responsively to avoid narrow-screen overflow. + - Plan: Add a "show full address" modal or expandable row with copy action. + +- [x] [#3135](https://github.com/GLEECBTC/gleec-wallet/issues/3135) Improve wallet coin display layout & fix fiat price update delays + - Status: Done (verified in codebase) + - Details: Balance layout is hard to scan on mobile; fiat updates lag. + - Plan: Reflow layout for hierarchy/alignment and ensure fiat updates are pushed to the UI promptly. + +- [x] [#3096](https://github.com/GLEECBTC/gleec-wallet/issues/3096) UX: Asset selector styling and dark mode polish + - Status: Done (verified in codebase) + - Details: Asset selector styling/contrast diverges from design system. + - Plan: Apply design tokens for backgrounds/borders/text and fix dark mode states. + +- [x] [#3093](https://github.com/GLEECBTC/gleec-wallet/issues/3093) UX: Logout dropdown positioning across resolutions + - Status: Done (verified in codebase) + - Details: Dropdown is clipped or misplaced on resize/scroll. + - Plan: Anchor overlay to the trigger and clamp to viewport bounds on resize. + +- [x] [#3076](https://github.com/GLEECBTC/gleec-wallet/issues/3076) App-wide viewport audit and mobile adaptiveness + - Status: Done (verified in codebase) + - Details: Core app shells and primary navigation now apply responsive constraints/padding for narrow viewports. + - Evidence: `lib/views/common/pages/page_layout.dart`, `lib/router/navigators/main_layout/main_layout_router_delegate.dart`, `lib/views/main_layout/widgets/main_layout_top_bar.dart`, `lib/views/common/main_menu/main_menu_bar_mobile.dart`. + +- [x] [#3075](https://github.com/GLEECBTC/gleec-wallet/issues/3075) Coin details layout optimization and persistent scrollbars + - Status: Done (verified in codebase) + - Details: Transaction history moved above addresses on desktop, charts reduced in height, and scrollbars are now persistently visible. + - Plan: Reorder sections, increase above-the-fold density, and enable persistent scrollbars. + +- [x] [#3057](https://github.com/GLEECBTC/gleec-wallet/issues/3057) NFT network icons are green + - Status: Done (verified in codebase) + - Details: Icons appear with incorrect green tint. + - Plan: Fix icon assets or tint logic; ensure theme-driven colors. + +- [x] [#3025](https://github.com/GLEECBTC/gleec-wallet/issues/3025) Tendermint HD privkey output extends beyond singleaddress + - Status: Done (verified in codebase) + - Details: Privkey display overflows; Tendermint is single-address. + - Plan: Restrict output to index 0 and update layout to prevent overflow. + +- [x] [#3022](https://github.com/GLEECBTC/gleec-wallet/issues/3022) fix(ui): Review and replace hard-coded style values + - Status: Done (verified in codebase) + - Details: Targeted hard-coded style values were replaced with theme-driven tokens/components in high-impact UI paths. + - Evidence: `packages/komodo_ui_kit/lib/src/buttons/ui_primary_button.dart`, `lib/shared/widgets/coin_select_item_widget.dart`, `lib/views/main_layout/widgets/main_layout_top_bar.dart`. + +- [x] [#2980](https://github.com/GLEECBTC/gleec-wallet/issues/2980) fix(ui): Align address list columns + - Status: Done (verified in codebase) + - Details: Address list needs column alignment for readability. + - Plan: Use table-like layout with fixed label columns and flexible values. + +- [x] [#2942](https://github.com/GLEECBTC/gleec-wallet/issues/2942) Duplicated "add assets" button + - Status: Done (verified in codebase) + - Details: Duplicate CTAs on portfolio page. + - Plan: Remove redundant CTA and keep a single primary action. + +- [x] [#2941](https://github.com/GLEECBTC/gleec-wallet/issues/2941) Elements without theme applied need fixing + - Status: Done (verified in codebase) + - Details: Previously non-themed text/components in wallet/topbar/address flows now bind to active theme colors and typography. + - Evidence: `lib/shared/widgets/coin_select_item_widget.dart`, `lib/views/wallet/coin_details/coin_details_info/coin_addresses.dart`, `lib/views/main_layout/widgets/main_layout_top_bar.dart`. + +- [x] [#2936](https://github.com/GLEECBTC/gleec-wallet/issues/2936) Inconsistent toggle component styles + - Status: Done (verified in codebase) + - Details: Toggle usage was consolidated to shared switcher components in coins manager and withdraw custom-fee flows. + - Evidence: `lib/views/wallet/coins_manager/coins_manager_list_item.dart`, `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart`. + +- [x] [#2763](https://github.com/GLEECBTC/gleec-wallet/issues/2763) Move log out button into sidebar + - Status: Done (verified in codebase) + - Details: Logout button breaks on resize; sidebar is stable. + - Plan: Relocate to sidebar and remove top-right button variant. + +- [x] [#2762](https://github.com/GLEECBTC/gleec-wallet/issues/2762) Ludicrous digits on first chart loading + - Status: Done (verified in codebase) + - Details: Charts show absurd values before data settles. + - Plan: Delay rendering until data is valid; clamp/format values during load. + +- [x] [#2741](https://github.com/GLEECBTC/gleec-wallet/issues/2741) The Address Dropdown remains stuck on the screen on scrolling the page + - Status: Done (verified in codebase) + - Details: Dropdown overlay does not scroll/dismiss. + - Plan: Close overlay on scroll and anchor overlay to scrolling container. + +- [x] [#2722](https://github.com/GLEECBTC/gleec-wallet/issues/2722) Restore scrollbars + - Status: Done (verified in codebase) + - Details: Persistent scrollbars restored via DexScrollbar updates across scrollable views. + - Plan: Re-enable persistent scrollbars on long pages. + +- [x] [#2564](https://github.com/GLEECBTC/gleec-wallet/issues/2564) Log Out Button Shifts When Window is Resized + - Status: Done (verified in codebase) + - Details: Logout button moves out of view when resizing. + - Plan: Apply responsive constraints and anchor to consistent layout regions. + +- [x] [#2489](https://github.com/GLEECBTC/gleec-wallet/issues/2489) Allow scrolling of wallet screen from gap in the left part + - Status: Done (verified in codebase) + - Details: Scroll hit-testing now covers empty padding/left gap on desktop. + - Plan: Extend scroll hit-testing to padding regions for consistent behavior. + +- [x] [#3273](https://github.com/GLEECBTC/gleec-wallet/issues/3273) Improved animation while waiting for faucet + - Status: Done (verified in codebase) + - Details: Faucet wait state now shows animated progress dots with contextual text instead of a generic spinner. + - Evidence: `lib/views/wallet/coin_details/faucet/faucet_view.dart`. + +- [x] [#2599](https://github.com/GLEECBTC/gleec-wallet/issues/2599) Review app text elements for typography best practices + - Status: Done (verified in codebase) + - Details: High-traffic wallet, address, and topbar views now consistently use theme typography and improved hierarchy spacing. + - Evidence: `lib/views/wallet/coin_details/coin_details_info/coin_addresses.dart`, `lib/views/main_layout/widgets/main_layout_top_bar.dart`, `lib/shared/widgets/coin_select_item_widget.dart`. + +- [x] [#2944](https://github.com/GLEECBTC/gleec-wallet/issues/2944) Added copy buttons for commit hash etc in settings page + - Status: Done (verified in codebase) + - Details: Copying build metadata is cumbersome. + - Plan: Add copy buttons with feedback (snackbar/toast). + +- [x] [#2958](https://github.com/GLEECBTC/gleec-wallet/issues/2958) Inconsistencies in Buy/Sell tab + - Status: Done (verified in codebase) + - Details: Buy/Sell selection handling was stabilized to prevent unintended resets during dependent updates. + - Evidence: `lib/views/market_maker_bot/market_maker_bot_form_content.dart`. + +- [x] [#2987](https://github.com/GLEECBTC/gleec-wallet/issues/2987) Pin parents to top of coins lists + - Status: Done (verified in codebase) + - Details: Parent assets are buried in lists, reducing discoverability. + - Plan: Pin parent rows above token children; keep search/filters consistent. + +- [x] [#2601](https://github.com/GLEECBTC/gleec-wallet/issues/2601) Sortable table columns + - Status: Done (verified in codebase) + - Details: Portfolio/swap list headers now support deterministic column sorting behavior. + - Evidence: `lib/views/wallet/coins_manager/coins_manager_list_header.dart`, `lib/views/dex/entities_list/history/swap_history_sort_mixin.dart`. + +- [x] [#2803](https://github.com/GLEECBTC/gleec-wallet/issues/2803) Include extended app metadata in settings + - Status: Done (verified in codebase) + - Details: Missing build/version info hinders support. + - Plan: Add metadata section with version, commit hash, build date, and copy actions. + +- [x] [#2546](https://github.com/GLEECBTC/gleec-wallet/issues/2546) FR: Hide balances toggle (stealth mode) + - Status: Done (verified in codebase) + - Details: A persisted settings toggle now masks wallet balances and fiat values in primary balance surfaces. + - Evidence: `lib/views/settings/widgets/general_settings/settings_hide_balances.dart`, `lib/bloc/settings/settings_bloc.dart`, `lib/model/stored_settings.dart`, `lib/shared/widgets/coin_balance.dart`, `lib/shared/widgets/coin_fiat_balance.dart`. + +- [x] [#3097](https://github.com/GLEECBTC/gleec-wallet/issues/3097) UX: Wallet price tap opens charts (shortcut) + - Status: Done (verified in codebase) + - Details: Shortcut requested to open charts from price tap. + - Plan: Add tap target on price, ensure it does not conflict with existing actions. + +- [x] [#3346](https://github.com/GLEECBTC/gleec-wallet/issues/3346) Mention trezor supports just wallet mode currently + - Status: Done (verified in codebase) + - Details: Disabled tabs need a clear reason for Trezor mode. + - Plan: Add tooltip on disabled tabs and helper text in the connect screen. + +## Loading, data refresh, and responsiveness + +- [x] [#3378](https://github.com/GLEECBTC/gleec-wallet/issues/3378) Slow display without spinner for long swap history + - Status: Done (verified in codebase) + - Details: History view now shows a loading state correctly, including initial empty-stream emissions. + - Plan: Add loading indicator or skeleton; keep previous data during fetch. + +- [x] [#3373](https://github.com/GLEECBTC/gleec-wallet/issues/3373) Represent the swap data from kdf accurately in the UI + - Status: Done (verified in codebase) + - Details: Swap model mapping now prefers fraction fields for amount accuracy and respects KDF `recoverable` state. + - Evidence: `lib/model/swap.dart`. + +- [x] [#3364](https://github.com/GLEECBTC/gleec-wallet/issues/3364) EVM tx history does not update until navigation + - Status: Done (verified in codebase) + - Details: New txs appear only after navigating away and back. + - Plan: Trigger refresh on broadcast completion and update list in place. + +- [x] [#3360](https://github.com/GLEECBTC/gleec-wallet/issues/3360) Unconfirmed transactions amount appears as zero until navigation + - Status: Done (verified in codebase) + - Details: Unconfirmed rows show 0 amount until a navigation refresh. + - Plan: Use tx amount data immediately and update state on status change. + +- [x] [#3359](https://github.com/GLEECBTC/gleec-wallet/issues/3359) Duplicated/incorrect tx data for ATOM + - Status: Done (verified in codebase) + - Details: ATOM history shows duplicate or incorrect entries. + - Plan: Deduplicate by hash and verify mapping for internal/external txs. + +- [x] [#3211](https://github.com/GLEECBTC/gleec-wallet/issues/3211) Slow address loading on tab change delays user action + - Status: Done (verified in codebase) + - Details: Address loading uses cached results and pubkey prefetching to reduce tab-switch latency. + - Evidence: `lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart`. + +- [x] [#2593](https://github.com/GLEECBTC/gleec-wallet/issues/2593) HD address list underpopulated after import + - Status: Done (verified in codebase) + - Details: HD pubkey refresh now runs a one-time `scan_for_new_addresses` task per wallet/asset before fetching balances, improving first-load address discovery for imported wallets. + - Evidence: `sdk/packages/komodo_defi_rpc_methods/lib/src/strategies/pubkey/hd_multi_address_strategy.dart`, `sdk/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/hd_wallet/scan_for_new_addresses_status.dart`, `sdk/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart`. + +- [x] [#3094](https://github.com/GLEECBTC/gleec-wallet/issues/3094) UX/Perf: Reduce wallet page jank during coin activation + - Status: Done (verified in codebase) + - Details: Activation flow now pre-seeds activating coins and reduces heavy synchronous refresh behavior. + - Evidence: `lib/bloc/coins_bloc/coins_bloc.dart`. + +- [x] [#3073](https://github.com/GLEECBTC/gleec-wallet/issues/3073) Balances: avoid transient zeroes while loading + - Status: Done (verified in codebase) + - Details: Temporary zero values confuse users during load. + - Plan: Use skeletons/placeholders and transition directly to real values. + +- [x] [#3121](https://github.com/GLEECBTC/gleec-wallet/issues/3121) Refresh transactions list after broadcast + - Status: Done (verified in codebase) + - Details: Tx list and balances do not update post-broadcast. + - Plan: Trigger a delayed refresh after broadcast success and update cached data. + +- [x] [#3302](https://github.com/GLEECBTC/gleec-wallet/issues/3302) Premature Swap page entry prompts competing activations + - Status: Done (verified in codebase) + - Details: Multiple activation triggers compete and fail. + - Plan: Debounce activation requests and ensure a single activation per coin. + +- [x] [#3229](https://github.com/GLEECBTC/gleec-wallet/issues/3229) Review of wasm-bindgen errors and impact on UI responsivity + - Status: Done (verified in codebase) + - Details: Web RPC calls now guard parse/transport failures and return bounded fallback error payloads to keep UI responsive. + - Evidence: `lib/mm2/rpc_web.dart`. + +- [x] [#2761](https://github.com/GLEECBTC/gleec-wallet/issues/2761) Avoid fruitless activation fail loops + - Status: Done (verified in codebase) + - Details: ARRR activation retries were narrowed to retryable errors and now honor explicit cancel flow. + - Evidence: `lib/services/arrr_activation/arrr_activation_service.dart`. + +- [x] [#3001](https://github.com/GLEECBTC/gleec-wallet/issues/3001) False "unrecoverable" failed swaps + - Status: Done (verified in codebase) + - Details: Failed swap UI now uses KDF `recoverable` state and avoids false unrecoverable labeling. + - Evidence: `lib/model/swap.dart`, `lib/views/dex/entities_list/history/history_item.dart`. + +- [x] [#2986](https://github.com/GLEECBTC/gleec-wallet/issues/2986) Misc unhandled errors in web console + - Status: Done (verified in codebase) + - Details: Web RPC and error parsing paths now handle malformed responses defensively to prevent noisy unhandled console errors. + - Evidence: `lib/mm2/rpc_web.dart`, `lib/mm2/mm2_api/rpc/rpc_error.dart`. + +## Error messaging and validation + +- [x] [#3357](https://github.com/GLEECBTC/gleec-wallet/issues/3357) EVM send max error on HD + - Status: Done (verified in codebase) + - Details: Send-max fails when parent gas balance is missing. + - Plan: Detect missing gas, show clear message, and disable max when invalid. + +- [x] [#3356](https://github.com/GLEECBTC/gleec-wallet/issues/3356) [SDK] Implement Localised, Actionable Error Messaging System + - Status: Done (verified in codebase) + - Details: Error display normalization now maps common KDF error families to localized, actionable messages. + - Evidence: `lib/shared/utils/kdf_error_display.dart`, `lib/bloc/withdraw_form/withdraw_form_bloc.dart`. + +- [x] [#3292](https://github.com/GLEECBTC/gleec-wallet/issues/3292) Ambiguous error on connection fail + - Status: Done (verified in codebase) + - Details: "NoSuchCoin" hides real connection failures. + - Plan: Map connection errors to user-friendly text and recovery steps. + +- [x] [#3151](https://github.com/GLEECBTC/gleec-wallet/issues/3151) Add password length overflow error messaging + - Status: Done (verified in codebase) + - Details: Passwords over 128 chars get no warning. + - Plan: Add inline validation and explain the length limit. + +- [x] [#3081](https://github.com/GLEECBTC/gleec-wallet/issues/3081) Insufficient-gas and common errors: user-friendly messages + - Status: Done (verified in codebase) + - Details: Withdraw errors now normalize insufficient gas/fee and related transport errors into clearer user guidance. + - Evidence: `lib/bloc/withdraw_form/withdraw_form_bloc.dart`. + +- [x] [#2884](https://github.com/GLEECBTC/gleec-wallet/issues/2884) Trezor: `User cancelled action` shows as "something went wrong" + - Status: Done (verified in codebase) + - Details: User-cancel is treated as a generic error. + - Plan: Map to a specific cancellation message. + +- [x] [#2881](https://github.com/GLEECBTC/gleec-wallet/issues/2881) Improve Trezor PIN error message + - Status: Done (verified in codebase) + - Details: PIN error message is unclear. + - Plan: Use explicit Invalid PIN messaging and guidance. + +- [x] [#2766](https://github.com/GLEECBTC/gleec-wallet/issues/2766) Trezor hidden wallet passphrase should be non-empty + - Status: Done (verified in codebase) + - Details: Hidden wallet can be entered without a passphrase. + - Plan: Require a non-empty passphrase before continuing. + +- [x] [#2996](https://github.com/GLEECBTC/gleec-wallet/issues/2996) Custom fee input has misleading `$` prefix, bad defaults + - Status: Done (verified in codebase) + - Details: Custom fee inputs now use chain-appropriate units/defaults and no longer present misleading dollar-style prefixes. + - Evidence: `lib/bloc/withdraw_form/withdraw_form_bloc.dart`, `sdk/packages/komodo_ui/lib/src/core/inputs/fee_info_input.dart`. + +- [x] [#3331](https://github.com/GLEECBTC/gleec-wallet/issues/3331) Coin failing activation listed on portfolio page + - Status: Done (verified in codebase) + - Details: Active coins list now filters out inactive/suspended assets, so failed activations no longer appear as active. + - Plan: Remove from active list or mark as failed with retry CTA. + +- [x] [#2950](https://github.com/GLEECBTC/gleec-wallet/issues/2950) Coin wallet in failed activation state has active buttons + - Status: Done (verified in codebase) + - Details: Coin detail action buttons now disable correctly when activation has not succeeded. + - Evidence: `lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart`. + +- [x] [#2801](https://github.com/GLEECBTC/gleec-wallet/issues/2801) Transaction modal has misleading confirmations + - Status: Done (verified in codebase) + - Details: Transaction detail modal now labels unknown confirmation/block-height values safely instead of implying finality. + - Evidence: `lib/views/wallet/coin_details/transactions/transaction_details.dart`. + +- [x] [#3303](https://github.com/GLEECBTC/gleec-wallet/issues/3303) [ux] Toggle visibility of seed / pw + - Status: Done (verified in codebase) + - Details: Sensitive key actions remain gated by explicit visibility controls. + - Plan: Keep visible until user toggles off; add an explicit hide control. + +- [x] [#3024](https://github.com/GLEECBTC/gleec-wallet/issues/3024) Enhancement: Granular privkey visibility toggle + - Status: Done (verified in codebase) + - Details: Per-key visibility exists and private-key copy/QR actions now respect the visibility gate. + - Plan: Add per-row toggle with masked default and audit logging. + +- [x] [#2985](https://github.com/GLEECBTC/gleec-wallet/issues/2985) Inactive coin remains on main wallet view appearing active + - Status: Done (verified in codebase) + - Details: Active coins list now filters out inactive/suspended assets to prevent stale entries. + - Plan: Update state handling to mark inactive and remove from active list. + +- [x] [#2994](https://github.com/GLEECBTC/gleec-wallet/issues/2994) Default enabled coins can't be disabled + - Status: Done (verified in codebase) + - Details: Disabled coins re-enable on re-login. + - Plan: Persist disabled state in wallet preferences and apply on load. + +- [x] [#3282](https://github.com/GLEECBTC/gleec-wallet/issues/3282) Cancel config for ARRR activation leaves toggle active + - Status: Done (verified in codebase) + - Details: User-cancelled ARRR configuration now rolls back toggle/selection state without surfacing a suspended failure state. + - Plan: Roll back UI state when cancel occurs and sync with activation state. + +## Workflow and interaction polish + +- [x] [#3397](https://github.com/GLEECBTC/gleec-wallet/issues/3397) Mobile orientation change dismisses modals + - Status: Done (verified in codebase) + - Details: Dialog presentation now consistently uses root navigator + guarded close flow to avoid unintended dismiss during orientation/layout churn. + - Evidence: `lib/shared/widgets/app_dialog.dart`, `lib/shared/widgets/connect_wallet/connect_wallet_button.dart`. + +- [x] [#3340](https://github.com/GLEECBTC/gleec-wallet/issues/3340) Login popups sometimes show when they shouldnt + - Status: Done (verified in codebase) + - Details: Login popup triggers now include stronger session/auth guards and non-dismissible modal handling in connect flow. + - Evidence: `lib/shared/widgets/connect_wallet/connect_wallet_button.dart`, `lib/shared/widgets/remember_wallet_service.dart`. + +- [x] [#3231](https://github.com/GLEECBTC/gleec-wallet/issues/3231) Don't dismiss login/import modal for out of bound touch events + - Status: Done (verified in codebase) + - Details: Tapping outside modal discards user input. + - Plan: Disable barrier dismiss and add explicit cancel/back actions. + +- [x] [#3277](https://github.com/GLEECBTC/gleec-wallet/issues/3277) Makerbot sell list only contains activated coins + - Status: Done (verified in codebase) + - Details: Sell list excludes inactive coins, reducing discovery. + - Plan: Show all coins with activation status or prompt to activate on select. + +- [x] [#2497](https://github.com/GLEECBTC/gleec-wallet/issues/2497) Expose bot configuration options in settings + - Status: Done (verified in codebase) + - Details: Market maker trade configuration now exposes broader stale-price validity interval choices and persists selected interval per pair. + - Evidence: `lib/views/market_maker_bot/update_interval_dropdown.dart`, `lib/views/market_maker_bot/trade_bot_update_interval.dart`, `lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_state.dart`. + +- [x] [#2504](https://github.com/GLEECBTC/gleec-wallet/issues/2504) Export/import maker orders + - Status: Done (verified in codebase) + - Details: Trading-bot settings now include maker-order export/import actions and a persisted `Save orders` toggle; when disabled, stored maker-order configs are cleared on next app launch. + - Evidence: `lib/views/settings/widgets/general_settings/settings_manage_trading_bot.dart`, `lib/model/settings/market_maker_bot_settings.dart`, `lib/services/initializer/app_bootstrapper.dart`, `assets/translations/en.json`. + +- [x] [#3217](https://github.com/GLEECBTC/gleec-wallet/issues/3217) Bot order details view does not persist upon price adjustment loop + - Status: Done (verified in codebase) + - Details: Order details now track refreshed bot orders to avoid resets on price updates. + - Plan: Preserve selected order state across refresh updates. + +- [x] [#3178](https://github.com/GLEECBTC/gleec-wallet/issues/3178) Add cancel button in activation ARRR progress pane + - Status: Done (verified in codebase) + - Details: ARRR activation status pane includes a cancel action wired to a real cancellation path in the activation service. + - Evidence: `lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart`, `lib/services/arrr_activation/arrr_activation_service.dart`. + +- [x] [#3095](https://github.com/GLEECBTC/gleec-wallet/issues/3095) UX: Wallet name validation and rename-on-import + - Status: Done (verified in codebase) + - Details: Wallet naming lacks validation and rename during import. + - Plan: Add inline validation and allow renaming in the import flow. + +- [x] [#3089](https://github.com/GLEECBTC/gleec-wallet/issues/3089) UX: Persist HD mode across sessions + - Status: Done (verified in codebase) + - Details: HD/legacy mode does not persist across sessions. + - Plan: Store mode preference and honor it during migration. + +- [x] [#3083](https://github.com/GLEECBTC/gleec-wallet/issues/3083) DEX: Sell dropdown uses wallet coins list sorting + - Status: Done (verified in codebase) + - Details: Sell list ordering differs from wallet list. + - Plan: Reuse wallet sorting logic for DEX dropdowns. + +- [x] [#3078](https://github.com/GLEECBTC/gleec-wallet/issues/3078) Wallet list tags: HD/Iguana, Generated/Imported, Date + - Status: Done (verified in codebase) + - Details: Wallet metadata now stores mode/provenance/date and wallet list rows render corresponding quick tags. + - Evidence: `lib/model/wallet.dart`, `lib/model/kdf_auth_metadata_extension.dart`, `lib/bloc/auth_bloc/auth_bloc.dart`, `lib/views/wallets_manager/widgets/wallet_list_item.dart`. + +- [x] [#3071](https://github.com/GLEECBTC/gleec-wallet/issues/3071) Login import: show custom seed input only when BIP39 validation fails + - Status: Done (verified in codebase) + - Details: Custom-seed toggle now appears only after non-HD BIP39 failure and is hidden in HD mode. + - Plan: Hide advanced input until validation fails; provide explicit "show advanced" option. + +- [x] [#2517](https://github.com/GLEECBTC/gleec-wallet/issues/2517) Wallet Seed Field BIP39 Input Suggestions + - Status: Done (verified in codebase) + - Details: Seed import now provides live BIP39 word suggestions and selectable suggestion chips while keeping custom-seed and HD validation flows intact. + - Evidence: `lib/views/wallets_manager/widgets/wallet_simple_import.dart`. + +- [x] [#2984](https://github.com/GLEECBTC/gleec-wallet/issues/2984) Bulk disable & coin activation view consolidation + - Status: Done (verified in codebase) + - Details: Coins manager flow now unifies activation/bulk actions with corrected select-all state behavior and cleaner controls. + - Evidence: `lib/views/wallet/coins_manager/coins_manager_list_wrapper.dart`, `lib/views/wallet/coins_manager/coins_manager_controls.dart`, `lib/views/wallet/coins_manager/coins_manager_select_all_button.dart`, `lib/bloc/coins_manager/coins_manager_bloc.dart`. + +- [x] [#2749](https://github.com/GLEECBTC/gleec-wallet/issues/2749) Improve UX where user is expected to be patient + - Status: Done (verified in codebase) + - Details: Long wait states now provide contextual progress messaging and improved visual feedback instead of bare spinners. + - Evidence: `lib/views/wallet/coin_details/faucet/faucet_view.dart`, `lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart`. + +- [x] [#2680](https://github.com/GLEECBTC/gleec-wallet/issues/2680) Option to input custom fees on Tendermint/IBC withdraw not available + - Status: Done (verified in codebase) + - Details: Tendermint/IBC withdraw flow now exposes custom gas/fee controls with corresponding UI inputs. + - Evidence: `lib/bloc/withdraw_form/withdraw_form_state.dart`, `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart`, `sdk/packages/komodo_ui/lib/src/core/inputs/fee_info_input.dart`. + +- [x] [#3241](https://github.com/GLEECBTC/gleec-wallet/issues/3241) Multi-Address Wallet Mode causes address/balance confusion + - Status: Done (verified in codebase) + - Details: Added an in-wallet notice explaining multi-address mode and balance differences. + - Plan: Add explicit messaging, warnings, and a clear explanation of address set changes. + +- [x] [#3092](https://github.com/GLEECBTC/gleec-wallet/issues/3092) UX: Withdraw fee priority selector (EVM/Tendermint) + - Status: Done (verified in codebase) + - Details: Withdraw flow supports user-selectable fee priority tiers where chain support is available. + - Evidence: `lib/bloc/withdraw_form/withdraw_form_state.dart`, `lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart`, `sdk/packages/komodo_ui/lib/src/defi/transaction/withdrawal_priority.dart`. diff --git a/docs/QA_TEST_CASE_GENERATION_PROMPT.md b/docs/QA_TEST_CASE_GENERATION_PROMPT.md new file mode 100644 index 0000000000..10c77415f1 --- /dev/null +++ b/docs/QA_TEST_CASE_GENERATION_PROMPT.md @@ -0,0 +1,213 @@ +You are a Senior QA Test Architect for cryptocurrency wallet and DEX products. + +Your task is to generate a **complete manual test case document** for the entire Gleec Wallet app, intended for QA testers to execute manually. + +## Non-Negotiable Execution Rules + +1. Produce the **entire deliverable in one response**. +2. Do **not** stop early, ask follow-up questions, split work into phases, or wait for confirmation. +3. Do **not** leave placeholders such as "TBD", "to be added", or "sample only". +4. If output space is tight, compress wording, but still include every required section. +5. The output must be directly usable by manual QA without rewriting. + +## Quality Standards To Apply + +Use these industry standards as guidance: + +- IEEE 29119 principles for test documentation and structure. +- ISTQB test design techniques: equivalence partitioning, boundary value analysis, state-transition, decision table, and error guessing. +- Risk-based testing for prioritization (business-critical flows first). +- ISO/IEC 25010 quality attributes (functional suitability, reliability, usability, security, compatibility, performance). +- OWASP MASVS-inspired manual security checks for wallet/mobile contexts. +- WCAG 2.2 AA accessibility checks for manual UI validation. + +## Product Context + +Application under test: **Gleec Wallet** (Flutter/Dart, multi-platform: Web, Android, iOS, macOS, Linux, Windows). + +Core capabilities: + +- Non-custodial wallet creation/import/login/logout. +- Multi-coin activation, balances, addresses, transaction history. +- Send/withdraw flows. +- DEX trading (maker/taker, orderbook, swap history). +- Bridge flows. +- NFT management. +- Settings and app preferences. +- Market maker bot configuration. +- Routing/navigation and responsive layouts. +- Hardware wallet-related flows (Trezor where available). + +## Mandatory Test-Coin and Network Policy + +All manual test cases that involve blockchain activity must use **testnet/faucet assets only**. + +- Use faucet coins such as **DOC** and **MARTY**. +- Faucet endpoint pattern: `https://faucet.gleec.com/faucet/{COIN}/{ADDRESS}`. +- Ensure the app setting for test coins is enabled before coin visibility checks. +- Never use real-value assets for this test document. +- Include expected behavior for faucet outcomes: success, denied/cooldown, and network/error responses. + +## Scope (Must Be Fully Covered) + +Generate manual test cases for all areas below: + +1. Authentication and wallet lifecycle +2. Wallet manager (create/import/rename/delete/select/remember) +3. Coin manager (activate/deactivate/search/filter/test-coin visibility) +4. Wallet dashboard and balances (including hide balances / hide zero balances) +5. Coin details (addresses, explorer links, chart visibility, transaction list/details) +6. Withdraw/send flows (validation, fees, confirmation, success/failure, memo/tag where applicable) +7. DEX flows (maker/taker, orderbook, order lifecycle, swap lifecycle, history, export) +8. Bridge flows (pair/protocol validation, amount validation, success/failure handling) +9. NFT flows (list/details/send/history/filter) +10. Settings (theme, analytics/privacy, test coins, diagnostic/logging-related toggles, persistence) +11. Market maker bot flows (create/edit/start/stop/validation) +12. Navigation and routing (menu routes, deep links where applicable, back navigation) +13. Responsive behavior (mobile/tablet/desktop breakpoints) +14. Cross-platform checks (Web, Android, iOS, macOS, Linux, Windows) +15. Accessibility checks (keyboard nav, screen reader labels, focus order, touch targets, color contrast) +16. Security and privacy checks (seed phrase handling, clipboard exposure risk, session handling, sensitive-data display) +17. Error handling and recovery (network outages, partial failures, invalid inputs, retries, stale states) +18. Localization/readability checks (overflow, formatting, date/number display) + +## Deliverable Format Requirements + +Produce one comprehensive document with these sections and in this order: + +### 1. Test Strategy Summary + +- Objective +- In-scope / out-of-scope +- Assumptions +- Risks +- Entry/exit criteria + +### 2. Test Environment Matrix + +For each platform, include: + +- OS/device/browser +- Build type +- Network condition notes +- Required test accounts/wallet setup + +### 3. Test Data Strategy + +Include: + +- Wallet profiles (new wallet, imported wallet, funded faucet wallet) +- Coin sets (DOC/MARTY and any non-test coin visibility controls) +- Address sets (valid, invalid, unsupported format) +- Amount sets (min, max, precision, insufficient-balance scenarios) + +### 4. Requirement/Feature Traceability Matrix (RTM) + +Create a matrix mapping: + +- Feature ID +- Feature name +- Risk level +- Mapped test case IDs +- Platform coverage + +### 5. Detailed Manual Test Cases (Core Section) + +Generate detailed test cases using this template for every test: + +- **Test Case ID** (format: `GW-MAN--`) +- **Module** +- **Title** +- **Priority** (`P0`, `P1`, `P2`, `P3`) +- **Severity if failed** (`S1`, `S2`, `S3`, `S4`) +- **Type** (`Smoke`, `Functional`, `Negative`, `Boundary`, `Regression`, `Security`, `Accessibility`, `Compatibility`, `Recovery`) +- **Platform(s)** +- **Preconditions** +- **Test Data** +- **Steps** (numbered, explicit user actions) +- **Expected Result** +- **Post-conditions** +- **Dependencies/Notes** + +### 6. End-to-End User Journey Suites + +Include fully detailed E2E suites such as: + +- New user onboarding to first funded transaction (DOC/MARTY) +- Restore/import wallet to active trading +- Faucet funding to withdraw/send verification +- DEX order placement to completion/cancel verification +- Settings persistence across logout/restart + +### 7. Non-Functional Manual Test Suite + +Include manual tests for: + +- Performance perception/responsiveness checkpoints +- Reliability and recovery behavior +- Accessibility +- Security/privacy +- Compatibility/platform differences + +### 8. Regression Pack Definition + +Define three packs with explicit test IDs: + +- `Smoke Pack` (fast, release-gating) +- `Critical Regression Pack` (money movement + auth + data integrity) +- `Full Regression Pack` (complete coverage) + +### 9. Defect Classification Model + +Provide: + +- Severity scale definition (S1-S4) +- Priority scale definition (P0-P3) +- Reproducibility labels +- Required bug report fields + +### 10. Execution Order and Time Estimate + +Provide: + +- Recommended execution sequence by risk +- Estimated execution time per module +- Suggested parallel tester allocation + +### 11. Test Completion Checklist + +Provide a sign-off checklist QA can use to confirm coverage completion. + +### 12. Final Coverage Statement + +Explicitly state that the document covers the full app scope and identify any assumptions made. + +## Coverage Depth Rules + +- Cover happy path, negative path, boundary path, and recovery path for each critical feature. +- Ensure money-movement features (send/withdraw/DEX/bridge/faucet) have the highest depth. +- Include both first-time-user and returning-user scenarios. +- Include interrupted-flow cases (navigation away, app restart, network drop) where applicable. +- Include persistence checks (state/settings retained after relaunch/logout/login). + +## Prioritization Rules + +Use risk-based prioritization: + +- **P0**: Auth, wallet access, seed/security, faucet funding, send/withdraw, DEX trade execution, bridge execution, transaction correctness. +- **P1**: Coin activation/visibility, orderbook/history correctness, settings persistence, NFT send/history. +- **P2**: Advanced filters, secondary UI controls, localization polish. +- **P3**: Nice-to-have UX refinements and low-risk cosmetic checks. + +## Output Constraints + +- Output must be clean Markdown. +- Use readable tables for RTM, environment matrix, and regression pack summaries. +- Use numbered steps for every test case. +- Do not output implementation code. +- Do not output automation scripts. +- Do not reference internal uncertainty; make reasonable assumptions and continue. + +## Final Instruction + +Generate the **complete manual test case document now**, in a **single uninterrupted response**, fully covering the whole application and using **DOC/MARTY faucet-based testing** for blockchain-dependent scenarios. diff --git a/docs/SDK_APP_FULL_DIFF_REVIEW_PROMPT.md b/docs/SDK_APP_FULL_DIFF_REVIEW_PROMPT.md new file mode 100644 index 0000000000..5e96093034 --- /dev/null +++ b/docs/SDK_APP_FULL_DIFF_REVIEW_PROMPT.md @@ -0,0 +1,191 @@ +# SDK + App Full Diff Review Prompt + +You are a blocking senior code review agent. Your job is to fully review two branch diffs in this workspace and write the final findings to a markdown document. This is not a spot check. You must not finish until every changed file in both diffs has been reviewed and the findings document is written. + +## Workspace + +- App repo: `/Users/charl/Code/UTXO/gleec-wallet-dev` +- SDK repo: `/Users/charl/Code/UTXO/gleec-wallet-dev/sdk` + +## Exact Review Scope + +Review these two diffs: + +1. App diff: `dev...polish/05-docs-and-release-notes` +2. SDK diff: `dev...polish/01-core-foundation` + +Branch resolution rules: + +- In the app repo, prefer local `polish/05-docs-and-release-notes`; fall back to `origin/polish/05-docs-and-release-notes` only if the local branch does not exist. +- In the SDK repo, prefer local `polish/01-core-foundation` if it exists; otherwise use `origin/polish/01-core-foundation`. +- Do not silently substitute any other branch or SHA. + +## Required Output + +Write the final review to: + +- `docs/SDK_APP_DIFF_REVIEW_FINDINGS.md` + +The file must be clean Markdown and must be complete before you stop. + +## Non-Negotiable Rules + +1. Review the entire diff for both repos, not a sample. +2. Do not stop after finding the first issue. +3. Do not stop after reviewing only high-risk files. +4. Do not stop until `docs/SDK_APP_DIFF_REVIEW_FINDINGS.md` exists and includes a reviewed-files appendix covering every changed file from both diffs. +5. Treat this as a code review, not an implementation task. Do not make product code changes unless explicitly asked. +6. Do not revert unrelated local changes. The worktree may already be dirty. +7. Unit and integration tests in this repo are currently unreliable. Use thorough code review and static analysis instead of relying on tests for validation. +8. Findings are the primary output. If there are no findings, state that explicitly, but only after proving full coverage. + +## Review Method You Must Follow + +### 1. Resolve refs and capture the exact changed-file lists + +Run commands equivalent to the following and keep the resulting file lists as your review checklist: + +```bash +cd /Users/charl/Code/UTXO/gleec-wallet-dev +git rev-parse --verify dev +git rev-parse --verify polish/05-docs-and-release-notes || git rev-parse --verify origin/polish/05-docs-and-release-notes +git diff --name-status dev...polish/05-docs-and-release-notes || git diff --name-status dev...origin/polish/05-docs-and-release-notes + +cd /Users/charl/Code/UTXO/gleec-wallet-dev/sdk +git rev-parse --verify dev +git rev-parse --verify polish/01-core-foundation || git rev-parse --verify origin/polish/01-core-foundation +git diff --name-status dev...polish/01-core-foundation || git diff --name-status dev...origin/polish/01-core-foundation +``` + +Also inspect: + +- `git diff --stat` +- `git log --oneline --no-merges dev...` + +### 2. Review every changed file file-by-file + +For each changed file in each diff: + +- Read the actual patch. +- Read enough surrounding code to understand the full behavior. +- Read dependent callers, callees, models, DTOs, serializers, mappers, extensions, BLoCs, services, repositories, widgets, routes, and docs that are necessary to judge correctness. +- If a file changes a public API, check downstream usage and compatibility. +- If a file changes docs or release notes, verify that the claims are accurate, complete, non-misleading, and match actual behavior and migration requirements. +- If a file changes the SDK or app `sdk` submodule reference, validate the integration implications. + +You must maintain a running checklist so no changed file is skipped. + +### 3. Review for the failure modes below + +Check for all of these, wherever relevant: + +- Logic bugs +- Behavioral regressions +- Missing edge-case handling +- Null-safety mistakes +- Async race conditions and stale state +- Stream/subscription lifecycle leaks +- BLoC event/state inconsistencies +- Incorrect default values or fallback behavior +- Serialization/deserialization mismatches +- RPC contract breakage +- Breaking public API changes without migration handling +- Persistence or schema migration issues +- Numeric precision, rounding, fee, and amount-validation bugs +- Auth, wallet, seed, and privacy/security regressions +- Platform-specific issues across Web, Android, iOS, macOS, Linux, and Windows where applicable +- Navigation, lifecycle, and restoration issues +- Error handling gaps and swallowed failures +- Incorrect loading, retry, timeout, or offline behavior +- Docs/release-note inaccuracies, missing caveats, or unsafe instructions + +### 4. Run static analysis + +Run static analysis where feasible and record the results in the report: + +```bash +cd /Users/charl/Code/UTXO/gleec-wallet-dev +flutter analyze + +cd /Users/charl/Code/UTXO/gleec-wallet-dev/sdk +flutter analyze +``` + +If analysis fails because of pre-existing issues, separate pre-existing noise from diff-related findings as clearly as possible. Do not use failing tests as a reason to reduce review depth. + +### 5. Cross-check app and SDK together + +Do not review the SDK and app in isolation only. Also check for cross-repo mismatch risk, including: + +- SDK API/model changes that would break app assumptions +- App docs or release notes that describe behavior not supported by the SDK diff +- Missing release note callouts for breaking changes, migrations, config changes, or user-visible behavior changes +- Submodule pointer changes that do not line up with the reviewed SDK branch intent + +## Output Format for `docs/SDK_APP_DIFF_REVIEW_FINDINGS.md` + +Use this structure: + +### 1. Scope + +- Exact repos and diffs reviewed +- Exact refs resolved +- Review date + +### 2. Review Summary + +- Overall verdict +- Count of findings by severity +- Key risk themes + +### 3. Findings + +List findings first, ordered by severity highest to lowest. + +For each finding, include: + +- Finding ID +- Severity: `Blocker`, `High`, `Medium`, or `Low` +- Repo: `App` or `SDK` +- Diff: exact diff reviewed +- File path and 1-based line numbers +- Clear title +- Why this is a bug, regression, or missing edge-case handling +- Concrete scenario or failure mode +- Recommended fix direction + +If there are no findings, this section must say `No findings after full diff review`, and the rest of the document must still prove that the review was completed. + +### 4. Static Analysis + +- App `flutter analyze` result +- SDK `flutter analyze` result +- Note any diff-related analyzer issues separately from pre-existing issues + +### 5. Residual Risks and Verification Gaps + +- Anything that could not be fully proven by static review alone +- Any high-risk assumptions that deserve manual verification + +### 6. Reviewed Files Appendix + +This appendix is mandatory. + +Create a table with one row for every changed file from both diffs. + +Columns: + +- Repo +- Diff +- Status (`Reviewed`) +- File +- Findings (`None` or comma-separated Finding IDs) +- Notes + +Do not stop until every changed file from both diffs appears in this appendix. + +## Review Standard + +Your standard is: if this merged into `dev`, what could break, regress, mislead users, or fail on edge cases? + +Be skeptical. Read broadly enough around the changed code to make a defensible judgment. Do not declare completion until both diffs are fully covered and the markdown report is written. diff --git a/docs/UNIFIED_GLEEC_APP_PRODUCT_PLAN.md b/docs/UNIFIED_GLEEC_APP_PRODUCT_PLAN.md new file mode 100644 index 0000000000..dfa732d71a --- /dev/null +++ b/docs/UNIFIED_GLEEC_APP_PRODUCT_PLAN.md @@ -0,0 +1,2028 @@ +# Gleec Unified App: Product Vision & Design Plan + +## Combining DEX, CEX, Pay, and Card into One Seamless Experience + +**Version:** 1.0 — Draft +**Date:** February 2026 +**Reference Benchmark:** Exodus Wallet + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [Vision & Strategic Goals](#2-vision--strategic-goals) +3. [Competitive Landscape & Exodus Deep-Dive](#3-competitive-landscape--exodus-deep-dive) +4. [Current State Assessment — Gleec Ecosystem](#4-current-state-assessment--gleec-ecosystem) +5. [Target User Personas](#5-target-user-personas) +6. [Information Architecture & Navigation](#6-information-architecture--navigation) +7. [Screen-by-Screen Design Specifications](#7-screen-by-screen-design-specifications) +8. [Onboarding & Authentication](#8-onboarding--authentication) +9. [Unified Trading Experience (DEX + CEX)](#9-unified-trading-experience-dex--cex) +10. [Banking & Card Integration (Gleec Pay + Card)](#10-banking--card-integration-gleec-pay--card) +11. [Design System & Visual Language](#11-design-system--visual-language) +12. [Technical Architecture](#12-technical-architecture) +13. [Phased Delivery Roadmap](#13-phased-delivery-roadmap) +14. [Success Metrics & KPIs](#14-success-metrics--kpis) +15. [Risks & Mitigations](#15-risks--mitigations) +16. [Appendix](#16-appendix) + +--- + +## 1. Executive Summary + +Gleec currently operates several independent products — a self-custody DEX wallet, a centralized exchange (CEX), a crypto-friendly banking service (Gleec Pay), and a Visa debit card (Gleec Card). Each serves a distinct purpose, but the fragmentation forces users to context-switch between apps, manage separate accounts, and mentally reconcile balances across platforms. + +This document proposes **Gleec One** — a single, unified application that merges all Gleec services into one cohesive experience modelled after the design excellence of Exodus, enhanced with CEX trading, fiat banking, and card management capabilities that Exodus lacks. + +**The core thesis:** Users should never have to leave the app. Whether they want to hold, swap, trade on an order book, spend with a card, or receive a bank transfer — it all happens in one place, with one identity, and one portfolio view. + +### Build Strategy: New App, Reuse SDK + +Gleec One is a **new Flutter application**, not a refactor of the existing wallet. The Komodo DeFi SDK and its associated packages are imported as dependencies into a fresh project. This gives us: + +- A clean architecture from day one with no inherited tech debt in the UI or state layers +- Freedom to adopt the new navigation, design system, and feature structure without migration friction +- Parallel development — the existing wallet stays in maintenance mode while Gleec One is built +- A clear boundary: the SDK handles crypto (swaps, activation, balances), the app handles everything else (CEX, Card, Pay, UX) + +The existing wallet codebase serves as a **reference implementation** — its BLoC logic for withdraw flows, swap progress tracking, and market maker bot state machines is ported into the new feature modules, cleaned up along the way. + +### Key Outcomes + +| Outcome | Description | +|---|---| +| **Single portfolio** | One unified view of all assets across self-custody, exchange, and banking | +| **Frictionless trading** | Swap via DEX or trade on CEX order books from the same screen | +| **Spend anywhere** | Top up and manage the Gleec Visa Card without leaving the wallet | +| **Bank-grade fiat** | IBAN, SEPA, and fiat on/off ramps built in | +| **Exodus-level UX** | Beautiful, intuitive, beginner-friendly — with power-user depth | + +--- + +## 2. Vision & Strategic Goals + +### Product Vision + +> **Gleec One is the everything-app for crypto.** One download. One login. Hold, trade, swap, spend, and bank — all in a single interface that feels as polished as Exodus and as powerful as a professional trading platform. + +### Strategic Goals + +#### 2.1 Consolidation over Fragmentation + +The current Gleec ecosystem asks users to install and manage multiple apps: the DEX wallet, the CEX web/mobile app, the Gleec Pay portal, and the Card app. Each has its own authentication, its own UI language, and its own mental model. This fragmentation: + +- Increases onboarding friction (multiple sign-ups, multiple KYC flows) +- Splits user attention and creates confusion about "where my money is" +- Prevents cross-selling (a DEX user may never discover Gleec Pay) +- Dilutes brand identity across inconsistent interfaces + +**Goal:** Reduce the number of Gleec-branded apps from 4+ to 1 unified application. + +#### 2.2 Exodus as the UX North Star + +Exodus consistently ranks among the top crypto wallets for design and usability. Key attributes to adopt: + +| Exodus Attribute | Gleec One Adaptation | +|---|---| +| Zero-friction onboarding (no KYC for basic wallet) | Tiered onboarding: instant wallet, progressive KYC for CEX/Pay | +| Left-sidebar navigation (desktop) / bottom tab bar (mobile) | Adopt identical pattern with expanded sections | +| Portfolio-first home screen | Unified portfolio aggregating all balances | +| Integrated swap with no external redirects | DEX swaps + CEX order routing in one flow | +| Dark/light mode with premium visual polish | Custom design system with Gleec brand identity | +| Hardware wallet support (Trezor/Ledger) | Maintain and extend Trezor support, add Ledger | + +#### 2.3 Competitive Differentiation + +What makes Gleec One unique versus Exodus, Trust Wallet, or Coin98: + +1. **True CEX + DEX convergence.** No other major wallet seamlessly offers both atomic-swap DEX trading AND centralized order-book trading in one interface. Users choose their trade execution venue per transaction. + +2. **Built-in banking.** Gleec Pay's IBAN and Gleec Card's Visa debit are first-class citizens, not afterthoughts. Users can receive salary, pay bills, and spend at merchants — all from the same app. + +3. **Self-custody by default, custodial by choice.** The wallet is non-custodial at its core. CEX balances and Pay balances are clearly separated and labelled, giving users full transparency about custody. + +4. **Institutional-grade market making.** The existing market maker bot becomes an advanced feature for power users and liquidity providers. + +#### 2.4 Design Principles + +These principles guide every design decision in Gleec One: + +| # | Principle | Description | +|---|---|---| +| 1 | **Progressive disclosure** | Show beginners a simple interface; reveal complexity on demand | +| 2 | **Single source of truth** | One portfolio, one balance view, one transaction history | +| 3 | **Custody transparency** | Always make it clear whether funds are self-custody, on exchange, or in Pay | +| 4 | **Speed over ceremony** | Minimize taps/clicks to complete any action | +| 5 | **Trust through clarity** | No jargon, no ambiguity — every state and error is human-readable | +| 6 | **Platform parity** | Desktop, mobile, and web should feel like the same app | +| 7 | **Accessible by default** | WCAG AA compliance, scalable text, screen reader support | + +--- + +## 3. Competitive Landscape & Exodus Deep-Dive + +### 3.1 Market Overview + +The crypto wallet market in 2025-2026 is converging on a "super-app" model. Key trends: + +- **Built-in exchange is table stakes.** Wallets without integrated swaps are losing market share. +- **Embedded wallets grow 3x faster** than traditional external wallets (Dune Wallet Report v2). +- **Fiat on/off ramps are expected,** not optional. MoonPay, Ramp, Banxa integration is standard. +- **Chain abstraction is the future.** Users should not need to think about which chain their assets are on. +- **Account abstraction and passkeys** are replacing seed phrases for mainstream users. +- **KYC is becoming tiered.** Basic wallet = no KYC; trading/banking = progressive verification. + +### 3.2 Exodus Wallet — Design Audit + +Exodus is the primary design benchmark. Here is a detailed analysis of what they do well and where Gleec One can surpass them. + +#### What Exodus Does Exceptionally Well + +**1. Portfolio Home Screen** +- Opens directly to a portfolio view showing total balance in fiat +- Pie chart or bar showing asset allocation +- Each asset shows: icon, name, amount, fiat value, 24h change +- Clean hierarchy: total balance (hero) → asset list (scrollable) +- Clicking an asset drills into that asset's detail page + +**2. Left Sidebar Navigation (Desktop)** +- Persistent sidebar with icon + label for each section +- Sections: Portfolio, Wallet (Assets), Exchange, Staking, Apps +- Active state is clearly indicated +- Collapses gracefully on narrow screens +- Feels like a native macOS/Windows app + +**3. Bottom Tab Bar (Mobile)** +- Standard iOS/Android tab bar pattern: Portfolio, Assets, Exchange, Browser, Settings +- Familiar to every smartphone user +- Badge indicators for pending transactions + +**4. Exchange / Swap Flow** +- Two-token selector (From → To) with amount input +- Real-time rate display with estimated output +- One-tap swap execution +- No registration, no external redirect +- Progress tracking with clear status states + +**5. Onboarding** +- Download → Open → Wallet is ready (zero config) +- Seed phrase backup is encouraged but not forced upfront +- Password/biometric lock added after first use +- No email, no account creation for basic wallet + +**6. Visual Design** +- Dark mode by default with excellent contrast +- Subtle gradients and glassmorphism elements +- Asset icons are high-quality and consistently styled +- Typography is clear with strong hierarchy (Manrope-style sans-serif) +- Animations are smooth and purposeful (not gratuitous) + +**7. Send/Receive** +- Clean form: recipient address, amount, fee tier +- QR code scanner and address book +- Transaction preview before broadcast +- Clear success/failure states with explorer links + +#### Where Exodus Falls Short (Gleec One Opportunities) + +| Exodus Limitation | Gleec One Opportunity | +|---|---| +| No order-book trading | Full CEX order-book with limit/market/stop orders | +| No fiat banking / IBAN | Gleec Pay IBAN integration as first-class feature | +| No debit card | Gleec Card management built into the app | +| Swap-only exchange (no limit orders on DEX) | Maker/taker orders on DEX + CEX limit orders | +| No margin/futures | Gleec CEX futures and margin trading (for verified users) | +| Partial open source | Fully open-source DEX layer (Komodo SDK) | +| No market-making tools | Built-in market maker bot for advanced users | +| Limited staking options | Expandable staking with both on-chain and exchange staking | +| No multi-wallet / account management | Multiple wallet profiles + exchange accounts | +| No 2FA | Full 2FA for exchange/banking features | +| Browser extension only for Web3 | Integrated dApp browser in mobile app | + +### 3.3 Other Competitors — Quick Comparison + +| Feature | Exodus | Trust Wallet | Coin98 | Gleec One (Proposed) | +|---|---|---|---|---| +| Self-custody wallet | Yes | Yes | Yes | Yes | +| Built-in DEX swaps | Yes | Yes | Yes | Yes | +| CEX order-book trading | No | No | No | **Yes** | +| Fiat banking (IBAN) | No | No | No | **Yes** | +| Debit card | No | Via partner | No | **Yes (Gleec Card)** | +| Market maker bot | No | No | No | **Yes** | +| Hardware wallet support | Trezor + Ledger | Ledger | No | Trezor (+ Ledger planned) | +| NFT support | Yes | Yes | Yes | Yes (planned) | +| Staking | Yes | Yes | Yes | Yes | +| Fiat on-ramp | MoonPay/Ramp | MoonPay | Via partners | Banxa + Gleec Pay | +| Cross-chain bridge | Limited | Via partners | Yes | Yes | +| Futures / Margin | No | No | No | **Yes (via CEX)** | +| Open source | Partial | Yes | Partial | Yes (DEX/SDK layer) | + +--- + +## 4. Current State Assessment — Gleec Ecosystem + +### 4.1 Product Inventory + +Gleec currently operates the following products independently: + +#### Gleec DEX Wallet (This Codebase) +- **Platform:** Flutter — Web, Desktop (Win/macOS/Linux), Mobile (iOS/Android) +- **Architecture:** BLoC pattern, Komodo DeFi SDK, MM2 protocol +- **Core features:** Self-custody wallet, atomic-swap DEX, cross-chain bridge, fiat on-ramp (Banxa), market maker bot, portfolio charts, NFTs (disabled), Trezor support +- **Strengths:** Mature codebase, multi-platform, real atomic swaps, HD wallet support +- **Weaknesses:** Complex onboarding (HD vs Iguana modes exposed to users), dated visual design, no CEX integration, no banking, navigation is functional but not elegant + +#### Gleec CEX (exchange.gleec.com) +- **Platform:** Web + mobile app (Google Play) +- **Core features:** Spot trading with order book, ~100 trading pairs, futures/margin, 0.25% flat fees, 2FA, cold storage +- **Strengths:** Professional trading interface, fiat pairs (EUR), high liquidity +- **Weaknesses:** Separate account from wallet, no self-custody option, separate KYC flow + +#### Gleec Pay +- **Platform:** Web portal +- **Core features:** Crypto-friendly IBAN, SEPA transfers, worldwide payments, virtual account numbers +- **Strengths:** Licensed by FINTRAC, bridges crypto and traditional banking +- **Weaknesses:** Completely separate from wallet and exchange, limited discoverability + +#### Gleec Card +- **Platform:** Physical Visa + virtual card, managed via separate app +- **Core features:** Spend crypto at 50M+ merchants, instant top-up from exchange, plastic and virtual options +- **Strengths:** Real-world spending utility, Visa network reach +- **Weaknesses:** Requires separate app, manual top-up from exchange + +### 4.2 Technical Assets — SDK Reuse & Reference Code + +Gleec One is a new Flutter project. The SDK packages are imported as dependencies. The app-level code (BLoCs, views, routing) is written fresh but uses the existing codebase as a reference for proven business logic. + +#### SDK Packages (Direct Dependencies) + +These are imported into the new project's `pubspec.yaml` via path or git reference: + +| Package | Role | Import Strategy | +|---|---|---| +| `komodo_defi_sdk` | Core DEX engine — activation, swaps, balances, HD wallets | **Git submodule dependency** | +| `komodo_defi_rpc_methods` | RPC request/response models and methods | **Git submodule dependency** | +| `komodo_defi_types` | Shared type definitions | **Git submodule dependency** | +| `komodo_defi_local_auth` | Local wallet auth, Trezor initialization | **Git submodule dependency** | +| `komodo_cex_market_data` | Price feeds from Binance, CoinGecko, CoinPaprika | **Git submodule dependency** | + +#### Reference Code (Port & Rewrite) + +The following logic from the existing wallet is valuable but lives in the app layer, not the SDK. It should be studied, then reimplemented cleanly in the new feature modules: + +| Existing Code | Value | Action | +|---|---|---| +| `WithdrawFormBloc` (state machine) | Multi-step send flow with validation, fee estimation, confirmation | Port logic into new `SendBloc`, simplify states | +| `TakerBloc` / `MakerFormBloc` | DEX swap execution and order placement | Port into `features/trade/swap/bloc/` | +| `MarketMakerBotBloc` | Automated market-making state machine | Port into `features/trade/bot/bloc/` | +| `BridgeBloc` / `BridgeRepository` | Cross-chain bridge orchestration | Port into `features/trade/bridge/bloc/` | +| `TransactionHistoryBloc` | Transaction fetching and caching | Port into `features/assets/bloc/` | +| `CoinAddressesBloc` | HD wallet address management | Port into `features/assets/bloc/` | +| `TrezorAuthMixin` | Trezor hardware wallet flow | Port into `core/auth/` | +| Platform abstractions | Web vs native platform detection and behavior | Port into `core/platform/` | +| Error message mapping | KDF error → human-readable text (WIP in current codebase) | Port into `core/error/` | +| Translation strings | `assets/translations/en.json` and others | Copy and extend | + +#### Not Carried Forward + +The following are replaced entirely in the new app: + +| Current Code | Reason Not Carried Forward | +|---|---| +| `MainMenuBar` / navigation system | Replaced by new sidebar + tab bar architecture | +| `MainLayoutRouterDelegate` | New routing system (go_router or auto_route) | +| `AuthBloc` (wallet auth) | Rewritten as `WalletAuthBloc` with cleaner states, HD-only default | +| All view widgets (`lib/views/`) | Every screen is redesigned from scratch | +| `komodo_ui_kit` components | New design system built fresh (may share some base components) | +| `app_bloc_root.dart` | New DI and BLoC provider tree | +| Settings architecture | Restructured with grouped categories | + +### 4.3 Pain Points to Solve + +Based on analysis of the current codebase and user-facing issues: + +| # | Pain Point | Impact | Solution in Gleec One | +|---|---|---|---| +| 1 | HD vs Iguana wallet mode exposed to users | Confuses beginners, creates support load | Abstract away — HD by default, Iguana as hidden legacy option | +| 2 | Separate apps for DEX, CEX, Pay, Card | User drop-off, split identity | Single unified app | +| 3 | Complex coin activation flow | Users must manually activate each coin | Auto-activate popular coins, lazy-activate on first receive | +| 4 | No unified portfolio across services | Users cannot see total wealth | Aggregate portfolio across wallet + exchange + Pay | +| 5 | Trading is DEX-only with no limit orders UX | Limits trading appeal | Add CEX order book + improve DEX maker/taker UX | +| 6 | NFT feature disabled | Missing trending feature | Re-enable with polished UI | +| 7 | Fiat on-ramp is external redirect (Banxa) | Breaks immersion | Embed Banxa flow or use Gleec Pay direct fiat | +| 8 | No spending capability | Crypto stays in wallet | Gleec Card integration for real-world spending | +| 9 | Settings page is flat and overwhelming | Hard to find what you need | Grouped settings with search | +| 10 | Error messages are technical | Intimidates non-technical users | Human-readable error system (already in progress in codebase) | + +--- + +## 5. Target User Personas + +### Persona 1: "Alex" — The Crypto Curious Beginner + +- **Age:** 22-35 +- **Experience:** Has used Venmo/PayPal, bought crypto once on Coinbase, wants more control +- **Goals:** Hold Bitcoin and a few altcoins safely, maybe swap sometimes, wants a card to spend crypto +- **Frustrations:** Seed phrases are scary, gas fees are confusing, too many apps to manage +- **Gleec One must:** Be as easy as Exodus to set up, show portfolio in fiat, make swapping one-tap, hide technical complexity + +### Persona 2: "Maria" — The Active Trader + +- **Age:** 28-45 +- **Experience:** Uses Binance or Kraken daily, understands order books, has a hardware wallet +- **Goals:** Trade actively on CEX, use DEX for privacy/low-cap tokens, wants limit/stop orders +- **Frustrations:** Moving funds between exchange and wallet is slow, wants everything in one place +- **Gleec One must:** Offer professional order-book trading, fast deposits/withdrawals to self-custody, advanced charting, and the market maker bot + +### Persona 3: "James" — The Crypto-Native Freelancer + +- **Age:** 25-40 +- **Experience:** Gets paid in crypto, uses DeFi regularly, wants to spend crypto for daily expenses +- **Goals:** Receive USDT/USDC payments, convert to fiat, pay rent, buy groceries with card +- **Frustrations:** Converting crypto to spendable fiat takes 3 apps and 2 days +- **Gleec One must:** Provide IBAN for receiving fiat, instant card top-up from any crypto balance, seamless fiat off-ramp + +### Persona 4: "Priya" — The DeFi Power User + +- **Age:** 30-50 +- **Experience:** Runs a market-making operation, uses multiple DEXs, provides liquidity +- **Goals:** Run automated trading strategies, bridge assets across chains, maximize yield +- **Frustrations:** Fragmented tools, no single dashboard for all positions +- **Gleec One must:** Offer the market maker bot with advanced config, multi-chain bridge, portfolio analytics, and API access + +### Persona Priority Matrix + +| Feature Area | Alex (Beginner) | Maria (Trader) | James (Freelancer) | Priya (Power User) | +|---|---|---|---|---| +| Simple onboarding | **Critical** | Nice-to-have | Important | Nice-to-have | +| Portfolio view | **Critical** | **Critical** | **Critical** | **Critical** | +| DEX swaps | Important | **Critical** | Important | **Critical** | +| CEX order book | Not needed | **Critical** | Nice-to-have | Important | +| Gleec Card | Important | Nice-to-have | **Critical** | Nice-to-have | +| Gleec Pay / IBAN | Nice-to-have | Nice-to-have | **Critical** | Nice-to-have | +| Market maker bot | Not needed | Important | Not needed | **Critical** | +| Hardware wallet | Not needed | **Critical** | Nice-to-have | **Critical** | +| NFTs | Nice-to-have | Nice-to-have | Not needed | Important | +| Bridge | Not needed | Important | Nice-to-have | **Critical** | + +--- + +## 6. Information Architecture & Navigation + +### 6.1 Navigation Philosophy + +Following Exodus's proven pattern, adapted for Gleec One's expanded feature set: + +- **Desktop:** Persistent left sidebar with icon + label, collapsible to icons-only +- **Mobile:** Bottom tab bar (5 primary tabs) with a "More" overflow for secondary features +- **Web:** Same as desktop, responsive to mobile layout at breakpoints + +### 6.2 Primary Navigation Structure + +``` +┌─────────────────────────────────────────────────────────────┐ +│ GLEEC ONE — NAVIGATION │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ PRIMARY TABS (always visible) │ +│ ───────────────────────────── │ +│ 1. 🏠 Home / Portfolio │ +│ 2. 💰 Assets │ +│ 3. 🔄 Trade │ +│ 4. 💳 Card & Pay │ +│ 5. ⚙️ Settings & More │ +│ │ +│ DESKTOP SIDEBAR (expanded) │ +│ ────────────────────────── │ +│ 1. Portfolio (home/dashboard) │ +│ 2. Assets (coin list & management) │ +│ 3. Trade │ +│ ├─ Swap (quick DEX swaps) │ +│ ├─ Exchange (CEX order book) │ +│ ├─ Bridge (cross-chain) │ +│ └─ Bot (market maker) │ +│ 4. Card & Pay │ +│ ├─ Card (Gleec Card management) │ +│ └─ Banking (Gleec Pay / IBAN) │ +│ 5. Earn (staking & rewards) │ +│ 6. NFTs (gallery & marketplace) │ +│ ────────────────────────── │ +│ 7. Settings │ +│ 8. Support │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 6.3 Screen Hierarchy Map + +``` +Root +├── Onboarding +│ ├── Welcome / Value Prop +│ ├── Create Wallet (seed generation) +│ ├── Import Wallet (seed / file / hardware) +│ ├── Set Password / Biometrics +│ └── KYC Flow (optional, for CEX/Pay features) +│ +├── Portfolio (Home) +│ ├── Total Balance (hero) +│ ├── Balance Breakdown (Wallet / Exchange / Pay) +│ ├── Asset Allocation Chart +│ ├── Top Movers / Watchlist +│ ├── Recent Transactions (unified) +│ └── Quick Actions (Send / Receive / Swap / Buy) +│ +├── Assets +│ ├── Asset List (search, filter, sort) +│ │ └── Asset Detail +│ │ ├── Balance & Price Chart +│ │ ├── Send +│ │ ├── Receive (address + QR) +│ │ ├── Swap (pre-filled) +│ │ ├── Transaction History +│ │ └── Manage Addresses (HD) +│ ├── Add/Remove Assets +│ └── Hidden Small Balances Toggle +│ +├── Trade +│ ├── Swap (DEX) +│ │ ├── Simple Swap (From → To) +│ │ ├── Advanced (maker/taker orders) +│ │ ├── Active Swaps +│ │ └── Swap History +│ ├── Exchange (CEX) +│ │ ├── Trading Pair Selector +│ │ ├── Order Book +│ │ ├── Price Chart (TradingView-style) +│ │ ├── Order Entry (Market / Limit / Stop) +│ │ ├── Open Orders +│ │ ├── Order History +│ │ └── Deposit/Withdraw to Self-Custody +│ ├── Bridge +│ │ ├── Bridge Swap Form +│ │ └── Bridge History +│ └── Market Maker Bot +│ ├── Bot Dashboard (status, P&L) +│ ├── Configure Pairs +│ └── Bot History +│ +├── Card & Pay +│ ├── Card Overview +│ │ ├── Card Balance +│ │ ├── Top Up (from wallet or exchange) +│ │ ├── Transaction History +│ │ ├── Card Settings (freeze, limits) +│ │ └── Order Physical Card +│ ├── Banking (Gleec Pay) +│ │ ├── IBAN Details +│ │ ├── Send Bank Transfer +│ │ ├── Receive (share IBAN) +│ │ ├── Transaction History +│ │ └── Account Settings +│ └── Buy Crypto (Fiat On-Ramp) +│ ├── Amount & Currency Selection +│ ├── Payment Method +│ └── Order Tracking +│ +├── Earn +│ ├── Staking Overview +│ ├── Available Staking Options +│ ├── Active Stakes +│ └── Rewards History +│ +├── NFTs +│ ├── Gallery (by chain) +│ ├── NFT Detail +│ ├── Send NFT +│ └── NFT Transaction History +│ +└── Settings + ├── Profile & Identity + │ ├── KYC Status + │ ├── Account Verification + │ └── Connected Devices + ├── Security + │ ├── Password / Biometrics + │ ├── 2FA (for CEX/Pay) + │ ├── Backup Seed Phrase + │ ├── Hardware Wallet + │ └── Session Management + ├── Preferences + │ ├── Theme (Light / Dark / System) + │ ├── Currency (fiat display) + │ ├── Language + │ ├── Notifications + │ └── Privacy (hide balances) + ├── Advanced + │ ├── Network Settings + │ ├── Export Data (tax reports) + │ ├── Diagnostic Logs + │ └── Developer Options + └── About & Legal + ├── Version Info + ├── Licenses + └── Terms / Privacy Policy +``` + +### 6.4 Mobile Tab Bar Mapping + +The 5-tab mobile layout maps as follows: + +| Tab | Icon | Label | Contains | +|---|---|---|---| +| 1 | Home icon | Portfolio | Dashboard, balances, quick actions | +| 2 | Wallet icon | Assets | Asset list, coin details, send/receive | +| 3 | Swap arrows icon | Trade | Swap, Exchange, Bridge (sub-tabs) | +| 4 | Card icon | Card | Gleec Card, Pay, Buy Crypto | +| 5 | Gear icon | More | Settings, Earn, NFTs, Support, Bot | + +### 6.5 Desktop Sidebar Behavior + +| State | Behavior | +|---|---| +| **Expanded (default)** | Icon + label, ~220px wide | +| **Collapsed** | Icon only, ~64px wide, labels as tooltips | +| **Hover on collapsed** | Temporarily expand that item's label | +| **Active indicator** | Highlighted background + left accent bar | +| **Sub-navigation** | Trade and Card sections expand/collapse inline | +| **User avatar/name** | Bottom of sidebar with wallet name and quick-switch | + +--- + +## 7. Screen-by-Screen Design Specifications + +### 7.1 Portfolio (Home) Screen + +This is the first screen users see after login. It must immediately answer: "How much is my crypto worth?" + +#### Layout (Desktop) + +``` +┌──────────┬───────────────────────────────────────────────────┐ +│ │ PORTFOLIO 🔔 │ +│ Sidebar │ ┌─────────────────────────────────────────────┐ │ +│ │ │ $12,847.32 USD │ │ +│ │ │ ▲ +2.4% today Wallet | Exchange | Pay │ │ +│ │ └─────────────────────────────────────────────┘ │ +│ │ │ +│ │ ┌──────────────────┐ ┌────────────────────────┐ │ +│ │ │ Asset Allocation │ │ Quick Actions │ │ +│ │ │ [Donut Chart] │ │ [Send] [Receive] │ │ +│ │ │ │ │ [Swap] [Buy] │ │ +│ │ └──────────────────┘ └────────────────────────┘ │ +│ │ │ +│ │ RECENT ACTIVITY [View All] │ +│ │ ┌─────────────────────────────────────────────┐ │ +│ │ │ ↑ Sent 0.05 BTC -$2,341 2m ago │ │ +│ │ │ ↓ Received 500 USDT +$500 1h ago │ │ +│ │ │ ⇄ Swapped ETH → USDT ... 3h ago │ │ +│ │ │ 💳 Card purchase -$42 today │ │ +│ │ └─────────────────────────────────────────────┘ │ +│ │ │ +│ │ TOP ASSETS │ +│ │ ┌─────────────────────────────────────────────┐ │ +│ │ │ BTC 0.15 $7,023 ▲ 3.1% │ │ +│ │ │ ETH 2.4 $4,102 ▼ 1.2% │ │ +│ │ │ USDT 1,722 $1,722 — 0.0% │ │ +│ │ └─────────────────────────────────────────────┘ │ +└──────────┴───────────────────────────────────────────────────┘ +``` + +#### Key Design Decisions + +1. **Balance breakdown tabs:** "Wallet | Exchange | Pay" lets users see where their funds are. Default view shows total across all. Each tab filters the asset list to that venue. + +2. **Quick Actions:** Four prominent buttons for the most common tasks. Each is context-aware — "Buy" only shows if KYC is complete for fiat on-ramp. + +3. **Unified activity feed:** Merges transactions from wallet sends, DEX swaps, CEX trades, card purchases, and Pay transfers into one chronological list with clear iconography distinguishing each type. + +4. **Asset allocation donut chart:** Mirrors Exodus's visual representation. Interactive — tapping a segment highlights that asset. + +5. **Portfolio value chart:** Accessible via tap/swipe on the hero balance area. Shows 1D/1W/1M/3M/1Y/ALL time periods identical to Exodus. + +### 7.2 Assets Screen + +#### Layout Principles + +- **Search bar at top** with instant filtering +- **Filter chips:** All, Crypto, Tokens, Stablecoins, NFTs +- **Sort options:** By value (default), alphabetical, 24h change, custom +- **Each row:** Coin icon, name/ticker, balance (crypto), balance (fiat), 24h sparkline, 24h change % +- **Zero-balance assets:** Hidden by default, toggle to show +- **Add Asset button:** Prominent at top or as FAB on mobile + +#### Asset Detail Page + +When tapping an asset, the detail page shows: + +``` +┌─────────────────────────────────────────────────┐ +│ ← Back ⋮ │ +│ │ +│ [BTC Icon] Bitcoin │ +│ 0.15 BTC ≈ $7,023.00 │ +│ ▲ 3.1% today │ +│ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ [Price Chart] │ │ +│ │ 1D 1W 1M 3M 1Y ALL │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ [Send] [Receive] [Swap] [Trade] [Stake] │ +│ │ +│ BALANCE DETAILS │ +│ ┌─────────────────────────────────────────┐ │ +│ │ Wallet (self-custody) 0.10 BTC │ │ +│ │ Exchange 0.04 BTC │ │ +│ │ Gleec Card 0.01 BTC │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ TRANSACTIONS [View All] │ +│ ┌─────────────────────────────────────────┐ │ +│ │ ↑ Sent 0.05 BTC Feb 26 -$2,341 │ │ +│ │ ↓ Received 0.20 BTC Feb 24 +$9,100 │ │ +│ │ ⇄ Swap to ETH Feb 22 0.03BTC │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ ADDRESSES (HD Wallet) │ +│ ┌─────────────────────────────────────────┐ │ +│ │ m/44'/0'/0'/0/0 bc1q...xyz 0.10 BTC│ │ +│ │ + Generate New Address │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +#### Key Enhancements over Current Gleec Wallet + +1. **Balance split by venue** — shows where the asset is held (wallet vs exchange vs card) +2. **"Trade" action** — opens CEX trading pair for this asset (in addition to "Swap" for DEX) +3. **"Stake" action** — shows if staking is available for this asset +4. **Price chart** — interactive with time period selector (mirrors Exodus) +5. **Unified transaction history** — all transactions for this asset regardless of venue + +### 7.3 Trade Screen — Swap (DEX) + +The swap interface follows Exodus's proven design but adds maker/taker order support. + +#### Simple Swap Mode (Default) + +``` +┌─────────────────────────────────────────────────┐ +│ SWAP [Simple|Advanced]│ +│ │ +│ From │ +│ ┌─────────────────────────────────────────┐ │ +│ │ [BTC ▼] 0.05 │ │ +│ │ Bitcoin ≈ $2,341 │ │ +│ │ Balance: 0.15 │ │ +│ │ [MAX] │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ [⇅ Switch] │ +│ │ +│ To │ +│ ┌─────────────────────────────────────────┐ │ +│ │ [ETH ▼] ~0.82 │ │ +│ │ Ethereum ≈ $2,318 │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ Rate: 1 BTC = 16.4 ETH │ +│ Route: DEX Atomic Swap │ +│ Est. time: ~15 min │ +│ Network fee: ~$3.20 │ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ SWAP NOW │ │ +│ └──────────────────────────────┘ │ +│ │ +│ ─── or trade on Exchange ─── │ +│ [Open BTC/ETH on Exchange →] │ +│ │ +└─────────────────────────────────────────────────┘ +``` + +#### Advanced Swap Mode + +Shows the existing maker/taker order functionality but with a cleaner interface: +- Order book visualization +- Place maker order with spread setting +- Active orders list +- History with filtering and export + +#### Cross-Sell to CEX + +The "or trade on Exchange" link at the bottom is a key design decision. It introduces CEX trading to DEX users organically, without forcing it. If the CEX offers better rates or faster execution for that pair, the app can show a subtle indicator. + +### 7.4 Trade Screen — Exchange (CEX) + +A professional trading interface for users who want order-book trading. + +#### Layout (Desktop) + +``` +┌──────────┬─────────────────────────────────────────────────┐ +│ │ EXCHANGE [BTC/USDT ▼] 📊 Chart 📋 │ +│ │ │ +│ Sidebar │ ┌──────────────────────┬──────────────────────┐│ +│ │ │ │ ORDER BOOK ││ +│ │ │ PRICE CHART │ ────────── ││ +│ │ │ (TradingView) │ Ask 47,234 0.12 ││ +│ │ │ │ Ask 47,230 0.45 ││ +│ │ │ │ ─── Spread ─── ││ +│ │ │ │ Bid 47,225 0.30 ││ +│ │ │ │ Bid 47,220 1.20 ││ +│ │ └──────────────────────┴──────────────────────┘│ +│ │ │ +│ │ ┌──────────────────────┬──────────────────────┐│ +│ │ │ BUY BTC │ SELL BTC ││ +│ │ │ [Market|Limit|Stop] │ [Market|Limit|Stop]││ +│ │ │ Price: [47,225 ] │ Price: [47,234 ] ││ +│ │ │ Amount: [ ] │ Amount: [ ]││ +│ │ │ Total: $0.00 │ Total: $0.00 ││ +│ │ │ [25%][50%][75%][MAX]│ [25%][50%][75%][MAX││ +│ │ │ ┌──────────────┐ │ ┌──────────────┐ ││ +│ │ │ │ BUY BTC │ │ │ SELL BTC │ ││ +│ │ │ └──────────────┘ │ └──────────────┘ ││ +│ │ │ CEX Bal: 0.04 BTC │ CEX Bal: 1,722 USDT││ +│ │ │ [Deposit from │ [Deposit from ││ +│ │ │ Wallet →] │ Wallet →] ││ +│ │ └──────────────────────┴──────────────────────┘│ +│ │ │ +│ │ OPEN ORDERS (3) │ +│ │ ┌──────────────────────────────────────────────┐│ +│ │ │ Limit Buy 0.1 BTC @ 46,500 [Cancel] ││ +│ │ │ Limit Sell 0.05 BTC @ 48,000 [Cancel] ││ +│ │ └──────────────────────────────────────────────┘│ +└──────────┴─────────────────────────────────────────────────┘ +``` + +#### Key Design Decisions + +1. **Deposit from Wallet prompt:** Below each order form, show the self-custody wallet balance with a one-tap deposit option. This bridges the gap between DEX wallet and CEX without forcing users to navigate away. + +2. **Order types:** Market, Limit, and Stop orders. Futures/Margin available as a toggle for verified users (progressive disclosure). + +3. **Trading pair selector:** Searchable dropdown with favorites/recent pairs. Shows both price and 24h volume. + +4. **Mobile adaptation:** On mobile, the exchange screen uses a vertical stack: Chart (collapsible) → Order entry (tabbed Buy/Sell) → Open orders. Order book available as a slide-up sheet. + +### 7.5 Card & Pay Screen + +#### Card Tab + +``` +┌─────────────────────────────────────────────────┐ +│ GLEEC CARD │ +│ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ ╔═══════════════════════════════════╗ │ │ +│ │ ║ GLEEC VISA ║ │ │ +│ │ ║ ║ │ │ +│ │ ║ •••• •••• •••• 4827 ║ │ │ +│ │ ║ JAMES WILSON 09/28 ║ │ │ +│ │ ╚═══════════════════════════════════╝ │ │ +│ │ │ │ +│ │ Balance: $1,247.00 │ │ +│ │ │ │ +│ │ [Top Up] [Freeze] [Details] │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ SPENDING THIS MONTH │ +│ ┌─────────────────────────────────────────┐ │ +│ │ $847 of $2,000 limit │ │ +│ │ ████████████░░░░░░░ 42% │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ RECENT TRANSACTIONS │ +│ ┌─────────────────────────────────────────┐ │ +│ │ 🛒 Amazon -$42.00 Today │ │ +│ │ ☕ Starbucks -$5.50 Today │ │ +│ │ ⬆ Top-up from BTC +$200 Yesterday │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ [Order Physical Card] │ +│ [Manage Virtual Cards] │ +└─────────────────────────────────────────────────┘ +``` + +#### Top-Up Flow + +The card top-up flow is critical for "James" (the freelancer persona). It must be fast: + +1. Tap "Top Up" +2. Choose source: Wallet balance, Exchange balance, or Bank (Gleec Pay) +3. Choose asset (if crypto) — shows current rate to card currency +4. Enter amount +5. Confirm — funds appear on card instantly (or within seconds) + +#### Banking Tab (Gleec Pay) + +``` +┌─────────────────────────────────────────────────┐ +│ GLEEC PAY │ +│ │ +│ Account Balance: €3,450.00 │ +│ │ +│ IBAN: DE89 3704 0044 0532 0130 00 │ +│ [Copy] [Share] [QR Code] │ +│ │ +│ [Send Transfer] [Convert to Crypto] │ +│ │ +│ RECENT TRANSFERS │ +│ ┌─────────────────────────────────────────┐ │ +│ │ ↓ Salary deposit +€2,500 Feb 25 │ │ +│ │ ↑ Rent payment -€800 Feb 1 │ │ +│ │ ⇄ Convert to BTC -€500 Jan 28 │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +--- + +## 8. Onboarding & Authentication + +### 8.1 Design Philosophy + +Exodus's biggest UX win is frictionless onboarding: download, open, wallet is ready. No email, no account, no KYC for the basic wallet. Gleec One must match this for the self-custody wallet layer while progressively introducing KYC for CEX and Pay features. + +### 8.2 Tiered Identity Model + +``` +TIER 0 — Anonymous Wallet (No KYC) +├── Self-custody wallet (HD, BIP39) +├── DEX swaps (atomic swaps, no custodial risk) +├── Bridge transfers +├── NFT management +├── Market maker bot (on DEX) +└── Full portfolio tracking + +TIER 1 — Basic Verification (Email + Phone) +├── Everything in Tier 0 +├── CEX spot trading (limited volume) +├── Fiat on-ramp via Banxa (limited) +└── Push notifications and account recovery + +TIER 2 — Full KYC (ID Verification) +├── Everything in Tier 1 +├── CEX trading (unlimited) +├── Futures and margin trading +├── Gleec Pay (IBAN account) +├── Gleec Card (virtual + physical) +├── Higher fiat on-ramp limits +└── Fiat off-ramp (sell crypto to bank) +``` + +### 8.3 First-Time Onboarding Flow + +``` +Step 1: Welcome Screen + "Welcome to Gleec One" + "Your wallet, exchange, and card — all in one." + [Create New Wallet] [Import Existing Wallet] + + ↓ Create New Wallet + +Step 2: Wallet Creation (< 5 seconds) + - Generate BIP39 mnemonic in background + - Show animated "Creating your wallet..." (1-2s for perceived quality) + - HD wallet by default (never expose HD vs Iguana choice to users) + + ↓ + +Step 3: Secure Your Wallet + - Set password (required) OR set up biometrics (mobile) + - Password strength meter with real-time feedback + - Minimum 8 characters, no "weak password" toggle in production + + ↓ + +Step 4: Backup Seed Phrase (Strongly Encouraged, Not Forced) + - "Your wallet is ready! Before you start, let's secure your backup." + - [Back Up Now] (recommended, highlighted) + - [Skip for Now] (subtle, with a warning badge that persists) + - If "Back Up Now": show 12/24 words → confirm by selecting in order → done + - If "Skip": persistent reminder in Settings and on Portfolio screen + + ↓ + +Step 5: Portfolio Screen (Home) + - Wallet is ready with $0 balance + - Prominent "Deposit" and "Buy Crypto" CTAs + - Optional: show popular coins to activate (BTC, ETH, USDT pre-selected) + + ↓ (Optional, triggered by accessing CEX or Pay features) + +Step 6: Progressive KYC + - When user first taps "Exchange" or "Card & Pay": + "To trade on the exchange / use banking features, we need to verify your identity." + [Verify Now] — starts in-app KYC flow + [Not Now] — returns to wallet features + - KYC flow: Email → Phone → ID upload → Selfie → Processing (async) + - Status shown in Settings: "Verification: Pending / Approved / Action Needed" +``` + +### 8.4 Import Wallet Flow + +``` +Import Options: +├── From Seed Phrase +│ ├── 12-word or 24-word input +│ ├── BIP39 validation in real-time +│ ├── Set password +│ └── Auto-detect supported coins and balances +│ +├── From File (Legacy Gleec Wallet backup) +│ ├── File picker +│ ├── Decrypt with password +│ └── Migrate to new format +│ +├── From Hardware Wallet +│ ├── Connect Trezor (USB or Bluetooth) +│ ├── Device confirmation +│ ├── Read accounts from device +│ └── Hardware wallet mode (restricted: no CEX, no Card — these require custodial actions) +│ +└── From QR Code (Device Sync) + ├── Scan QR from another Gleec One instance + ├── Encrypted sync of wallet config (NOT seed phrase) + └── Cross-device portfolio sync +``` + +### 8.5 Login Flow + +``` +Returning User: +├── Password Entry +│ ├── Quick login: auto-submit when paste detected (password manager) +│ ├── Biometric unlock (Face ID / Touch ID / Fingerprint) +│ └── "Forgot password?" → re-import from seed phrase +│ +├── Multi-Wallet Support +│ ├── If multiple wallets exist, show wallet selector first +│ ├── Last-used wallet is pre-selected +│ ├── "Remember wallet" checkbox to skip selector +│ └── Wallet avatar/icon for visual differentiation +│ +└── Session Management + ├── Auto-lock after configurable timeout (5m / 15m / 30m / 1h / Never) + ├── Lock on app background (mobile, optional) + └── CEX/Pay session may have separate timeout (stricter) +``` + +### 8.6 Key UX Decisions vs. Current Gleec Wallet + +| Current Behavior | Gleec One Behavior | Rationale | +|---|---|---| +| Exposes HD vs Iguana wallet choice | HD only, Iguana hidden as legacy import | Beginners don't understand derivation paths | +| Allows weak passwords (dev toggle) | Enforce strong passwords in production | Security non-negotiable for financial app | +| Coin activation is manual per-coin | Top coins pre-activated, others lazy-activate | Reduce friction for new users | +| Seed backup is a separate settings action | Prompted during onboarding, persistent reminder | Critical security UX from Exodus | +| No biometric option | Biometric primary on mobile | Standard for modern financial apps | +| No progressive KYC | Tiered KYC (anonymous → basic → full) | Unlocks CEX/Pay without blocking wallet use | + +--- + +## 9. Unified Trading Experience (DEX + CEX) + +### 9.1 The Core Innovation + +No major wallet app today seamlessly combines DEX and CEX trading in one interface. This is Gleec One's flagship differentiator. The design philosophy: + +> **The user chooses WHAT to trade. The app suggests WHERE to trade it.** + +### 9.2 Trade Routing Architecture + +When a user initiates a trade, the app evaluates both DEX and CEX execution options: + +``` +User wants to swap 1 BTC → ETH + +┌──────────────────────────────────────────────────┐ +│ TRADE OPTIONS │ +│ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ ⚡ EXCHANGE (CEX) BEST RATE │ │ +│ │ Rate: 1 BTC = 16.42 ETH │ │ +│ │ Fee: 0.25% ($5.85) │ │ +│ │ Speed: Instant │ │ +│ │ Custody: Exchange holds funds during trade │ │ +│ │ [Trade on Exchange →] │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 🔒 DEX (Atomic Swap) NON-CUSTODIAL│ +│ │ Rate: 1 BTC = 16.38 ETH │ │ +│ │ Fee: Network fees only (~$3.20) │ │ +│ │ Speed: ~15 minutes │ │ +│ │ Custody: Your keys, your crypto │ │ +│ │ [Swap on DEX →] │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ ℹ️ DEX swaps are non-custodial but slower. │ +│ Exchange trades are instant but custodial. │ +└──────────────────────────────────────────────────┘ +``` + +### 9.3 Smart Defaults + +The app uses smart defaults to minimize decision fatigue: + +| User Scenario | Default Route | Reason | +|---|---|---| +| Small swap (< $100) | DEX | Fees are lower, no KYC needed | +| Large trade (> $1,000) | CEX | Better liquidity, instant execution | +| Pair available on both | Show comparison | Let user choose with clear trade-offs | +| Pair only on DEX | DEX (auto) | No choice needed, show explanation | +| Pair only on CEX | CEX (auto) | No choice needed, show explanation | +| User has no CEX account | DEX (with "Unlock faster trades" prompt) | Don't block, just upsell | +| User has funds on CEX only | CEX (with "Or deposit to wallet for DEX") | Meet user where their funds are | + +### 9.4 Unified Order Management + +All orders — DEX maker/taker and CEX limit/market/stop — appear in a single "Orders" view: + +``` +MY ORDERS [Filter ▼] + +Active (3) +┌────────────────────────────────────────────────────┐ +│ 🔒 DEX Maker Sell 0.5 ETH @ 0.034 BTC Active │ +│ ⚡ CEX Limit Buy 1000 USDT @ 0.99 Open │ +│ 🔒 DEX Swap 0.1 BTC → LTC Step 3/5 In Progress │ +└────────────────────────────────────────────────────┘ + +History [Export ▼] +┌────────────────────────────────────────────────────┐ +│ ⚡ CEX Market Sold 0.2 BTC $9,400 Feb 25 │ +│ 🔒 DEX Swap 0.5 ETH → USDT $1,200 Feb 24 │ +│ ⚡ CEX Limit Bought 2 ETH $3,800 Feb 23 │ +└────────────────────────────────────────────────────┘ +``` + +Labels (🔒 DEX / ⚡ CEX) make venue immediately clear. The filter allows showing DEX-only, CEX-only, or both. + +### 9.5 Internal Transfer Between Wallet and Exchange + +Moving funds between self-custody wallet and CEX should be as easy as transferring between bank accounts: + +``` +TRANSFER [Wallet ⇄ Exchange] + +From: [Wallet (self-custody) ▼] Balance: 0.15 BTC +To: [Exchange ▼] Balance: 0.04 BTC + +Amount: [0.05 ] BTC [MAX] + ≈ $2,341.00 + +Fee: Network fee ~$1.50 + +[Transfer to Exchange →] + +ℹ️ This sends BTC from your wallet to your exchange + account. It requires an on-chain transaction. +``` + +For supported coins, internal transfers can use the exchange's deposit address. The app pre-fills this address and handles the flow transparently — the user just picks "Wallet" and "Exchange" as source and destination. + +### 9.6 Market Maker Bot (Advanced) + +The existing market maker bot is preserved as a power-user feature, accessible from Trade → Bot: + +``` +MARKET MAKER BOT [On/Off Toggle] + +Status: Running (3 pairs active) +Uptime: 14h 32m +Total P&L: +$142.50 + +Active Pairs: +┌────────────────────────────────────────────────────┐ +│ BTC/USDT Spread: 0.5% Volume: 0.1 BTC +$82│ +│ ETH/USDT Spread: 0.3% Volume: 2 ETH +$45│ +│ LTC/BTC Spread: 0.8% Volume: 5 LTC +$15│ +│ │ +│ [Edit] [Pause] [Remove] per row │ +└────────────────────────────────────────────────────┘ + +[+ Add Trading Pair] +[View Bot Orders in Exchange →] +[Download Performance Report] +``` + +### 9.7 Bridge Integration + +Cross-chain bridge is accessible from Trade → Bridge, following the same From → To pattern as swaps but with explicit chain selection: + +``` +BRIDGE + +From Chain: [Ethereum ▼] Asset: [USDT ▼] +To Chain: [Polygon ▼] Asset: [USDT (auto)] + +Amount: [500 ] USDT +Fee: ~$2.40 (bridge + gas) +Est. time: ~5 minutes + +[Bridge Now →] +``` + +--- + +## 10. Banking & Card Integration (Gleec Pay + Card) + +### 10.1 Why This Matters + +The "last mile" problem in crypto is spending. Users accumulate crypto but face a multi-step, multi-app process to actually use it for daily expenses. Gleec One solves this by making the Gleec Card and Gleec Pay first-class features alongside the wallet and exchange. + +### 10.2 Card Management + +#### Card Lifecycle + +``` +1. Discovery + └── User sees "Card & Pay" tab in navigation + └── Landing page: "Spend your crypto anywhere Visa is accepted" + └── [Get Your Card] CTA + +2. Application (Requires Tier 2 KYC) + ├── If KYC complete: order card instantly + ├── If KYC incomplete: "Verify identity to get your card" → KYC flow + └── Card types: Virtual (instant) + Physical (ships in 5-7 days) + +3. Activation + ├── Virtual: ready immediately after approval + └── Physical: enter card number + CVV to activate + +4. Daily Use + ├── View balance and transactions in app + ├── Top up from any source (wallet, exchange, Pay) + ├── Set spending limits + ├── Freeze/unfreeze card + └── View card details (number, CVV, expiry) + +5. Management + ├── Replace lost/stolen card + ├── Change PIN + ├── Close card + └── Dispute transactions +``` + +#### Top-Up Intelligence + +The top-up flow should be smart about where funds come from: + +``` +TOP UP GLEEC CARD Amount: $200 + +Recommended source: +┌────────────────────────────────────────────────────┐ +│ 💰 USDT (Wallet) Balance: $1,722 │ +│ Stablecoins avoid price slippage. [Select →] │ +└────────────────────────────────────────────────────┘ + +Other sources: +┌────────────────────────────────────────────────────┐ +│ 📊 Exchange Balance Balance: $340 │ +│ No conversion needed. [Select →] │ +├────────────────────────────────────────────────────┤ +│ ₿ BTC (Wallet) Balance: $7,023 │ +│ Will sell BTC at market rate. [Select →] │ +├────────────────────────────────────────────────────┤ +│ 🏦 Gleec Pay (EUR) Balance: €3,450 │ +│ Convert EUR at current rate. [Select →] │ +└────────────────────────────────────────────────────┘ +``` + +The app recommends stablecoins first (no slippage), then exchange fiat, then volatile crypto — with clear warnings about price impact. + +### 10.3 Gleec Pay Banking Integration + +#### Account Features + +| Feature | Description | +|---|---| +| **IBAN** | Personal IBAN for receiving bank transfers (SEPA, SWIFT) | +| **Send transfers** | Send EUR/USD to any bank account | +| **Receive transfers** | Share IBAN for salary, payments, invoices | +| **Convert to crypto** | Buy crypto directly from Pay balance | +| **Convert from crypto** | Sell crypto to Pay balance (fiat off-ramp) | +| **Card funding** | Top up Gleec Card from Pay balance | +| **Transaction history** | Full banking transaction history | +| **Statements** | Monthly statements for accounting | + +#### The Crypto-to-Fiat Loop + +This is the killer feature for "James" (the freelancer): + +``` +Receive crypto payment (USDT, BTC, ETH) + ↓ +View in Gleec One portfolio + ↓ +Option A: Top up Card directly from crypto balance + → Spend at any Visa merchant + ↓ +Option B: Convert crypto → Gleec Pay (EUR/USD) + → Use IBAN to pay bills, rent, etc. + ↓ +Option C: Hold in wallet (self-custody) + → Trade/swap when ready +``` + +All three options are accessible from the same app, the same portfolio view, with 2-3 taps maximum. + +### 10.4 Buy Crypto (Fiat On-Ramp) + +Currently using Banxa as an external redirect. Gleec One improves this: + +#### Short-term: Embedded Banxa + +Embed the Banxa flow inside the app using a webview/iframe instead of redirecting to an external browser. The user stays in-app throughout. + +#### Medium-term: Direct Purchase via Gleec Pay + +If the user has a Gleec Pay account with EUR/USD balance: + +``` +BUY CRYPTO + +Pay with: [Gleec Pay (EUR) ▼] Balance: €3,450 +Buy: [Bitcoin (BTC) ▼] +Amount: [€500 ] ≈ 0.0107 BTC +Fee: €1.25 (0.25%) + +Receive to: [Wallet (self-custody) ▼] + +[Buy Bitcoin →] +``` + +This eliminates third-party fees and completes the loop: fiat in (Pay) → crypto (Wallet) → trade (DEX/CEX) → spend (Card). + +### 10.5 Custody Transparency + +Throughout the Card & Pay section, the app must clearly communicate custody status: + +``` +CUSTODY INDICATORS + +🔒 Self-Custody Wallet + "Only you control these funds. Not even Gleec can access them." + +🏦 Gleec Exchange + "These funds are held on the Gleec exchange. Protected by 2FA and cold storage." + +💳 Gleec Card + "Card balance is held by Gleec's card issuer. Spend anywhere Visa is accepted." + +🏛️ Gleec Pay + "Banking funds are held in a regulated e-money account (FINTRAC licensed)." +``` + +These indicators appear: +- In the portfolio balance breakdown +- On the asset detail page (balance split section) +- When transferring between venues +- In tooltips throughout the app + +--- + +## 11. Design System & Visual Language + +### 11.1 Brand Identity + +Gleec One should feel premium, trustworthy, and modern. The visual language draws inspiration from Exodus's polish while establishing Gleec's own identity. + +#### Design Tokens + +| Token | Value | Notes | +|---|---|---| +| **Primary font** | Inter or Manrope | Clean, modern sans-serif with excellent number rendering | +| **Mono font** | JetBrains Mono or SF Mono | For addresses, amounts, code | +| **Corner radius** | 12px (cards), 8px (buttons), 4px (inputs) | Rounded but not bubbly | +| **Spacing scale** | 4px base (4, 8, 12, 16, 24, 32, 48, 64) | Consistent rhythm | +| **Shadow system** | 3 levels (subtle, medium, elevated) | Depth without heaviness | +| **Animation duration** | 200ms (micro), 350ms (transition), 500ms (page) | Snappy, not sluggish | +| **Animation easing** | Cubic-bezier(0.4, 0, 0.2, 1) | Material standard ease | + +### 11.2 Color System + +#### Dark Theme (Primary) + +| Role | Color | Usage | +|---|---|---| +| **Background** | #0D0F14 | Main app background | +| **Surface** | #161923 | Cards, panels, sidebar | +| **Surface elevated** | #1E2230 | Dropdowns, modals, tooltips | +| **Border** | #2A2E3D | Subtle dividers and card borders | +| **Text primary** | #FFFFFF | Headings, primary content | +| **Text secondary** | #8B8FA3 | Labels, descriptions, metadata | +| **Text tertiary** | #555970 | Disabled, placeholder | +| **Accent primary** | #4F8FFF | CTAs, links, active states | +| **Accent secondary** | #7C5CFC | Secondary actions, charts | +| **Success** | #34D399 | Positive values, confirmations | +| **Warning** | #FBBF24 | Caution states, pending | +| **Error** | #F87171 | Errors, negative values, declines | +| **Positive change** | #34D399 | Price up, profit | +| **Negative change** | #F87171 | Price down, loss | + +#### Light Theme + +| Role | Color | +|---|---| +| **Background** | #F8F9FC | +| **Surface** | #FFFFFF | +| **Surface elevated** | #FFFFFF (with shadow) | +| **Border** | #E2E4EC | +| **Text primary** | #0D0F14 | +| **Text secondary** | #6B7084 | +| **Accent primary** | #3B7AE8 | + +### 11.3 Component Library (Evolved from Komodo UI Kit) + +The existing `komodo_ui_kit` package is the foundation, but it needs significant expansion for Gleec One. + +#### New / Redesigned Components + +| Component | Description | Priority | +|---|---|---| +| **AppSidebar** | Persistent left sidebar with collapse, active states, sub-nav | P0 | +| **MobileTabBar** | 5-tab bottom bar with badges | P0 | +| **PortfolioHeroCard** | Total balance with breakdown tabs and chart | P0 | +| **AssetListItem** | Coin icon, name, balance, sparkline, change % | P0 | +| **TradeRouteCard** | DEX vs CEX comparison card for smart routing | P0 | +| **OrderBookWidget** | Real-time order book with depth visualization | P1 | +| **CandlestickChart** | TradingView-style price chart with time periods | P1 | +| **CardVisual** | Gleec Card representation with flip animation | P1 | +| **CustodyBadge** | 🔒/🏦/💳/🏛️ indicators with tooltip | P0 | +| **ProgressStepper** | Multi-step flow indicator (swap progress, KYC) | P0 | +| **AmountInput** | Crypto/fiat amount input with MAX and conversion | P0 | +| **CoinSelector** | Searchable coin picker with chain grouping | P0 | +| **TransactionListItem** | Unified item for sends, swaps, trades, card purchases | P0 | +| **EmptyState** | Friendly illustration + CTA for empty screens | P1 | +| **NotificationToast** | Non-blocking success/error/info notifications | P0 | +| **KYCStatusBanner** | Verification status with action prompt | P1 | +| **BalanceBreakdown** | Venue-split balance display (wallet/exchange/pay) | P0 | + +#### Retained Components (from existing UI Kit) + +| Component | Status | +|---|---| +| `UiPrimaryButton` | Keep, update styling | +| `UiSecondaryButton` | Keep, update styling | +| `UiTextFormField` | Keep, add new variants | +| `UiDropdown` | Keep, improve search | +| `UiSwitcher` | Keep as-is | +| `UiCheckbox` | Keep as-is | +| `StatisticCard` | Keep, update layout | +| `UiSpinner` | Replace with Lottie animation | +| `UiScrollbar` | Keep as-is | +| `Gap` | Keep as-is | + +### 11.4 Iconography + +| Category | Style | Source | +|---|---|---| +| **Navigation icons** | Outlined, 24px, 1.5px stroke | Custom or Phosphor Icons | +| **Action icons** | Filled, 20px | Custom or Phosphor Icons | +| **Coin icons** | Full color, 32px (list) / 48px (detail) | CoinGecko API or local SVGs | +| **Status icons** | Colored filled, 16px | Custom | +| **Illustrations** | Flat, limited palette, branded | Custom (for empty states, onboarding) | + +### 11.5 Motion & Animation Principles + +| Context | Type | Duration | Easing | +|---|---|---|---| +| Page transition | Slide + fade | 350ms | Ease-in-out | +| Modal open | Scale up + fade | 250ms | Ease-out | +| List item appear | Staggered fade-in | 150ms per item | Ease-out | +| Button press | Scale down 0.97x | 100ms | Linear | +| Success state | Checkmark draw + confetti (optional) | 500ms | Spring | +| Loading | Skeleton shimmer | Continuous | Linear | +| Balance update | Number count-up | 300ms | Ease-out | +| Swap progress | Step-by-step with pulsing active step | Continuous | — | + +### 11.6 Responsive Breakpoints + +| Breakpoint | Width | Layout | +|---|---|---| +| **Mobile** | < 600px | Bottom tab bar, single column, stacked | +| **Tablet** | 600-1024px | Bottom tab bar, two-column where useful | +| **Desktop** | 1024-1440px | Left sidebar (collapsed by default), multi-column | +| **Wide desktop** | > 1440px | Left sidebar (expanded), multi-column with max-width | + +--- + +## 12. Technical Architecture + +### 12.1 Architecture Overview + +Gleec One is a **new Flutter project** that imports the Komodo DeFi SDK as a dependency. The app layer is built from scratch with a clean, modular architecture. The key principle is **feature-first organization** with clear boundaries between the self-custody layer (Komodo DeFi SDK), the CEX integration layer, and the banking/card layer. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ GLEEC ONE APP (Flutter) │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ PRESENTATION LAYER │ │ +│ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐ │ │ +│ │ │Portfo│ │Assets│ │Trade │ │Card& │ │Settings │ │ │ +│ │ │lio │ │ │ │ │ │Pay │ │ │ │ │ +│ │ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ └────┬─────┘ │ │ +│ └─────┼────────┼────────┼────────┼───────────┼─────────┘ │ +│ │ │ │ │ │ │ +│ ┌─────┴────────┴────────┴────────┴───────────┴─────────┐ │ +│ │ BLOC / STATE LAYER │ │ +│ │ PortfolioBloc, AssetsBloc, TradeBloc, CardBloc, │ │ +│ │ PayBloc, SettingsBloc, AuthBloc, KycBloc │ │ +│ └────────────────────────┬──────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────────┴──────────────────────────────┐ │ +│ │ REPOSITORY LAYER │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │ │ +│ │ │ Wallet │ │ Exchange │ │ Banking / Card │ │ │ +│ │ │ Repo │ │ Repo │ │ Repo │ │ │ +│ │ └────┬─────┘ └────┬─────┘ └─────┬─────────────┘ │ │ +│ └───────┼──────────────┼──────────────┼─────────────────┘ │ +│ │ │ │ │ +│ ┌───────┴──────┐ ┌────┴──────┐ ┌─────┴──────────────────┐ │ +│ │ Komodo DeFi │ │ Gleec CEX │ │ Gleec Pay + Card │ │ +│ │ SDK │ │ API Client│ │ API Client │ │ +│ │ (MM2/KDF) │ │ │ │ │ │ +│ └──────────────┘ └───────────┘ └─────────────────────────┘ │ +│ │ +├─────────────────────────────────────────────────────────────┤ +│ SHARED LAYER │ +│ Design System (UI Kit), Auth, Analytics, Storage, i18n, │ +│ Error Handling, Networking, Feature Flags │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 12.2 Project Bootstrap + +The new project is created with: + +```bash +flutter create --org com.gleec --project-name gleec_one gleec-one +``` + +Then the SDK is added as a git submodule: + +```bash +git submodule add sdk +``` + +And referenced in `pubspec.yaml`: + +```yaml +dependencies: + flutter: + sdk: flutter + + # Komodo DeFi SDK packages (git submodule) + komodo_defi_sdk: + path: sdk/packages/komodo_defi_sdk + komodo_defi_rpc_methods: + path: sdk/packages/komodo_defi_rpc_methods + komodo_defi_types: + path: sdk/packages/komodo_defi_types + komodo_defi_local_auth: + path: sdk/packages/komodo_defi_local_auth + komodo_cex_market_data: + path: sdk/packages/komodo_cex_market_data + + # New Gleec service clients (local packages) + gleec_cex_client: + path: packages/gleec_cex_client + gleec_pay_client: + path: packages/gleec_pay_client + + # Core dependencies + flutter_bloc: ^9.0.0 + get_it: ^8.0.0 + go_router: ^14.0.0 + dio: ^5.0.0 + flutter_secure_storage: ^9.0.0 + easy_localization: ^3.0.0 + # ... etc +``` + +### 12.3 Feature Module Structure + +The new project uses a feature-first folder structure from day one: + +``` +lib/ +├── app/ # App shell, routing, DI +│ ├── router/ +│ ├── di/ # GetIt dependency injection setup +│ ├── app.dart +│ └── theme/ +│ +├── core/ # Shared infrastructure +│ ├── auth/ # Authentication (local + CEX session) +│ ├── error/ # Error handling, human-readable messages +│ ├── network/ # HTTP clients, interceptors +│ ├── storage/ # Local storage, secure storage +│ ├── analytics/ # Event tracking +│ ├── feature_flags/ # Feature gating (NFTs, Futures, etc.) +│ └── constants/ +│ +├── features/ # Feature modules (self-contained) +│ ├── portfolio/ +│ │ ├── bloc/ +│ │ ├── data/ # Repository + data sources +│ │ ├── domain/ # Models, entities +│ │ └── presentation/ # Widgets, pages +│ │ +│ ├── assets/ +│ │ ├── bloc/ +│ │ ├── data/ +│ │ ├── domain/ +│ │ └── presentation/ +│ │ +│ ├── trade/ +│ │ ├── swap/ # DEX swap feature +│ │ │ ├── bloc/ +│ │ │ ├── data/ +│ │ │ └── presentation/ +│ │ ├── exchange/ # CEX order-book trading +│ │ │ ├── bloc/ +│ │ │ ├── data/ +│ │ │ └── presentation/ +│ │ ├── bridge/ +│ │ ├── bot/ # Market maker bot +│ │ └── shared/ # Shared trade components +│ │ +│ ├── card/ # Gleec Card management +│ │ ├── bloc/ +│ │ ├── data/ +│ │ └── presentation/ +│ │ +│ ├── pay/ # Gleec Pay banking +│ │ ├── bloc/ +│ │ ├── data/ +│ │ └── presentation/ +│ │ +│ ├── earn/ # Staking & rewards +│ ├── nfts/ # NFT gallery +│ ├── onboarding/ # Welcome, wallet creation, KYC +│ └── settings/ +│ +├── shared/ # Shared UI components +│ ├── widgets/ +│ ├── utils/ +│ └── extensions/ +│ +└── main.dart + +sdk/ # Git submodule (Komodo DeFi SDK) +└── packages/ + ├── komodo_defi_sdk/ + ├── komodo_defi_rpc_methods/ + ├── komodo_defi_types/ + ├── komodo_defi_local_auth/ + └── komodo_cex_market_data/ + +packages/ # New local packages (part of this repo) +├── gleec_cex_client/ # Gleec CEX API client +├── gleec_pay_client/ # Gleec Pay + Card API client +└── gleec_ui_kit/ # New design system (built fresh) +``` + +### 12.4 New API Integration Layer + +#### Gleec CEX API Client + +A new package or module wrapping the Gleec CEX REST/WebSocket API: + +``` +packages/gleec_cex_client/ +├── lib/ +│ ├── gleec_cex_client.dart # Public API +│ ├── src/ +│ │ ├── api/ +│ │ │ ├── auth_api.dart # Login, 2FA, session management +│ │ │ ├── trading_api.dart # Orders, trades, order book +│ │ │ ├── account_api.dart # Balances, deposits, withdrawals +│ │ │ └── market_api.dart # Ticker, OHLC, trading pairs +│ │ ├── models/ # Response/request models +│ │ ├── websocket/ # Real-time data (order book, ticker) +│ │ └── interceptors/ # Auth, rate limiting, error mapping +│ └── gleec_cex_client.dart +└── test/ +``` + +#### Gleec Pay + Card API Client + +``` +packages/gleec_pay_client/ +├── lib/ +│ ├── src/ +│ │ ├── api/ +│ │ │ ├── account_api.dart # IBAN, balance, transfers +│ │ │ ├── card_api.dart # Card management, top-up, freeze +│ │ │ └── kyc_api.dart # KYC status, document upload +│ │ ├── models/ +│ │ └── interceptors/ +│ └── gleec_pay_client.dart +└── test/ +``` + +### 12.5 State Management Architecture + +BLoC is the primary state management pattern, following conventions from the BLoC library documentation: + +#### BLoC Naming Conventions (following BLoC docs) + +| Convention | Example | +|---|---| +| Events: past-tense verbs | `SwapRequested`, `OrderPlaced`, `CardTopUpInitiated` | +| States: descriptive nouns | `PortfolioLoaded`, `SwapInProgress`, `CardFrozen` | +| BLoC names: feature + Bloc | `PortfolioBloc`, `SwapBloc`, `ExchangeBloc` | +| Cubits for simple state | `ThemeCubit`, `BalanceVisibilityCubit` | + +#### Key New BLoCs + +| BLoC | Responsibility | +|---|---| +| `PortfolioBloc` | Aggregates balances from Wallet + CEX + Pay into unified view | +| `ExchangeBloc` | CEX order management, order book, trading state | +| `ExchangeAuthBloc` | CEX session, 2FA, separate from wallet auth | +| `CardBloc` | Card balance, transactions, top-up, freeze/unfreeze | +| `PayBloc` | Gleec Pay balance, transfers, IBAN management | +| `KycBloc` | KYC status, document upload, verification progress | +| `TradeRouterBloc` | Smart routing — compares DEX vs CEX rates for a trade | +| `NotificationBloc` | Unified notifications from all services | + +#### Mapping from Old Wallet BLoCs (Reference) + +When porting logic from the existing wallet, use this mapping to understand where old BLoC concerns land in the new architecture: + +| Old Wallet BLoC | New Gleec One BLoC(s) | Notes | +|---|---|---| +| `AuthBloc` | `WalletAuthBloc` + `ExchangeAuthBloc` | Split local wallet auth from CEX session | +| `CoinsBloc` | `AssetsBloc` | Add CEX balance aggregation | +| `TakerBloc` / `MakerFormBloc` | `SwapBloc` | Simplified, cleaner states | +| `TradingEntitiesBloc` | `OrdersBloc` | Unified DEX + CEX orders | +| `WithdrawFormBloc` | `SendBloc` | Port state machine, simplify events | +| `MarketMakerBotBloc` | `BotBloc` | Port state machine as-is, update UI bindings | +| `BridgeBloc` | `BridgeBloc` | Port largely as-is | +| `TransactionHistoryBloc` | `TransactionBloc` | Add CEX/Pay/Card transaction sources | +| `SettingsBloc` | `SettingsBloc` | New from scratch, expanded preferences | +| `OrderbookBloc` | `DexOrderbookBloc` + `CexOrderbookBloc` | Separate DEX and CEX order books | + +### 12.6 Data Flow — Unified Portfolio Example + +``` + PortfolioBloc + │ + ┌────────────┼────────────┐ + │ │ │ + WalletRepo ExchangeRepo PayRepo + │ │ │ + KomodoSDK GleecCEXAPI GleecPayAPI + (MM2/KDF) (+ CardAPI) + │ │ │ + ▼ ▼ ▼ + Self-custody Exchange Banking + balances balances balances + │ │ │ + └────────────┼────────────┘ + │ + PortfolioState + { + totalBalance: $12,847, + walletBalance: $9,023, + exchangeBalance: $2,577, + payBalance: $1,247, + assets: [ + { coin: BTC, wallet: 0.10, exchange: 0.04, card: 0.01 }, + { coin: ETH, wallet: 2.4, exchange: 0, card: 0 }, + ... + ] + } +``` + +### 12.7 Offline & Caching Strategy + +| Data | Cache Duration | Source | +|---|---|---| +| Wallet balances | Real-time (from KDF) | Komodo DeFi SDK | +| CEX balances | 10-second polling + WebSocket | Gleec CEX API | +| Pay/Card balances | 30-second polling | Gleec Pay API | +| Price data | 30-second cache | CEX market data package | +| Order book | Real-time WebSocket | Gleec CEX API | +| Transaction history | Cache with pull-to-refresh | All sources | +| Coin metadata | 24-hour cache | Local + API | +| User preferences | Persistent local storage | SharedPreferences / Hive | + +### 12.8 Security Architecture + +| Layer | Mechanism | +|---|---| +| **Wallet keys** | Local-only, AES-256 encrypted, never leaves device | +| **CEX session** | JWT tokens, stored in secure storage, auto-refresh | +| **Pay session** | Separate JWT, stricter timeout (15 min inactivity) | +| **2FA** | TOTP for CEX and Pay (Google Authenticator / Authy compatible) | +| **Biometrics** | Device-level biometric gate for app unlock + sensitive actions | +| **Pin protection** | Optional app PIN as fallback for no-biometric devices | +| **Screenshot protection** | Prevent screenshots on sensitive screens (seed, private key, card CVV) | +| **Certificate pinning** | Pin CEX and Pay API certificates | +| **Secure storage** | flutter_secure_storage for tokens, passwords, sensitive data | + +--- + +## 13. Phased Delivery Roadmap + +The unified app is delivered in four phases, each shippable as an independent release. Each phase adds a layer of functionality while maintaining a polished, complete-feeling product. + +### Phase 0: Foundation (Weeks 1-8) + +**Goal:** Bootstrap the new Flutter project, implement the design system, wire up the Komodo DeFi SDK, and ship a wallet that matches Exodus quality for core wallet features (hold, send, receive, view portfolio). + +#### Deliverables + +| # | Item | Description | Effort | +|---|---|---|---| +| 0.1 | Project bootstrap | Create new Flutter project, add SDK submodule, configure CI | M | +| 0.2 | Design system (gleec_ui_kit) | New color tokens, typography, core components from spec | L | +| 0.3 | App shell & navigation | Left sidebar (desktop) + bottom tab bar (mobile) + go_router | L | +| 0.4 | Core infrastructure | DI (GetIt), error handling, secure storage, analytics, i18n | L | +| 0.5 | Wallet auth | Wallet creation, import (seed), password, biometrics — HD only | L | +| 0.6 | Portfolio home screen | Hero balance card, donut chart, activity feed, quick actions | L | +| 0.7 | Asset list & management | Asset list with sparklines, search, sort/filter, add/remove coins | L | +| 0.8 | Asset detail page | Price chart, send/receive actions, transaction history, addresses | L | +| 0.9 | Send flow | Port withdraw state machine into new `SendBloc`, new UI | L | +| 0.10 | Receive flow | Address display, QR code, copy, share | M | +| 0.11 | Theme system | Dark/light themes per spec, system theme detection | M | +| 0.12 | Settings (basic) | Theme, language, hide balances, backup seed, security | M | +| 0.13 | Error message system | Human-readable error mapping (port from existing WIP) | M | +| 0.14 | Old wallet maintenance plan | Bug-fix-only branch for existing wallet during transition | S | + +**Exit criteria:** A standalone app that can create/import a wallet, display portfolio, manage assets, send/receive crypto, and looks like a modern Exodus-quality product. DEX swaps and trading are NOT in scope yet — this phase proves the new app shell works end-to-end with the SDK. + +**Key difference from an in-place refactor:** Every line of code in the new project is intentional. There's no dead code, no half-migrated screens, no legacy navigation. The SDK handles crypto operations; the app only contains presentation, state management, and the new service clients. + +### Phase 1: DEX Swaps + CEX Trading (Weeks 9-18) + +**Goal:** Add DEX swap functionality (ported from existing wallet logic) and introduce CEX order-book trading. This is the first "unified trading" milestone. + +#### Deliverables + +| # | Item | Description | Effort | +|---|---|---|---| +| 1.1 | DEX swap feature | Port swap logic from old wallet, build new from/to UI, progress tracker | L | +| 1.2 | Gleec CEX API client | New package wrapping CEX REST + WebSocket API | XL | +| 1.3 | CEX authentication | Login, 2FA, session management within app | L | +| 1.4 | Exchange screen | Order book, price chart, order entry (market/limit) | XL | +| 1.5 | Unified order management | Single view for DEX + CEX orders/history | L | +| 1.6 | Internal transfers | Wallet ⇄ Exchange transfer flow | M | +| 1.7 | Trade routing | Smart DEX vs CEX comparison for swap initiation | L | +| 1.8 | KYC flow (basic) | Email + phone verification for CEX access | M | +| 1.9 | Bridge feature | Port bridge logic from old wallet, build new UI | M | +| 1.10 | Market maker bot | Port bot state machine from old wallet, build new UI | M | + +**Exit criteria:** Users can trade on both DEX and CEX from one app. The smart routing suggests the best venue. CEX users can deposit/withdraw to self-custody wallet seamlessly. + +### Phase 2: Banking & Card (Weeks 19-26) + +**Goal:** Integrate Gleec Pay and Gleec Card, completing the "earn, hold, trade, spend" cycle. + +#### Deliverables + +| # | Item | Description | Effort | +|---|---|---|---| +| 2.1 | Gleec Pay API client | New package for Pay REST API | L | +| 2.2 | Gleec Card API client | Card management API integration | L | +| 2.3 | Card & Pay tab | Navigation section with card and banking views | M | +| 2.4 | Card management UI | Card visual, balance, freeze, transactions | L | +| 2.5 | Card top-up flow | Smart top-up from wallet/exchange/pay | L | +| 2.6 | Banking dashboard | IBAN display, transfer history | M | +| 2.7 | Send bank transfer | SEPA/SWIFT transfer form | M | +| 2.8 | Buy crypto via Pay | Direct purchase from Pay balance | L | +| 2.9 | Full KYC flow | ID verification for Pay/Card access | L | +| 2.10 | Unified portfolio v2 | Add Pay and Card balances to portfolio | M | +| 2.11 | Custody indicators | Visual indicators throughout app | S | + +**Exit criteria:** Users can manage their Gleec Card, view banking details, top up card from crypto, and see all balances (wallet + exchange + pay + card) in one portfolio. + +### Phase 3: Polish & Power Features (Weeks 27-34) + +**Goal:** Add staking, NFTs, advanced features, and polish every interaction to ship-ready quality. + +#### Deliverables + +| # | Item | Description | Effort | +|---|---|---|---| +| 3.1 | Staking / Earn | Staking interface for supported coins | L | +| 3.2 | NFT gallery v2 | Re-enable and polish NFT feature | L | +| 3.3 | Push notifications | Transaction alerts, price alerts, card activity | M | +| 3.4 | Tax export | Transaction export for tax reporting | M | +| 3.5 | Futures / margin | Advanced CEX trading (gated by KYC tier) | XL | +| 3.6 | dApp browser | In-app browser for Web3 interactions (mobile) | L | +| 3.7 | Multi-wallet switcher | Polish account switching UX | M | +| 3.8 | Ledger support | Add Ledger hardware wallet support | L | +| 3.9 | Accessibility audit | WCAG AA compliance, screen reader testing | M | +| 3.10 | Performance optimization | Startup time, memory, animation perf | M | +| 3.11 | Localization expansion | Add 5+ languages beyond English | M | +| 3.12 | QR device sync | Sync wallet config between devices | L | + +**Exit criteria:** Feature-complete Gleec One app ready for public launch. + +### Effort Legend + +| Size | Estimated Effort | +|---|---| +| S | 1-3 days (1 developer) | +| M | 3-7 days (1-2 developers) | +| L | 1-3 weeks (2-3 developers) | +| XL | 3-6 weeks (2-4 developers) | + +### Release Strategy + +| Release | Codename | Audience | Content | +|---|---|---|---| +| **Alpha** (end of Phase 0, ~Week 8) | "Canvas" | Internal team | Core wallet (hold, send, receive, portfolio) | +| **Beta 1** (end of Phase 1, ~Week 18) | "Bridge" | Invited testers | + DEX swaps + CEX trading | +| **Beta 2** (end of Phase 2, ~Week 26) | "Wallet" | Expanded beta | + Card & Pay integration | +| **RC** (mid Phase 3, ~Week 30) | "One" | Open beta | Feature complete | +| **1.0** (end of Phase 3, ~Week 34) | "Gleec One" | Public | Full launch | + +**Note on timeline:** The new-app approach adds ~4 weeks to the overall roadmap compared to an in-place refactor (Phase 0 is 8 weeks instead of 6) because the app shell, authentication, and core wallet features must be built from scratch. However, this is recouped in later phases through faster iteration on a clean codebase with no migration surprises. + +--- + +## 14. Success Metrics & KPIs + +### 14.1 North Star Metric + +**Monthly Active Wallets (MAW)** — The number of unique wallets that perform at least one meaningful action (send, receive, swap, trade, or card purchase) per month. + +### 14.2 Funnel Metrics + +| Stage | Metric | Target (6 months post-launch) | +|---|---|---| +| **Acquisition** | App downloads / installs | 50,000+ | +| **Activation** | Wallets created (completed onboarding) | 60% of installs | +| **First value** | First deposit or buy | 40% of activations | +| **Engagement** | Weekly active users | 30% of activated users | +| **Retention** | 30-day retention | 25% | +| **Revenue** | CEX trading volume | Baseline + 30% | +| **Revenue** | Card transaction volume | Baseline + 50% | + +### 14.3 Feature-Specific KPIs + +| Feature | KPI | Target | +|---|---|---| +| **Unified Portfolio** | % users who check portfolio daily | > 40% of MAW | +| **DEX Swaps** | Swap completion rate | > 85% (up from current baseline) | +| **CEX Trading** | % wallet users who also trade on CEX | > 15% (cross-sell) | +| **Smart Routing** | % of trades that use recommended route | > 60% | +| **Gleec Card** | % verified users who activate card | > 25% | +| **Card Top-Up** | Avg. monthly top-ups per card user | > 3 | +| **Gleec Pay** | % verified users who use IBAN | > 20% | +| **KYC Completion** | % of users who start KYC and complete it | > 70% | +| **Market Maker Bot** | Number of active bot users | 2x current baseline | + +### 14.4 Experience Quality Metrics + +| Metric | Target | +|---|---| +| App startup time (cold) | < 3 seconds | +| Time to first swap (new user) | < 5 minutes from install | +| Crash-free sessions | > 99.5% | +| App Store rating | > 4.5 stars | +| NPS (Net Promoter Score) | > 40 | +| Support ticket volume per MAW | < 5% | +| Average session duration | > 3 minutes | + +--- + +## 15. Risks & Mitigations + +### 15.1 Technical Risks + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| CEX API instability or breaking changes | Medium | High | Versioned API client with retry/fallback, mock server for testing | +| Pay/Card API integration complexity | Medium | High | Early API exploration in Phase 0, close collaboration with Pay/Card team | +| Performance degradation from added features | Medium | Medium | Performance budget per phase, lazy loading, profiling in CI | +| Platform-specific issues (web, iOS, Android) | High | Medium | Platform-specific testing matrix, CI on all targets | +| SDK breaking changes (Komodo DeFi) | Low | High | Pin SDK versions, integration tests, maintain fork if needed | + +### 15.2 Product Risks + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Feature bloat overwhelming users | Medium | High | Progressive disclosure, feature flags, user testing each phase | +| KYC friction causing drop-off | High | High | Tiered KYC (wallet works without it), streamlined ID verification | +| Users confused by custody model | Medium | High | Consistent custody badges, educational tooltips, onboarding education | +| CEX+DEX cannibalization | Low | Medium | Position as complementary — DEX for privacy, CEX for speed/liquidity | +| Card/Pay regulatory issues in certain jurisdictions | Medium | High | Geo-fencing, feature flags per region, legal review per market | + +### 15.3 Design Risks + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Ported BLoC logic introduces subtle regressions | Medium | Medium | Side-by-side testing against old wallet, integration tests for ported flows | +| Navigation complexity with expanded features | Medium | High | User testing with card sorting, A/B test navigation patterns | +| Mobile tab bar limited to 5 items | Low | Low | "More" tab with smart promotion of frequent features | +| Dark mode contrast issues | Low | Medium | WCAG AA audit, contrast checker in design review | + +### 15.4 Business Risks + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Competitor launches similar unified app | Medium | Medium | Speed to market, Gleec ecosystem lock-in (Pay/Card unique) | +| Low adoption of banking features | Medium | Medium | Card incentives (cashback, no fees), banking benefits marketing | +| Regulatory landscape changes | Medium | High | Modular architecture allows disabling features per jurisdiction | +| Team capacity for 30-week roadmap | Medium | High | Phase 0 and 1 are most critical — staff accordingly, cut Phase 3 scope if needed | + +--- + +## 16. Appendix + +### A. Glossary + +| Term | Definition | +|---|---| +| **CEX** | Centralized Exchange — Gleec's order-book exchange (exchange.gleec.com) | +| **DEX** | Decentralized Exchange — atomic-swap based, non-custodial trading via Komodo SDK | +| **KDF** | Komodo DeFi Framework — the underlying protocol engine (MM2) | +| **Atomic Swap** | Trustless exchange of assets between two parties without an intermediary | +| **HD Wallet** | Hierarchical Deterministic wallet — BIP39/BIP44 standard | +| **KYC** | Know Your Customer — identity verification required for regulated services | +| **IBAN** | International Bank Account Number — used for bank transfers | +| **SEPA** | Single Euro Payments Area — EU bank transfer system | +| **FINTRAC** | Financial Transactions and Reports Analysis Centre of Canada | +| **MAW** | Monthly Active Wallets — Gleec One's north star metric | + +### B. Reference Apps Studied + +| App | Key Takeaway for Gleec One | +|---|---| +| **Exodus** | UX benchmark — portfolio-first design, sidebar nav, premium visual polish | +| **Trust Wallet** | Multi-chain simplicity, dApp browser, clean asset management | +| **Coin98** | Super-app architecture, multi-zone navigation, DeFi integration | +| **goodcryptoX** | CEX+DEX+Bot in one app, proves the unified model works | +| **Revolut** | Fintech UX for card management, instant transfers, clean banking UI | +| **Binance** | CEX trading UI patterns, order types, futures interface | + +### C. Exodus Feature Comparison Matrix + +| Feature | Exodus | Gleec (Current) | Gleec One (Proposed) | +|---|---|---|---| +| Portfolio dashboard | Excellent | Basic | Excellent+ | +| Asset management | Excellent | Good | Excellent | +| DEX swaps | Good (via partners) | Excellent (atomic) | Excellent | +| CEX trading | None | Separate app | Integrated | +| Fiat on-ramp | MoonPay/Ramp | Banxa (redirect) | Banxa + Gleec Pay | +| Fiat off-ramp | Limited | None | Gleec Pay | +| Banking / IBAN | None | Separate service | Integrated | +| Debit card | None | Separate app | Integrated | +| Staking | Good | None | Good | +| NFTs | Good | Disabled | Good | +| Hardware wallet | Trezor + Ledger | Trezor | Trezor + Ledger | +| Market maker bot | None | Good | Excellent | +| Bridge | Limited | Good | Good | +| Mobile UX | Excellent | Good | Excellent | +| Desktop UX | Excellent | Good | Excellent | +| Onboarding | Excellent | Complex | Excellent | +| Dark mode | Yes | Yes | Yes (improved) | +| Multi-language | Yes | Yes | Yes (expanded) | + +### D. Open Questions for Stakeholders + +1. **CEX API access:** Does the Gleec CEX team provide a documented API? What authentication model does it use? Is there a sandbox/testnet? + +2. **Pay/Card API access:** Is there an API for Gleec Pay and Gleec Card, or does integration require building one? What's the current tech stack? + +3. **KYC provider:** Should Gleec One use the CEX's existing KYC flow (redirect), or build a native in-app KYC flow? If native, which provider (Jumio, Onfido, Sumsub)? + +4. **Regulatory scope:** Which jurisdictions are targeted for launch? Card and Pay features may need to be geo-fenced. + +5. **Brand alignment:** Is "Gleec One" the final name? Should the app replace the existing Gleec Wallet brand or coexist during transition? + +6. **Team capacity:** What's the available Flutter development team size? The roadmap assumes 3-5 Flutter developers + 1 designer + 1 PM. + +7. **CEX feature scope:** Should the initial CEX integration include futures/margin, or is spot-only sufficient for Phase 1? + +8. **Hardware wallet scope for CEX:** Should Trezor users be able to trade on CEX (requires custodial deposit), or remain wallet-only? + +9. **Existing user migration:** How do we migrate existing Gleec Wallet users to Gleec One? Can seed phrases be re-imported seamlessly? Should the old app prompt users to download Gleec One? + +10. **SDK ownership:** Should the Komodo DeFi SDK remain as a git submodule, or should Gleec fork and own it for faster iteration? + +11. **Old wallet end-of-life:** What is the maintenance window for the existing Gleec Wallet after Gleec One launches? When does it get sunset? + +12. **App store listing:** New listing for Gleec One, or update the existing Gleec Wallet listing? New listing means rebuilding download numbers but avoids confusing existing users with a radically different app. + +13. **Repository hosting:** Should the new Gleec One repo live in the same GitHub org? Should the SDK submodule reference be public or use deploy keys? + +14. **Shared accounts:** Can a single Gleec identity (email/phone) work across CEX, Pay, and Card, or do they currently have separate account systems that need unification on the backend? + +--- + +*This document is a living plan. It should be reviewed and updated as stakeholder feedback is incorporated, technical discoveries are made, and market conditions evolve.* diff --git a/docs/UNLOCALIZED_TEXT.md b/docs/UNLOCALIZED_TEXT.md new file mode 100644 index 0000000000..d5ff81be7c --- /dev/null +++ b/docs/UNLOCALIZED_TEXT.md @@ -0,0 +1,294 @@ +# Unlocalized Text Inventory + +Generated: 2026-02-04 + +## Scope & notes +- Scope: UI-facing text in `lib/views`, `lib/shared/widgets`, `lib/shared/ui`, `lib/sdk/widgets`, `lib/services/feedback`, plus UI kit defaults in `packages/komodo_ui_kit` and user-facing error/validator strings in `lib/bloc`. +- Excludes: tests, asset paths/keys, analytics-only strings, and debug logs. +- Some entries are formatting around localized strings (counts, colons, slashes, punctuation). Those are still listed because they hardcode English punctuation/ordering. + +## Global dialogs & feedback +- `lib/sdk/widgets/window_close_handler.dart:109` — "Do you really want to quit?" +- `lib/sdk/widgets/window_close_handler.dart:116` — "Cancel" +- `lib/sdk/widgets/window_close_handler.dart:120` — "Yes" +- `lib/shared/utils/window/window_native.dart:38` — "Cancel" +- `lib/shared/utils/window/window_native.dart:42` — "OK" +- `lib/shared/utils/utils.dart:72` — "Failed to copy to clipboard" +- `lib/services/feedback/feedback_ui_extension.dart:112` — "Let's Connect on Discord!" +- `lib/services/feedback/feedback_ui_extension.dart:153` — "Close" +- `lib/services/feedback/feedback_ui_extension.dart:162` — "Join Komodo Discord" +- `lib/views/main_layout/main_layout.dart:192` — "Report a bug or feedback" + +## Settings & support +- `lib/views/settings/widgets/general_settings/app_version_number.dart:49` — "Error: ${state.message}" +- `lib/views/settings/widgets/general_settings/app_version_number.dart:101` — "$label:" (localized label + colon) +- `lib/views/settings/widgets/support_page/support_page.dart:101` — "https://www.gleec.com/contact" + +## Fiat +- `lib/views/fiat/fiat_tab_bar.dart:44` — "Form" +- `lib/views/fiat/fiat_tab_bar.dart:50` — "In Progress" +- `lib/views/fiat/fiat_tab_bar.dart:56` — "History" +- `lib/views/fiat/fiat_inputs.dart:167` — "${LocaleKeys.enterAmount.tr()} $boundariesString" (localized label + bounds) + +## NFTs +- `lib/views/nfts/common/widgets/nft_no_chains_enabled.dart:15` — "Please enable NFT protocol assets in the wallet. Enable chains like ETH, BNB, AVAX, MATIC, or FTM to view your NFTs." +- `lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_filters.dart:83` — "Status" +- `lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_filters.dart:96` — "Blockchain" +- `lib/views/nfts/nft_transactions/common/widgets/nft_txn_media.dart:45` — "-" (fallback title) +- `lib/views/nfts/nft_transactions/common/widgets/nft_txn_media.dart:49` — " ($amount)" +- `lib/views/nfts/nft_transactions/common/utils/formatter.dart:7` — `NumberFormat("##0.00#####", "en_US")` +- `lib/views/nfts/nft_transactions/common/utils/formatter.dart:9` — "-" +- `lib/views/nfts/nft_transactions/common/utils/formatter.dart:17` — "-" +- `lib/views/nfts/nft_transactions/common/utils/formatter.dart:18` — "-" +- `lib/views/nfts/nft_transactions/common/utils/formatter.dart:21` — "-" +- `lib/views/nfts/nft_transactions/common/utils/formatter.dart:23` — `NumberFormat.decimalPatternDigits(locale: "en_US", ...)` and "USD" +- `lib/views/nfts/nft_tabs/nft_tab.dart:105` — "Ethereum" +- `lib/views/nfts/nft_tabs/nft_tab.dart:107` — "BNB Smart Chain" +- `lib/views/nfts/nft_tabs/nft_tab.dart:109` — "Avalanche C-Chain" +- `lib/views/nfts/nft_tabs/nft_tab.dart:111` — "Polygon" +- `lib/views/nfts/nft_tabs/nft_tab.dart:113` — "Fantom" + +## Bitrefill +- `lib/views/bitrefill/bitrefill_button.dart:120` — "${widget.coin.abbr} is currently suspended" +- `lib/views/bitrefill/bitrefill_button.dart:124` — "${widget.coin.abbr} is not supported by Bitrefill" +- `lib/views/bitrefill/bitrefill_button.dart:128` — "No ${widget.coin.abbr} balance available for spending" + +## Wallet: main lists & balances +- `lib/views/wallet/wallet_page/wallet_main/all_coins_list.dart:64` — "No coins found" +- `lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart:197` — "Maximum gap limit reached - please use existing unused addresses first" +- `lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart:199` — "Maximum number of addresses reached for this asset" +- `lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart:201` — "Missing derivation path configuration" +- `lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart:203` — "Protocol does not support multiple addresses" +- `lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart:205` — "Current wallet mode does not support multiple addresses" +- `lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart:207` — "No active wallet - please sign in first" +- `lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart:272` — "Derivation: ${pubkey.derivationPath}" +- `lib/views/wallet/wallet_page/wallet_main/balance_summary_widget.dart:50` — "\$${maskedBalanceText}" +- `lib/views/wallet/wallet_page/wallet_main/balance_summary_widget.dart:51` — "\$${NumberFormat(\"#,##0.00\").format(totalBalance!)}" +- `lib/views/wallet/wallet_page/wallet_main/balance_summary_widget.dart:64` — `NumberFormat.currency(symbol: '$')` +- `lib/shared/constants.dart:36` — "****" (masked balance) +- `lib/shared/widgets/coin_balance.dart:36` — "--" +- `lib/shared/widgets/coin_balance.dart:51` — " ${Coin.normalizeAbbr(coin.abbr)}" + +## Wallet: list items & tooltips +- `lib/views/wallet/wallet_page/common/expandable_private_key_list.dart:206` — "${displayCount} key${displayCount > 1 ? 's' : ''}" +- `lib/views/wallet/wallet_page/common/expandable_private_key_list.dart:239` — "${displayCount} key${displayCount > 1 ? 's' : ''}" +- `lib/views/wallet/wallet_page/common/expandable_private_key_list.dart:320` — "Path" +- `lib/views/wallet/wallet_page/common/expandable_private_key_list.dart:349` — "Copy address" +- `lib/views/wallet/wallet_page/common/expandable_private_key_list.dart:374` — "Copy pubkey" +- `lib/views/wallet/wallet_page/common/expandable_private_key_list.dart:381` — "Private key" +- `lib/views/wallet/wallet_page/common/expandable_private_key_list.dart:387` — "*" (mask character) +- `lib/views/wallet/wallet_page/common/expandable_private_key_list.dart:498` — "Path: ${widget.privateKey.hdInfo!.derivationPath}" +- `lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart:167` — "${doubleToString(...)} ${widget.coin.abbr}" +- `lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart:217` — `NumberFormat.currency(symbol: '$')` +- `lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart:304` — "\$${maskedBalanceText}" +- `lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart:316` — "--" +- `lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart:319` — "\$$formatted" +- `lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart:399` — "${doubleToString(...)} ${coin.abbr}" +- `lib/views/wallet/wallet_page/common/grouped_asset_ticker_item.dart:79` — `NumberFormat.currency(symbol: '$')` +- `lib/views/wallet/wallet_page/common/grouped_asset_ticker_item.dart:223` — "Hide related assets" +- `lib/views/wallet/wallet_page/common/grouped_asset_ticker_item.dart:224` — "Show related assets" +- `lib/views/wallet/wallet_page/common/grouped_asset_ticker_item.dart:258` — "Available on Networks:" +- `lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart:111` — "1000" (default blocks per iter) +- `lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart:112` — "200" (default interval ms) +- `lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart:586` — "Download timed out after ${downloadTimeout.inMinutes} minutes" + +## Wallet: charts & formatting +- `lib/views/wallet/wallet_page/charts/coin_prices_chart.dart:66` — `symbol: '$'` +- `lib/views/wallet/wallet_page/charts/coin_prices_chart.dart:69` — "--" +- `lib/views/wallet/wallet_page/charts/coin_prices_chart.dart:100` — "Error: ${state.error}" +- `lib/views/wallet/wallet_page/charts/coin_prices_chart.dart:217` — `DateFormat("MMM")` +- `lib/views/wallet/wallet_page/charts/coin_prices_chart.dart:222` — `DateFormat("MMM ''yy")` +- `lib/views/wallet/wallet_page/charts/coin_prices_chart.dart:227` — `DateFormat("MMM d")` +- `lib/views/wallet/wallet_page/charts/coin_prices_chart.dart:235` — `DateFormat("d")` +- `lib/views/wallet/wallet_page/charts/coin_prices_chart.dart:242` — `DateFormat("EEE")` +- `lib/views/wallet/wallet_page/charts/coin_prices_chart.dart:249` — `DateFormat("EEE HH:mm")` +- `lib/views/wallet/wallet_page/charts/coin_prices_chart.dart:255` — `DateFormat("HH:mm")` +- `lib/views/wallet/wallet_page/charts/price_chart_tooltip.dart:23` — "--" +- `lib/views/wallet/wallet_page/charts/price_chart_tooltip.dart:26` — "\$${value.toStringAsFixed(2)}" +- `lib/views/wallet/wallet_page/charts/price_chart_tooltip.dart:28` — "\$${value.toStringAsPrecision(4)}" +- `lib/views/wallet/wallet_page/charts/price_chart_tooltip.dart:47` — "MMMM d, y" +- `lib/views/wallet/wallet_page/charts/price_chart_tooltip.dart:63` — "${coin.name}: ${valueToString(...)}" + +## Coin details & info +- `lib/shared/widgets/coin_type_tag.dart:46` — "SMART CHAIN" +- `lib/views/wallet/coin_details/coin_details_info/coin_details_info_fiat.dart:83` — "\$" +- `lib/views/wallet/coin_details/coin_details_info/coin_addresses.dart:319` — "${maskedBalanceText} ${abbr2Ticker(coin.abbr)} ($fiat)" +- `lib/views/wallet/coin_details/coin_details_info/coin_addresses.dart:320` — "${doubleToString(balance)} ${abbr2Ticker(coin.abbr)} ($fiat)" +- `lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart:341` — "${address.balance.spendable} ${coin.displayName} available" +- `lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart:444` — "${coin.abbr} is currently suspended" +- `lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart:471` — "Asset ${coin.id.id} not found" +- `lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart:494` — "Error updating configuration: $e" +- `lib/views/wallet/coin_details/coin_details_info/charts/coin_sparkline.dart:24` — "Error: ${snapshot.error}" +- `lib/views/wallet/coin_details/coin_details_info/charts/portfolio_growth_chart.dart:96` — `symbol: '$'` +- `lib/views/wallet/coin_details/coin_details_info/charts/portfolio_growth_chart.dart:149` — `locale: 'en_US'` +- `lib/views/wallet/coin_details/coin_details_info/charts/portfolio_growth_chart.dart:187` — "Linear progress indicator" +- `lib/views/wallet/coin_details/coin_details_info/charts/portfolio_growth_chart.dart:245` — "MMMM d, y" +- `lib/views/wallet/coin_details/coin_details_info/charts/portfolio_growth_chart.dart:281` — `symbol: '$'` +- `lib/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart:83` — `NumberFormat.currency(symbol: '$', ...)` +- `lib/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart:84` — "--" +- `lib/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart:145` — `locale: 'en_US'` +- `lib/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart:181` — "Linear progress indicator" +- `lib/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart:202` — "USDT" +- `lib/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart:232` — "MMMM d, y" +- `lib/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart:237` — `NumberFormat.currency(symbol: '$', ...)` +- `lib/views/wallet/coin_details/coin_details_info/charts/animated_portfolio_charts.dart:79-84` — time unit suffixes "y", "M", "d", "h", "m", "s" +- `lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart:79` — "USDT" +- `lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart:89` — "USDT" +- `lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart:455-460` — time unit suffixes "y", "M", "d", "h", "m", "s" +- `lib/views/wallet/coin_details/transactions/transaction_list_item.dart:126` — "$formatted ${Coin.normalizeAbbr(...)} " +- `lib/views/wallet/coin_details/transactions/transaction_list_item.dart:289` — "$_sign \$${formatAmt(...)}" +- `lib/views/wallet/coin_details/rewards/kmd_reward_list_item.dart:109` — "+KMD " +- `lib/views/wallet/coin_details/rewards/kmd_reward_list_item.dart:117` — "+KMD " +- `lib/views/wallet/coin_details/rewards/kmd_reward_list_item.dart:131` — "-" +- `lib/views/wallet/coin_details/rewards/kmd_reward_list_item.dart:186` — "-" +- `lib/views/wallet/coin_details/rewards/kmd_reward_list_item.dart:208` — "${dd} day(s)" +- `lib/views/wallet/coin_details/rewards/kmd_reward_list_item.dart:213` — "${hh}h ${minutes}m" +- `lib/views/wallet/coin_details/rewards/kmd_reward_list_item.dart:216` — "${mm}min" +- `lib/views/wallet/coin_details/rewards/kmd_reward_list_item.dart:218` — "-" +- `lib/views/wallet/coin_details/rewards/kmd_reward_info_header.dart:73` — "coingecko.com" +- `lib/views/wallet/coin_details/rewards/kmd_reward_info_header.dart:82` — "openrates.io" +- `lib/views/wallet/coin_details/rewards/kmd_reward_claim_success.dart:62` — "\$${formattedUsd}" +- `lib/views/wallet/coin_details/faucet/widgets/faucet_message.dart:33` — "${info.message}\n" (API-provided message) + +## Withdraw flow +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:36` — "Please enter recipient address" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:40` — "Recipient Address" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:41` — "Enter recipient address" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:78` — "Please enter an amount" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:81` — "Please enter a valid number" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:83` — "Amount must be greater than 0" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:87` — "Amount" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:88` — "Enter amount to send" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:189` — "Gas Price (Gwei)" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:206` — "Higher gas price = faster confirmation" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:210` — "Gas Limit" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:212` — "21000" (default gas limit) +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:227` — "Estimated: 21000" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:255` — "Standard ($defaultFee)" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:259` — "Fast (${defaultFee * 2})" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:263` — "Urgent (${defaultFee * 5})" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:282` — "Higher fee = faster confirmation" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:313` — "From" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:316` — "Default Wallet" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:320` — "To" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:325` — "Amount" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:331` — "Network Fee" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:336` — "Memo" +- `lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart:466` — "Withdrawal Failed" +- `lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart:127` — "Since you're sending your full amount, the network fee will be deducted from the amount. Do you agree?" +- `lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart:53` — "-${state.amount} ${Coin.normalizeAbbr(state.asset.id.id)}" +- `lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart:62` — "\$${state.usdAmountPrice ?? 0}" +- `lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart:134` — "${LocaleKeys.fee.tr()}:" +- `lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart:143` — "${LocaleKeys.transactionHash.tr()}:" +- `lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart:169` — "${LocaleKeys.memo.tr()}:" +- `lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form.dart:47` — "${LocaleKeys.recipientAddress.tr()}:" +- `lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form.dart:53` — "${LocaleKeys.amount.tr()}:" +- `lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form.dart:59` — "${LocaleKeys.fee.tr()}:" +- `lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form.dart:68` — "${LocaleKeys.memo.tr()}:" +- `lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form_error.dart:18` — "Unknown error" + +## DEX +- `lib/views/dex/dex_helpers.dart:54` — "≈\$${formatAmt(...)}" +- `lib/views/dex/dex_helpers.dart:345` — "\$0.00" +- `lib/views/dex/dex_list_filter/mobile/dex_list_filter_coins_list_mobile.dart:163` — " (segwit)" +- `lib/views/dex/dex_list_filter/mobile/dex_list_filter_coins_list_mobile.dart:167` — "($pairCount)" +- `lib/views/dex/dex_list_filter/mobile/dex_list_filter_mobile.dart:98` — "${LocaleKeys.taker.tr()}/${LocaleKeys.maker.tr()}" +- `lib/views/dex/dex_list_filter/desktop/dex_list_filter_desktop.dart:152` — "${LocaleKeys.taker.tr()}/${LocaleKeys.maker.tr()}" +- `lib/views/dex/simple/form/exchange_info/exchange_rate.dart:33` — "${LocaleKeys.rate.tr()}:" +- `lib/views/dex/simple/form/exchange_info/exchange_rate.dart:37` — "0.00" +- `lib/views/dex/simple/form/exchange_info/exchange_rate.dart:74` — " 1 ${Coin.normalizeAbbr(base ?? '')} = " +- `lib/views/dex/simple/form/exchange_info/exchange_rate.dart:79` — " $price ${Coin.normalizeAbbr(rel ?? '')}" +- `lib/views/dex/simple/form/exchange_info/exchange_rate.dart:84` — "(${baseFiat(context)})" +- `lib/views/dex/simple/form/exchange_info/exchange_rate.dart:91-94` — "1 ${Coin.normalizeAbbr(rel ?? '')} = $quotePrice ${Coin.normalizeAbbr(base ?? '')} (${relFiat(context)})" +- `lib/views/dex/simple/form/exchange_info/exchange_rate.dart:118` — "0" +- `lib/views/dex/simple/form/exchange_info/exchange_rate.dart:123` — "0" +- `lib/views/dex/simple/form/exchange_info/dex_compared_to_cex.dart:77` — "${formatAmt(diff)}%" +- `lib/views/dex/simple/form/tables/orders_table/grouped_list_view.dart:176` — " $abbr" +- `lib/views/dex/orderbook/orderbook_table.dart:84` — " ≈ " +- `lib/views/dex/orderbook/orderbook_table.dart:85` — "\$$baseUsdPrice" +- `lib/views/dex/common/trading_amount_field.dart:40` — "0.00" +- `lib/views/dex/simple/form/maker/maker_form_buy_amount.dart:103` — "0.00" +- `lib/views/dex/simple/form/maker/maker_form_sell_amount.dart:109` — "0.00" + +## Market maker bot +- `lib/views/market_maker_bot/coin_search_dropdown.dart:40` — "Search coins" +- `lib/views/market_maker_bot/coin_search_dropdown.dart:46` — "Search coins" +- `lib/views/market_maker_bot/coin_search_dropdown.dart:247` — "Search" +- `lib/views/market_maker_bot/coin_trade_amount_form_field.dart:130` — "≈$0" +- `lib/views/market_maker_bot/coin_trade_amount_form_field.dart:172` — "0.00" +- `lib/views/market_maker_bot/coin_trade_amount_form_field.dart:182` — "*" +- `lib/views/market_maker_bot/coin_trade_amount_label.dart:72` — "≈$0" +- `lib/views/market_maker_bot/coin_trade_amount_label.dart:110` — "*" +- `lib/views/market_maker_bot/market_maker_form_error_message_extensions.dart:98` — "too low for ${baseCoin?.abbr ?? ''}" +- `lib/views/market_maker_bot/market_maker_form_error_message_extensions.dart:115` — "too low for" +- `lib/views/market_maker_bot/trade_volume_type.dart:9` — "\$" and "%" +- `lib/views/market_maker_bot/trade_volume_type.dart:11` — "USD" and "Percentage" +- `lib/views/market_maker_bot/trade_pair_list_item.dart:38` — "-" (date placeholder) +- `lib/views/market_maker_bot/trade_pair_list_item.dart:74` — "${config.margin.toStringAsFixed(2)}%" +- `lib/views/market_maker_bot/trade_pair_list_item.dart:75` — "${config.updateInterval.minutes} min" +- `lib/views/market_maker_bot/trade_bot_update_interval.dart:10` — "1" +- `lib/views/market_maker_bot/trade_bot_update_interval.dart:12` — "3" +- `lib/views/market_maker_bot/trade_bot_update_interval.dart:14` — "5" +- `lib/views/market_maker_bot/trade_bot_update_interval.dart:33` — "Invalid interval" +- `lib/views/market_maker_bot/update_interval_dropdown.dart:38` — "${interval.minutes} ${LocaleKeys.minutes.tr()}" +- `lib/views/market_maker_bot/market_maker_bot_tab_type.dart:19` — "${LocaleKeys.orders.tr()} (${bloc.tradeBotOrdersCount})" +- `lib/views/market_maker_bot/market_maker_bot_tab_type.dart:21` — "${LocaleKeys.inProgress.tr()} (${bloc.inProgressCount})" +- `lib/views/market_maker_bot/market_maker_bot_tab_type.dart:23` — "${LocaleKeys.history.tr()} (${bloc.completedCount})" +- `lib/views/market_maker_bot/market_maker_bot_form_content.dart:101` — "${LocaleKeys.margin.tr()}:" +- `lib/views/market_maker_bot/market_maker_bot_form_content.dart:116` — "${LocaleKeys.updateInterval.tr()}:" +- `lib/views/market_maker_bot/market_maker_bot_confirmation_form.dart:357` — " (${percentage > 0 ? '+' : ''}${formatAmt(percentage)}%)" +- `lib/views/market_maker_bot/market_maker_bot_confirmation_form.dart:395` — "${formatDexAmt(amount)} " + +## Bridge +- `lib/views/bridge/bridge_tab_bar.dart:59` — "${LocaleKeys.inProgress.tr()} ($_inProgressCount)" +- `lib/views/bridge/bridge_tab_bar.dart:65` — "${LocaleKeys.history.tr()} ($_completedCount)" +- `lib/views/bridge/bridge_confirmation.dart:67` — "${LocaleKeys.somethingWrong.tr()} :(" +- `lib/views/bridge/bridge_confirmation.dart:201` — "${formatDexAmt(dto.buyAmount)} " + +## BLoC error/validator strings (user-facing) +- `lib/bloc/bridge_form/bridge_validator.dart:142` — "Failed to request trade preimage" +- `lib/bloc/bridge_form/bridge_bloc.dart:675` — "Failed to request fees" +- `lib/bloc/taker_form/taker_bloc.dart:611` — "Failed to request fees" +- `lib/bloc/taker_form/taker_validator.dart:320` — "Failed to request trade preimage" +- `lib/bloc/dex_repository.dart:76` — "Something wrong" +- `lib/bloc/dex_repository.dart:93` — "Something wrong" +- `lib/bloc/dex_repository.dart:149` — "Simulated best_orders failure (debug)" +- `lib/bloc/dex_repository.dart:168` — "best_orders returned null response" +- `lib/bloc/faucet_button/faucet_button_bloc.dart:41` — "Faucet request failed: ${response.message}" +- `lib/bloc/faucet_button/faucet_button_bloc.dart:45` — "Network error: ${error.toString()}" +- `lib/bloc/withdraw_form/withdraw_form_bloc.dart:108` — "Failed to load addresses: $e" +- `lib/bloc/withdraw_form/withdraw_form_bloc.dart:230` — "Address validation failed: $e" +- `lib/bloc/withdraw_form/withdraw_form_bloc.dart:259` — "Insufficient funds" +- `lib/bloc/withdraw_form/withdraw_form_bloc.dart:270` — "Amount must be greater than 0" +- `lib/bloc/withdraw_form/withdraw_form_bloc.dart:287` — "Invalid amount" +- `lib/bloc/withdraw_form/withdraw_form_bloc.dart:582` — "Failed to generate preview: $e" +- `lib/bloc/withdraw_form/withdraw_form_bloc.dart:667` — "Transaction failed: $e" +- `lib/bloc/withdraw_form/withdraw_form_bloc.dart:733` — "Failed to convert address: $e" +- `lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart:271` — "Failed to load portfolio profit/loss" +- `lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart:358` — "Failed to load portfolio growth" + +## UI kit defaults (komodo_ui_kit) +- `packages/komodo_ui_kit/lib/src/buttons/upload_button.dart:7` — "Select a file" +- `packages/komodo_ui_kit/lib/src/buttons/text_dropdown_button.dart:15` — "Select an item" +- `packages/komodo_ui_kit/lib/src/controls/selected_coin_graph_control.dart:110` — "All" +- `packages/komodo_ui_kit/lib/src/inputs/time_period_selector.dart:168` — "1H" +- `packages/komodo_ui_kit/lib/src/inputs/time_period_selector.dart:169` — "${hours}H" +- `packages/komodo_ui_kit/lib/src/inputs/time_period_selector.dart:172` — "1D" +- `packages/komodo_ui_kit/lib/src/inputs/time_period_selector.dart:173` — "${days}D" +- `packages/komodo_ui_kit/lib/src/inputs/time_period_selector.dart:176` — "1W" +- `packages/komodo_ui_kit/lib/src/inputs/time_period_selector.dart:177` — "${weeks}W" +- `packages/komodo_ui_kit/lib/src/inputs/time_period_selector.dart:180` — "1M" +- `packages/komodo_ui_kit/lib/src/inputs/time_period_selector.dart:181` — "${months}M" +- `packages/komodo_ui_kit/lib/src/inputs/time_period_selector.dart:184` — "1Y" +- `packages/komodo_ui_kit/lib/src/inputs/time_period_selector.dart:185` — "${years}Y" +- `packages/komodo_ui_kit/lib/src/inputs/time_period_selector.dart:188` — "Unsupported duration: $duration" + +## Legal / disclaimer content +All EULA/Terms/Disclaimer text is hardcoded English in `lib/shared/widgets/disclaimer/constants.dart`. Constants include: +- `disclaimerEulaTitle1` +- `disclaimerTocTitle2` through `disclaimerTocTitle20` +- `disclaimerEulaParagraph1` through `disclaimerEulaParagraph19` +- `disclaimerEulaTitle2` through `disclaimerEulaTitle6` +- `disclaimerEulaParagraph7` through `disclaimerEulaParagraph17` +- `disclaimerEulaLegacyParagraph1` +- `disclaimerTocParagraph2` through `disclaimerTocParagraph19` diff --git a/firebase.json b/firebase.json index 3c3e731ab5..267511fef9 100644 --- a/firebase.json +++ b/firebase.json @@ -2,11 +2,22 @@ "hosting": { "site": "walletrc", "public": "build/web", - "ignore": [ - "firebase.json", - "**/.*", - "**/node_modules/**" - ], + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + // "headers": [ + // { + // "source": "**", + // "headers": [ + // { + // "key": "Cross-Origin-Embedder-Policy", + // "value": "credentialless" + // }, + // { + // "key": "Cross-Origin-Opener-Policy", + // "value": "same-origin" + // } + // ] + // } + // ], "rewrites": [ { "source": "**", @@ -14,4 +25,4 @@ } ] } -} \ No newline at end of file +} diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index d57061dd6b..ab8e063fe8 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/ios/Podfile b/ios/Podfile index e30b311cb6..11c142e049 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '15.5.0' +platform :ios, '15.6' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 9cbad57888..4aab7d0697 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -286,6 +286,6 @@ SPEC CHECKSUMS: url_launcher_ios: 694010445543906933d732453a59da0a173ae33d video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b -PODFILE CHECKSUM: f2a1ebd07796ee082cb4d8e8fa742449f698c4f1 +PODFILE CHECKSUM: 8a1940628b81389d4af7ba4fac6ed1cc8374cca0 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index b6f939801a..b7f2942563 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 49484A33FCF0585DB40EBAD9 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C74478EE63B90E2A48A7AB3C /* GoogleService-Info.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 8A47D180A1B84E12912B6323 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C0C0C1027A54A2A85585B71 /* SceneDelegate.swift */; }; F1D1A3B2C3D4E5F6A7B8C9D1 /* FdMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D1A3B2C3D4E5F6A7B8C9D0 /* FdMonitor.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -43,6 +44,7 @@ 1E4F53BBF74C393F00500C43 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 335A3372CF627D5A148D7C1F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 2C0C0C1027A54A2A85585B71 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 6DB340A008F6FECB3B82619D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -124,6 +126,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 2C0C0C1027A54A2A85585B71 /* SceneDelegate.swift */, F1D1A3B2C3D4E5F6A7B8C9D0 /* FdMonitor.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); @@ -306,6 +309,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 8A47D180A1B84E12912B6323 /* SceneDelegate.swift in Sources */, F1D1A3B2C3D4E5F6A7B8C9D1 /* FdMonitor.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); @@ -375,7 +379,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -466,7 +470,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -516,7 +520,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index e8f0f3928b..78da4ae82f 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,70 +1,71 @@ -import Flutter -import UIKit - -@main -@objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - NSLog("🔴 AppDelegate: didFinishLaunchingWithOptions REACHED") - GeneratedPluginRegistrant.register(with: self) - - NSLog("AppDelegate: Setting up FD Monitor channel...") - setupFdMonitorChannel() - - #if DEBUG - NSLog("AppDelegate: DEBUG build detected, auto-starting FD Monitor...") - FdMonitor.shared.start(intervalSeconds: 60.0) - #else - NSLog("AppDelegate: RELEASE build, FD Monitor NOT auto-started (use Flutter to start manually)") - #endif - - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } - - private func setupFdMonitorChannel() { - guard let controller = window?.rootViewController as? FlutterViewController else { - return - } - - let channel = FlutterMethodChannel( - name: "com.komodo.wallet/fd_monitor", - binaryMessenger: controller.binaryMessenger - ) - - channel.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in - self?.handleFdMonitorMethodCall(call: call, result: result) - } - } - - private func handleFdMonitorMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "start": - let intervalSeconds: TimeInterval - if let args = call.arguments as? [String: Any], - let interval = args["intervalSeconds"] as? Double { - intervalSeconds = interval - } else { - intervalSeconds = 60.0 - } - FdMonitor.shared.start(intervalSeconds: intervalSeconds) - result(["success": true, "message": "FD Monitor started with interval: \(intervalSeconds)s"]) - - case "stop": - FdMonitor.shared.stop() - result(["success": true, "message": "FD Monitor stopped"]) - - case "getCurrentCount": - let count = FdMonitor.shared.getCurrentCount() - result(count) - - case "logDetailedStatus": - FdMonitor.shared.logDetailedStatus() - result(["success": true, "message": "Detailed FD status logged"]) - - default: - result(FlutterMethodNotImplemented) - } - } -} +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + private var fdMonitorChannel: FlutterMethodChannel? + + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + NSLog("🔴 AppDelegate: didFinishLaunchingWithOptions REACHED") + + #if DEBUG + NSLog("AppDelegate: DEBUG build detected, auto-starting FD Monitor...") + FdMonitor.shared.start(intervalSeconds: 60.0) + #else + NSLog("AppDelegate: RELEASE build, FD Monitor NOT auto-started (use Flutter to start manually)") + #endif + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + + NSLog("AppDelegate: Setting up FD Monitor channel...") + setupFdMonitorChannel(binaryMessenger: engineBridge.applicationRegistrar.messenger()) + } + + private func setupFdMonitorChannel(binaryMessenger: FlutterBinaryMessenger) { + fdMonitorChannel = FlutterMethodChannel( + name: "com.komodo.wallet/fd_monitor", + binaryMessenger: binaryMessenger + ) + + fdMonitorChannel?.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in + self?.handleFdMonitorMethodCall(call: call, result: result) + } + } + + private func handleFdMonitorMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "start": + let intervalSeconds: TimeInterval + if let args = call.arguments as? [String: Any], + let interval = args["intervalSeconds"] as? Double { + intervalSeconds = interval + } else { + intervalSeconds = 60.0 + } + FdMonitor.shared.start(intervalSeconds: intervalSeconds) + result(["success": true, "message": "FD Monitor started with interval: \(intervalSeconds)s"]) + + case "stop": + FdMonitor.shared.stop() + result(["success": true, "message": "FD Monitor stopped"]) + + case "getCurrentCount": + let count = FdMonitor.shared.getCurrentCount() + result(count) + + case "logDetailedStatus": + FdMonitor.shared.logDetailedStatus() + result(["success": true, "message": "Detailed FD status logged"]) + + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index f1425e463b..4782f9b1d9 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -33,6 +33,27 @@ This app needs camera access to scan QR codes NSPhotoLibraryUsageDescription This app needs access to your photo library to import QR codes + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000000..de2a22f0c5 --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,5 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { +} diff --git a/lib/app_config/app_config.dart b/lib/app_config/app_config.dart index e4fc6d4219..415c8379ab 100644 --- a/lib/app_config/app_config.dart +++ b/lib/app_config/app_config.dart @@ -21,9 +21,7 @@ const String coinsAssetsPath = 'packages/komodo_defi_framework/assets'; final Uri discordSupportChannelUrl = Uri.parse( 'mailto:info@gleec.com?subject=GLEEC%20Wallet%20Support', ); -final Uri discordInviteUrl = Uri.parse( - 'https://www.gleec.com/contact', -); +final Uri discordInviteUrl = Uri.parse('https://www.gleec.com/contact'); /// Const to define if Bitrefill integration is enabled in the app. const bool isBitrefillIntegrationEnabled = false; @@ -38,6 +36,11 @@ const bool isBitrefillIntegrationEnabled = false; ///! trading purposes where it is not legally compliant. const bool kShowTradingWarning = false; +/// Controls whether the HD mode warning banner is shown on the wallet page. +/// TODO: Replace this static flag with conditional visibility once we can +/// determine whether the wallet has previously been used in legacy mode. +const bool kShowHdWalletWarningBanner = false; + const Duration kPerformanceLogInterval = Duration(minutes: 1); /// Enable debug logging for electrum connections and RPC methods. @@ -79,6 +82,7 @@ Map priorityCoinsAbbrMap = { 'USDT-ERC20': 80, 'USDT-PLG20': 80, 'USDT-BEP20': 80, + 'USDT-TRC20': 80, // Rank 4: XRP (~$145 billion) 'XRP': 70, @@ -119,6 +123,7 @@ const List unauthenticatedUserPriorityTickers = [ 'BTC', 'KMD', 'ETH', + 'TRX', 'BNB', 'LTC', 'DASH', diff --git a/lib/app_config/package_information.dart b/lib/app_config/package_information.dart index ce8684ae95..57c413c686 100644 --- a/lib/app_config/package_information.dart +++ b/lib/app_config/package_information.dart @@ -6,14 +6,22 @@ class PackageInformation { String? packageVersion; String? packageName; String? commitHash; + String? buildDate; - static const String _kCommitHash = - String.fromEnvironment('COMMIT_HASH', defaultValue: 'unknown'); + static const String _kCommitHash = String.fromEnvironment( + 'COMMIT_HASH', + defaultValue: 'unknown', + ); + static const String _kBuildDate = String.fromEnvironment( + 'BUILD_DATE', + defaultValue: 'unknown', + ); Future init() async { final PackageInfo packageInfo = await PackageInfo.fromPlatform(); packageVersion = packageInfo.version; packageName = packageInfo.packageName; commitHash = _kCommitHash; + buildDate = _kBuildDate; } } diff --git a/lib/bloc/app_bloc_root.dart b/lib/bloc/app_bloc_root.dart index 8312313582..a8547cb77c 100644 --- a/lib/bloc/app_bloc_root.dart +++ b/lib/bloc/app_bloc_root.dart @@ -9,7 +9,6 @@ import 'package:get_it/get_it.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_ui/komodo_ui.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:universal_html/html.dart' as html; import 'package:web_dex/analytics/events.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; @@ -64,6 +63,7 @@ import 'package:web_dex/router/navigators/back_dispatcher.dart'; import 'package:web_dex/router/parsers/root_route_parser.dart'; import 'package:web_dex/router/state/routing_state.dart'; import 'package:web_dex/services/orders_service/my_orders_service.dart'; +import 'package:web_dex/services/platform_web_api/platform_web_api.dart'; import 'package:web_dex/shared/utils/debug_utils.dart'; import 'package:web_dex/shared/utils/ipfs_gateway_manager.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -234,11 +234,7 @@ class AppBlocRoot extends StatelessWidget { create: (context) => BridgeBloc( kdfSdk: komodoDefiSdk, dexRepository: dexRepository, - bridgeRepository: BridgeRepository( - mm2Api, - komodoDefiSdk, - coinsRepository, - ), + bridgeRepository: BridgeRepository(mm2Api, coinsRepository), coinsRepository: coinsRepository, analyticsBloc: BlocProvider.of(context), ), @@ -316,10 +312,12 @@ class _MyAppViewState extends State<_MyAppView> { late final RootRouteInformationParser _routeInformationParser; late final AirDexBackButtonDispatcher _airDexBackButtonDispatcher; late final DateTime _pageLoadStartTime; + late final PlatformWebApi _platformWebApi; @override void initState() { _pageLoadStartTime = DateTime.now(); + _platformWebApi = PlatformWebApi(); final coinsBloc = context.read(); _routeInformationParser = RootRouteInformationParser(coinsBloc); _airDexBackButtonDispatcher = AirDexBackButtonDispatcher(_routerDelegate); @@ -370,20 +368,16 @@ class _MyAppViewState extends State<_MyAppView> { // web and native to avoid web-code in code concerning all platforms. Future _hideAppLoader() async { if (kIsWeb) { - html.document.getElementById('main-content')?.style.display = 'block'; - - final loadingElement = html.document.getElementById('loading'); - - if (loadingElement == null) return; + _platformWebApi.setElementDisplay('main-content', 'block'); // Trigger the zoom out animation. - loadingElement.classes.add('init_done'); + _platformWebApi.addElementClass('loading', 'init_done'); // Await 200ms so the user can see the animation. await Future.delayed(const Duration(milliseconds: 200)); // Remove the loading indicator. - loadingElement.remove(); + _platformWebApi.removeElement('loading'); final delay = DateTime.now() .difference(_pageLoadStartTime) diff --git a/lib/bloc/auth_bloc/auth_bloc.dart b/lib/bloc/auth_bloc/auth_bloc.dart index 12e6a92fe9..c87d68cae8 100644 --- a/lib/bloc/auth_bloc/auth_bloc.dart +++ b/lib/bloc/auth_bloc/auth_bloc.dart @@ -103,7 +103,7 @@ class AuthBloc extends Bloc with TrezorAuthMixin { // Explicitly disconnect SSE on sign-out _log.info('User signed out, disconnecting SSE...'); _kdfSdk.streaming.disconnect(); - + await _authChangesSubscription?.cancel(); emit(AuthBlocState.initial()); } @@ -138,18 +138,24 @@ class AuthBloc extends Bloc with TrezorAuthMixin { allowWeakPassword: weakPasswordsAllowed, ), ); - final KdfUser? currentUser = await _kdfSdk.auth.currentUser; + KdfUser? currentUser = await _kdfSdk.auth.currentUser; + if (currentUser == null) { + return emit(AuthBlocState.error(AuthException.notSignedIn())); + } + + await _repairMissingWalletMetadata(currentUser); + currentUser = await _kdfSdk.auth.currentUser; if (currentUser == null) { return emit(AuthBlocState.error(AuthException.notSignedIn())); } _log.info('Successfully logged in to wallet'); emit(AuthBlocState.loggedIn(currentUser)); - + // Explicitly connect SSE after successful login _log.info('User authenticated, connecting SSE for streaming...'); _kdfSdk.streaming.connectIfNeeded(); - + _listenToAuthStateChanges(); } catch (e, s) { if (e is AuthException) { @@ -222,6 +228,8 @@ class AuthBloc extends Bloc with TrezorAuthMixin { 'Registered a new wallet, setting up metadata and logging in...', ); await _kdfSdk.setWalletType(event.wallet.config.type); + await _kdfSdk.setWalletProvenance(WalletProvenance.generated); + await _kdfSdk.setWalletCreatedAt(DateTime.now()); await _kdfSdk.confirmSeedBackup(hasBackup: false); // Filter out geo-blocked assets from default coins before adding to wallet final allowedDefaultCoins = _filterBlockedAssets(enabledByDefaultCoins); @@ -314,6 +322,8 @@ class AuthBloc extends Bloc with TrezorAuthMixin { 'Setting up wallet metadata and logging in...', ); await _kdfSdk.setWalletType(workingWallet.config.type); + await _kdfSdk.setWalletProvenance(WalletProvenance.imported); + await _kdfSdk.setWalletCreatedAt(DateTime.now()); await _kdfSdk.confirmSeedBackup( hasBackup: workingWallet.config.hasBackup, ); @@ -464,7 +474,7 @@ class AuthBloc extends Bloc with TrezorAuthMixin { ? AuthorizeMode.logIn : AuthorizeMode.noLogin; add(AuthModeChanged(mode: event, currentUser: user)); - + // Tie SSE connection lifecycle to authentication state if (user != null) { // User authenticated - connect SSE for balance/tx history streaming @@ -495,4 +505,26 @@ class AuthBloc extends Bloc with TrezorAuthMixin { return supportedAssets.toList(); } + + Future _repairMissingWalletMetadata(KdfUser user) async { + if (_isMissingMetadataStringValue(user.metadata['type'])) { + final walletType = user.walletId.isHd + ? WalletType.hdwallet + : WalletType.iguana; + await _kdfSdk.setWalletType(walletType); + } + + if (_isMissingMetadataStringValue(user.metadata['wallet_provenance'])) { + final isImported = user.metadata['isImported']; + if (isImported is bool) { + await _kdfSdk.setWalletProvenance( + isImported ? WalletProvenance.imported : WalletProvenance.generated, + ); + } + } + } + + bool _isMissingMetadataStringValue(dynamic value) { + return value == null || value is String && value.trim().isEmpty; + } } diff --git a/lib/bloc/auth_bloc/trezor_auth_mixin.dart b/lib/bloc/auth_bloc/trezor_auth_mixin.dart index f65a16ca6f..c8c0ab152b 100644 --- a/lib/bloc/auth_bloc/trezor_auth_mixin.dart +++ b/lib/bloc/auth_bloc/trezor_auth_mixin.dart @@ -65,13 +65,15 @@ mixin TrezorAuthMixin on Bloc { switch (authState.status) { case AuthenticationStatus.initializing: return AuthBlocState.trezorInitializing( - message: authState.message ?? LocaleKeys.trezorInitializingMessage.tr(), + message: + authState.message ?? LocaleKeys.trezorInitializingMessage.tr(), taskId: authState.taskId, ); case AuthenticationStatus.waitingForDevice: return AuthBlocState.trezorInitializing( message: - authState.message ?? LocaleKeys.trezorWaitingForDeviceMessage.tr(), + authState.message ?? + LocaleKeys.trezorWaitingForDeviceMessage.tr(), taskId: authState.taskId, ); case AuthenticationStatus.waitingForDeviceConfirmation: @@ -83,12 +85,15 @@ mixin TrezorAuthMixin on Bloc { ); case AuthenticationStatus.pinRequired: return AuthBlocState.trezorPinRequired( - message: authState.message ?? LocaleKeys.trezorPinRequiredMessage.tr(), + message: + authState.message ?? LocaleKeys.trezorPinRequiredMessage.tr(), taskId: authState.taskId!, ); case AuthenticationStatus.passphraseRequired: return AuthBlocState.trezorPassphraseRequired( - message: authState.message ?? LocaleKeys.trezorPassphraseRequiredMessage.tr(), + message: + authState.message ?? + LocaleKeys.trezorPassphraseRequiredMessage.tr(), taskId: authState.taskId!, ); case AuthenticationStatus.authenticating: @@ -96,11 +101,9 @@ mixin TrezorAuthMixin on Bloc { case AuthenticationStatus.completed: return _setupTrezorWallet(authState); case AuthenticationStatus.error: + final mappedError = _mapTrezorErrorMessage(authState.error); return AuthBlocState.error( - AuthException( - authState.error ?? LocaleKeys.trezorAuthFailedMessage.tr(), - type: AuthExceptionType.generalAuthError, - ), + AuthException(mappedError, type: AuthExceptionType.generalAuthError), ); case AuthenticationStatus.cancelled: return AuthBlocState.error( @@ -112,6 +115,23 @@ mixin TrezorAuthMixin on Bloc { } } + String _mapTrezorErrorMessage(String? errorMessage) { + if (errorMessage == null || errorMessage.trim().isEmpty) { + return LocaleKeys.trezorAuthFailedMessage.tr(); + } + + final normalized = errorMessage.toLowerCase(); + if (normalized.contains('cancel')) { + return LocaleKeys.trezorAuthCancelledMessage.tr(); + } + if (normalized.contains('invalid pin') || + (normalized.contains('pin') && normalized.contains('invalid'))) { + return LocaleKeys.trezorErrorInvalidPin.tr(); + } + + return errorMessage; + } + /// Sets up the Trezor wallet after successful authentication. /// This includes setting the wallet type, confirming seed backup, /// and adding the default activated coins. diff --git a/lib/bloc/bridge_form/bridge_bloc.dart b/lib/bloc/bridge_form/bridge_bloc.dart index 354dee5825..4e93227d36 100644 --- a/lib/bloc/bridge_form/bridge_bloc.dart +++ b/lib/bloc/bridge_form/bridge_bloc.dart @@ -26,6 +26,7 @@ import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/data_from_service.dart'; import 'package:web_dex/model/dex_form_error.dart'; import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; import 'package:web_dex/model/trade_preimage.dart'; import 'package:web_dex/model/typedef.dart'; import 'package:web_dex/model/wallet.dart'; @@ -672,7 +673,12 @@ class BridgeBloc extends Bloc { path: 'bridge_bloc::_getFeesData', isError: true, ); - return DataFromService(error: TextError(error: 'Failed to request fees')); + return DataFromService( + error: TextError( + error: formatKdfUserFacingError(e), + technicalDetails: extractKdfTechnicalDetails(e), + ), + ); } } diff --git a/lib/bloc/bridge_form/bridge_repository.dart b/lib/bloc/bridge_form/bridge_repository.dart index c1db2114d8..eac7745572 100644 --- a/lib/bloc/bridge_form/bridge_repository.dart +++ b/lib/bloc/bridge_form/bridge_repository.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; import 'package:web_dex/mm2/mm2_api/rpc/orderbook_depth/orderbook_depth_response.dart'; @@ -10,20 +9,25 @@ import 'package:web_dex/model/typedef.dart'; import 'package:web_dex/shared/utils/utils.dart'; class BridgeRepository { - BridgeRepository(this._mm2Api, this._kdfSdk, this._coinsRepository); + BridgeRepository(this._mm2Api, this._coinsRepository); final Mm2Api _mm2Api; - final KomodoDefiSdk _kdfSdk; final CoinsRepo _coinsRepository; + static const Duration _depthCacheTtl = Duration(seconds: 10); + final Map _depthCache = {}; + final Map?>> _depthInFlight = {}; + Future getSellCoins(CoinsByTicker? tickers) async { if (tickers == null) return null; final List? depths = await _getDepths(tickers); if (depths == null) return null; - final CoinsByTicker sellCoins = - tickers.entries.fold({}, (previousValue, entry) { + final CoinsByTicker sellCoins = tickers.entries.fold({}, ( + previousValue, + entry, + ) { final List coins = previousValue[entry.key] ?? []; final List tickerDepths = depths .where( @@ -57,11 +61,13 @@ class BridgeRepository { coins = removeWalletOnly(coins); final CoinsByTicker coinsByTicker = convertToCoinsByTicker(coins); - final CoinsByTicker multiProtocolCoins = - removeSingleProtocol(coinsByTicker); + final CoinsByTicker multiProtocolCoins = removeSingleProtocol( + coinsByTicker, + ); - final List? orderBookDepths = - await _getDepths(multiProtocolCoins); + final List? orderBookDepths = await _getDepths( + multiProtocolCoins, + ); if (orderBookDepths == null || orderBookDepths.isEmpty) { return multiProtocolCoins; @@ -75,20 +81,49 @@ class BridgeRepository { Future?> _getDepths(CoinsByTicker coinsByTicker) async { final List> depthsPairs = _getDepthsPairs(coinsByTicker); + if (depthsPairs.isEmpty) return null; + + final cacheKey = _getDepthPairsCacheKey(depthsPairs); + final cached = _depthCache[cacheKey]; + if (cached != null && + DateTime.now().difference(cached.cachedAt) < _depthCacheTtl) { + return cached.depths; + } - List? orderBookDepths = - await _getNotEmptyDepths(depthsPairs); - if (orderBookDepths?.isEmpty ?? true) { - orderBookDepths = await _frequentRequestDepth(depthsPairs); + final inFlight = _depthInFlight[cacheKey]; + if (inFlight != null) { + return inFlight; } - return orderBookDepths; + final requestFuture = () async { + List? orderBookDepths = await _getNotEmptyDepths( + depthsPairs, + ); + if (orderBookDepths?.isEmpty ?? true) { + orderBookDepths = await _frequentRequestDepth(depthsPairs); + } + + if (orderBookDepths != null) { + _depthCache[cacheKey] = _DepthCacheEntry( + depths: orderBookDepths, + cachedAt: DateTime.now(), + ); + } + return orderBookDepths; + }(); + + _depthInFlight[cacheKey] = requestFuture; + try { + return await requestFuture; + } finally { + _depthInFlight.remove(cacheKey); + } } Future?> _frequentRequestDepth( List> depthsPairs, ) async { - int attempts = 5; + int attempts = 3; List? orderBookDepthsLocal; if (depthsPairs.isEmpty) { @@ -101,7 +136,7 @@ class BridgeRepository { return orderBookDepthsLocal; } attempts -= 1; - await Future.delayed(const Duration(milliseconds: 600)); + await Future.delayed(const Duration(milliseconds: 800)); } return null; } @@ -109,8 +144,8 @@ class BridgeRepository { Future?> _getNotEmptyDepths( List> pairs, ) async { - final OrderBookDepthResponse? depthResponse = - await _mm2Api.getOrderBookDepth(pairs, _coinsRepository); + final OrderBookDepthResponse? depthResponse = await _mm2Api + .getOrderBookDepth(pairs, _coinsRepository); return depthResponse?.list .where((d) => d.bids != 0 || d.asks != 0) @@ -118,13 +153,10 @@ class BridgeRepository { } List> _getDepthsPairs(CoinsByTicker coins) { - return coins.values.fold>>( - [], - (previousValue, entry) { - previousValue.addAll(_createPairs(entry)); - return previousValue; - }, - ); + return coins.values.fold>>([], (previousValue, entry) { + previousValue.addAll(_createPairs(entry)); + return previousValue; + }); } List> _createPairs(List group) { @@ -138,4 +170,19 @@ class BridgeRepository { } return pairs; } + + String _getDepthPairsCacheKey(List> pairs) { + final normalizedPairs = pairs.map((pair) { + final sorted = List.of(pair)..sort(); + return '${sorted[0]}/${sorted[1]}'; + }).toList()..sort(); + return normalizedPairs.join('|'); + } +} + +class _DepthCacheEntry { + _DepthCacheEntry({required this.depths, required this.cachedAt}); + + final List depths; + final DateTime cachedAt; } diff --git a/lib/bloc/bridge_form/bridge_validator.dart b/lib/bloc/bridge_form/bridge_validator.dart index 75839d200e..d7290bf15a 100644 --- a/lib/bloc/bridge_form/bridge_validator.dart +++ b/lib/bloc/bridge_form/bridge_validator.dart @@ -16,6 +16,7 @@ import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/data_from_service.dart'; import 'package:web_dex/model/dex_form_error.dart'; import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; import 'package:web_dex/model/trade_preimage.dart'; import 'package:web_dex/shared/utils/formatters.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -87,6 +88,10 @@ class BridgeValidator { ); } else if (error is TradePreimageTransportError) { return DexFormError(error: LocaleKeys.notEnoughBalanceForGasError.tr()); + } else if (error is TradePreimageNoSuchCoinError) { + return DexFormError( + error: LocaleKeys.connectionToServersFailing.tr(args: [error.coin]), + ); } else if (error is TradePreimageVolumeTooLowError) { return DexFormError( error: LocaleKeys.lowTradeVolumeError.tr( @@ -135,7 +140,10 @@ class BridgeValidator { isError: true, ); return DataFromService( - error: TextError(error: 'Failed to request trade preimage'), + error: TextError( + error: formatKdfUserFacingError(e), + technicalDetails: extractKdfTechnicalDetails(e), + ), ); } } diff --git a/lib/bloc/cex_market_data/cache_constants.dart b/lib/bloc/cex_market_data/cache_constants.dart new file mode 100644 index 0000000000..82d76ec6d5 --- /dev/null +++ b/lib/bloc/cex_market_data/cache_constants.dart @@ -0,0 +1,11 @@ +// Hive typeIds 500-504 are reserved for app-local market-data caches. +const int profitLossCacheAdapterTypeId = 500; +const int profitLossAdapterTypeId = 501; +const int fiatValueAdapterTypeId = 502; +const int graphCacheAdapterTypeId = 503; +const int pointAdapterTypeId = 504; + +const String profitLossCacheBoxName = 'profit_loss_v2'; +const String mockProfitLossCacheBoxName = 'mock_profit_loss_v2'; +const String balanceGrowthCacheBoxName = 'balance_growth_v2'; +const String mockBalanceGrowthCacheBoxName = 'mock_balance_growth_v2'; diff --git a/lib/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart b/lib/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart index c345e38455..3b17cfd048 100644 --- a/lib/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart +++ b/lib/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart @@ -9,7 +9,7 @@ class UpdateFrequencyBackoffStrategy { this.maxInterval = const Duration(hours: 1), }); - /// The base interval for the first attempts (default: 2 minutes) + /// The base interval for the first attempts (default: 1 minute) final Duration baseInterval; /// The maximum interval to backoff to (default: 1 hour) diff --git a/lib/bloc/cex_market_data/mockup/mock_portfolio_growth_repository.dart b/lib/bloc/cex_market_data/mockup/mock_portfolio_growth_repository.dart index 5bd32af820..f873c0e2a5 100644 --- a/lib/bloc/cex_market_data/mockup/mock_portfolio_growth_repository.dart +++ b/lib/bloc/cex_market_data/mockup/mock_portfolio_growth_repository.dart @@ -1,9 +1,9 @@ import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; +import 'package:web_dex/bloc/cex_market_data/cache_constants.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/generator.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/mock_transaction_history_repository.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; import 'package:web_dex/bloc/cex_market_data/models/graph_cache.dart'; -import 'package:web_dex/bloc/cex_market_data/models/graph_type.dart'; import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart'; class MockPortfolioGrowthRepository extends PortfolioGrowthRepository { @@ -25,7 +25,7 @@ class MockPortfolioGrowthRepository extends PortfolioGrowthRepository { demoDataGenerator: DemoDataCache.withDefaults(sdk), ), cacheProvider: HiveLazyBoxProvider( - name: GraphType.balanceGrowth.tableName, + name: mockBalanceGrowthCacheBoxName, ), ); diff --git a/lib/bloc/cex_market_data/models/adapters/graph_cache_adapter.dart b/lib/bloc/cex_market_data/models/adapters/graph_cache_adapter.dart index adaca984dc..e4fc2c89ee 100644 --- a/lib/bloc/cex_market_data/models/adapters/graph_cache_adapter.dart +++ b/lib/bloc/cex_market_data/models/adapters/graph_cache_adapter.dart @@ -1,12 +1,13 @@ import 'dart:math'; -import 'package:hive/hive.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:web_dex/bloc/cex_market_data/cache_constants.dart'; import 'package:web_dex/bloc/cex_market_data/models/graph_cache.dart'; import 'package:web_dex/bloc/cex_market_data/models/graph_type.dart'; class GraphCacheAdapter extends TypeAdapter { @override - final int typeId = 17; + final int typeId = graphCacheAdapterTypeId; @override GraphCache read(BinaryReader reader) { diff --git a/lib/bloc/cex_market_data/models/adapters/point_adapter.dart b/lib/bloc/cex_market_data/models/adapters/point_adapter.dart index e5c9318cf0..618ca3c43b 100644 --- a/lib/bloc/cex_market_data/models/adapters/point_adapter.dart +++ b/lib/bloc/cex_market_data/models/adapters/point_adapter.dart @@ -1,10 +1,11 @@ import 'dart:math'; -import 'package:hive/hive.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:web_dex/bloc/cex_market_data/cache_constants.dart'; class PointAdapter extends TypeAdapter> { @override - final int typeId = 18; + final int typeId = pointAdapterTypeId; @override Point read(BinaryReader reader) { @@ -12,10 +13,7 @@ class PointAdapter extends TypeAdapter> { final Map fields = { for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), }; - return Point( - fields[0] as double, - fields[1] as double, - ); + return Point(fields[0] as double, fields[1] as double); } @override diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart index 339021156b..93aa64c2c7 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart @@ -14,6 +14,7 @@ import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; part 'portfolio_growth_event.dart'; part 'portfolio_growth_state.dart'; @@ -165,7 +166,6 @@ class PortfolioGrowthBloc filteredEventCoins, delay: kActivationPollingInterval, ); - // Only remove inactivate/activating coins after an attempt to load the // cached chart, as the cached chart may contain inactive coins. await _loadChart( @@ -196,9 +196,9 @@ class PortfolioGrowthBloc PortfolioGrowthLoadRequested event, { required bool useCache, }) async { - final activeCoins = await coins.removeInactiveCoins(_sdk); + final chartCoins = useCache ? coins : await coins.removeInactiveCoins(_sdk); final chart = await _portfolioGrowthRepository.getPortfolioGrowthChart( - activeCoins, + chartCoins, fiatCoinId: event.fiatCoinId, walletId: event.walletId, useCache: useCache, @@ -355,7 +355,10 @@ class PortfolioGrowthBloc ); emit( GrowthChartLoadFailure( - error: TextError(error: 'Failed to load portfolio growth'), + error: TextError( + error: formatKdfUserFacingError(error), + technicalDetails: extractKdfTechnicalDetails(error), + ), selectedPeriod: event.selectedPeriod, totalCoins: totalCoins, coinsWithKnownBalance: coinsWithKnownBalance, diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart index 602cb5613a..bc3658d190 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart @@ -1,7 +1,7 @@ import 'dart:math' show Point; import 'package:decimal/decimal.dart'; -import 'package:hive/hive.dart'; +import 'package:hive_ce/hive.dart'; import 'package:komodo_cex_market_data/komodo_cex_market_data.dart' show CoinOhlc, @@ -13,6 +13,7 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; import 'package:logging/logging.dart'; +import 'package:web_dex/bloc/cex_market_data/cache_constants.dart'; import 'package:web_dex/bloc/cex_market_data/charts.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/mock_portfolio_growth_repository.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; @@ -57,7 +58,7 @@ class PortfolioGrowthRepository { return PortfolioGrowthRepository( transactionHistoryRepo: transactionHistoryRepo, cacheProvider: HiveLazyBoxProvider( - name: GraphType.balanceGrowth.tableName, + name: balanceGrowthCacheBoxName, ), coinsRepository: coinsRepository, sdk: sdk, @@ -79,9 +80,12 @@ class PortfolioGrowthRepository { final _log = Logger('PortfolioGrowthRepository'); static Future ensureInitialized() async { - Hive - ..registerAdapter(GraphCacheAdapter()) - ..registerAdapter(PointAdapter()); + if (!Hive.isAdapterRegistered(graphCacheAdapterTypeId)) { + Hive.registerAdapter(GraphCacheAdapter()); + } + if (!Hive.isAdapterRegistered(pointAdapterTypeId)) { + Hive.registerAdapter(PointAdapter()); + } } /// Get the growth chart for a coin based on the transactions @@ -157,10 +161,26 @@ class PortfolioGrowthRepository { ); if (transactions.isEmpty) { - _log.fine('No transactions found for ${coin.id}, caching empty chart'); - // Insert an empty chart into the cache to avoid fetching transactions - // again for each invocation. The assumption is that this function is - // called later with useCache set to false to fetch the transactions again + _log.fine('No transactions found for ${coin.id}'); + + final String compoundKey = GraphCache.getPrimaryKey( + coinId: coinId.id, + fiatCoinId: fiatCoinId, + graphType: GraphType.balanceGrowth, + walletId: walletId, + isHdWallet: currentUser.isHd, + ); + final existingCache = await _graphCache.get(compoundKey); + if (existingCache != null && existingCache.graph.isNotEmpty) { + _log.fine( + 'Keeping existing non-empty cache for ${coin.id} ' + '(${existingCache.graph.length} points) ' + 'instead of overwriting with empty transactions', + ); + methodStopwatch.stop(); + return existingCache.graph; + } + final cacheInsertStopwatch = Stopwatch()..start(); await _graphCache.insert( GraphCache( diff --git a/lib/bloc/cex_market_data/profit_loss/demo_profit_loss_repository.dart b/lib/bloc/cex_market_data/profit_loss/demo_profit_loss_repository.dart index 96eddc0706..c9b6d2a9c1 100644 --- a/lib/bloc/cex_market_data/profit_loss/demo_profit_loss_repository.dart +++ b/lib/bloc/cex_market_data/profit_loss/demo_profit_loss_repository.dart @@ -1,5 +1,6 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; +import 'package:web_dex/bloc/cex_market_data/cache_constants.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/generator.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/mock_transaction_history_repository.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; @@ -19,7 +20,7 @@ class MockProfitLossRepository extends ProfitLossRepository { factory MockProfitLossRepository.withDefaults({ required PerformanceMode performanceMode, required KomodoDefiSdk sdk, - String cacheTableName = 'mock_profit_loss', + String cacheTableName = mockProfitLossCacheBoxName, }) { return MockProfitLossRepository( profitLossCacheProvider: HiveLazyBoxProvider( diff --git a/lib/bloc/cex_market_data/profit_loss/models/adapters/fiat_value_adapter.dart b/lib/bloc/cex_market_data/profit_loss/models/adapters/fiat_value_adapter.dart index 91bacca876..e97758380e 100644 --- a/lib/bloc/cex_market_data/profit_loss/models/adapters/fiat_value_adapter.dart +++ b/lib/bloc/cex_market_data/profit_loss/models/adapters/fiat_value_adapter.dart @@ -1,10 +1,11 @@ -import 'package:hive/hive.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:web_dex/bloc/cex_market_data/cache_constants.dart'; import '../fiat_value.dart'; class FiatValueAdapter extends TypeAdapter { @override - final int typeId = 16; + final int typeId = fiatValueAdapterTypeId; @override FiatValue read(BinaryReader reader) { @@ -12,10 +13,7 @@ class FiatValueAdapter extends TypeAdapter { final Map fields = { for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), }; - return FiatValue( - currency: fields[0] as String, - value: fields[1] as double, - ); + return FiatValue(currency: fields[0] as String, value: fields[1] as double); } @override diff --git a/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_adapter.dart b/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_adapter.dart index ad679f415d..b5bdd4e919 100644 --- a/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_adapter.dart +++ b/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_adapter.dart @@ -1,10 +1,11 @@ -import 'package:hive/hive.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:web_dex/bloc/cex_market_data/cache_constants.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/models/fiat_value.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/models/profit_loss.dart'; class ProfitLossAdapter extends TypeAdapter { @override - final int typeId = 15; + final int typeId = profitLossAdapterTypeId; @override ProfitLoss read(BinaryReader reader) { diff --git a/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_cache_adapter.dart b/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_cache_adapter.dart index 1153a2a0d4..2f065906d6 100644 --- a/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_cache_adapter.dart +++ b/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_cache_adapter.dart @@ -1,9 +1,10 @@ -import 'package:hive/hive.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:web_dex/bloc/cex_market_data/cache_constants.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/models/profit_loss_cache.dart'; class ProfitLossCacheAdapter extends TypeAdapter { @override - final int typeId = 14; + final int typeId = profitLossCacheAdapterTypeId; @override ProfitLossCache read(BinaryReader reader) { diff --git a/lib/bloc/cex_market_data/profit_loss/models/profit_loss.dart b/lib/bloc/cex_market_data/profit_loss/models/profit_loss.dart index e54c8e6b99..4e8acf0fc2 100644 --- a/lib/bloc/cex_market_data/profit_loss/models/profit_loss.dart +++ b/lib/bloc/cex_market_data/profit_loss/models/profit_loss.dart @@ -60,15 +60,15 @@ class ProfitLoss extends Equatable { factory ProfitLoss.fromJson(Map json) { return ProfitLoss( - profitLoss: (json['profit_loss'] as double?) ?? 0.0, + profitLoss: (json['profit_loss'] as num?)?.toDouble() ?? 0.0, coin: json['coin'] ?? '', fiatPrice: FiatValue.fromJson(json['fiat_value'] as Map), internalId: json['internal_id'] as String, - myBalanceChange: json['my_balance_change'] as double, - receivedAmountFiatPrice: json['received_by_me'] as double, - spentAmountFiatPrice: json['spent_by_me'] as double, + myBalanceChange: (json['my_balance_change'] as num).toDouble(), + receivedAmountFiatPrice: (json['received_by_me'] as num).toDouble(), + spentAmountFiatPrice: (json['spent_by_me'] as num).toDouble(), timestamp: DateTime.parse(json['timestamp']), - totalAmount: json['total_amount'] as double, + totalAmount: (json['total_amount'] as num).toDouble(), txHash: json['tx_hash'] as String, ); } @@ -135,15 +135,15 @@ class ProfitLoss extends Equatable { @override List get props => [ - profitLoss, - coin, - fiatPrice, - internalId, - myBalanceChange, - receivedAmountFiatPrice, - spentAmountFiatPrice, - timestamp, - totalAmount, - txHash, - ]; + profitLoss, + coin, + fiatPrice, + internalId, + myBalanceChange, + receivedAmountFiatPrice, + spentAmountFiatPrice, + timestamp, + totalAmount, + txHash, + ]; } diff --git a/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart b/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart index 540d7e4542..164ba41ef4 100644 --- a/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart @@ -15,6 +15,7 @@ import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; part 'profit_loss_event.dart'; part 'profit_loss_state.dart'; @@ -33,7 +34,10 @@ class ProfitLossBloc extends Bloc { _onLoadPortfolioProfitLoss, transformer: restartable(), ); - on(_onPortfolioPeriodChanged); + on( + _onPortfolioPeriodChanged, + transformer: restartable(), + ); on(_onClearPortfolioProfitLoss); } @@ -58,8 +62,6 @@ class ProfitLossBloc extends Bloc { final supportedCoins = await event.coins.filterSupportedCoins(); final filteredEventCoins = event.coins.withoutTestCoins(); final initialActiveCoins = await supportedCoins.removeInactiveCoins(_sdk); - // Charts for individual coins (coin details) are parsed here as well, - // and should be hidden if not supported. if (supportedCoins.isEmpty && filteredEventCoins.length <= 1) { return emit( PortfolioProfitLossChartUnsupported( @@ -75,8 +77,6 @@ class ProfitLossBloc extends Bloc { ).then(emit.call).catchError((Object error, StackTrace stackTrace) { const errorMessage = 'Failed to load CACHED portfolio profit/loss'; _log.warning(errorMessage, error, stackTrace); - // ignore cached errors, as the periodic refresh attempts should recover - // at the cost of a longer first loading time. }); // Fetch the un-cached version of the chart to update the cache. @@ -94,10 +94,6 @@ class ProfitLossBloc extends Bloc { useCache: false, ).then(emit.call).catchError((Object e, StackTrace s) { _log.severe('Failed to load uncached profit/loss chart', e, s); - // Ignore un-cached errors, as a transaction loading exception should not - // make the graph disappear with a load failure emit, as the cached data - // is already displayed. The periodic updates will still try to fetch the - // data and update the graph. }); } } catch (error, stackTrace) { @@ -237,6 +233,19 @@ class ProfitLossBloc extends Bloc { } /// Run periodic updates with exponential backoff strategy + bool _isStalePeriodicUpdate(Duration selectedPeriod, {String? stage}) { + if (state.selectedPeriod == selectedPeriod) { + return false; + } + + final stageInfo = stage == null ? '' : ' ($stage)'; + _log.fine( + 'Skipping stale profit/loss periodic update$stageInfo: ' + '$selectedPeriod -> ${state.selectedPeriod}.', + ); + return true; + } + Future _runPeriodicUpdates( ProfitLossPortfolioChartLoadRequested event, Emitter emit, @@ -246,6 +255,9 @@ class ProfitLossBloc extends Bloc { _log.fine('Stopping profit/loss periodic updates: bloc closed.'); break; } + if (_isStalePeriodicUpdate(event.selectedPeriod, stage: 'loop-start')) { + break; + } try { await Future.delayed(_backoffStrategy.getNextInterval()); @@ -255,20 +267,35 @@ class ProfitLossBloc extends Bloc { ); break; } + if (_isStalePeriodicUpdate(event.selectedPeriod, stage: 'post-delay')) { + break; + } final supportedCoins = await event.coins.filterSupportedCoins(); final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); + if (_isStalePeriodicUpdate(event.selectedPeriod, stage: 'pre-fetch')) { + break; + } final updatedChartState = await _getProfitLossChart( event, activeCoins, useCache: false, ); + if (_isStalePeriodicUpdate(event.selectedPeriod, stage: 'pre-emit')) { + break; + } emit(updatedChartState); } catch (error, stackTrace) { + if (_isStalePeriodicUpdate(event.selectedPeriod, stage: 'error')) { + break; + } _log.shout('Failed to load portfolio profit/loss', error, stackTrace); emit( ProfitLossLoadFailure( - error: TextError(error: 'Failed to load portfolio profit/loss'), + error: TextError( + error: formatKdfUserFacingError(error), + technicalDetails: extractKdfTechnicalDetails(error), + ), selectedPeriod: event.selectedPeriod, ), ); diff --git a/lib/bloc/cex_market_data/profit_loss/profit_loss_repository.dart b/lib/bloc/cex_market_data/profit_loss/profit_loss_repository.dart index 05561e6fd7..061e091515 100644 --- a/lib/bloc/cex_market_data/profit_loss/profit_loss_repository.dart +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_repository.dart @@ -1,11 +1,12 @@ import 'dart:async'; import 'dart:math'; -import 'package:hive/hive.dart'; +import 'package:hive_ce/hive.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; import 'package:logging/logging.dart'; +import 'package:web_dex/bloc/cex_market_data/cache_constants.dart'; import 'package:web_dex/bloc/cex_market_data/charts.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/demo_profit_loss_repository.dart'; @@ -33,7 +34,7 @@ class ProfitLossRepository { factory ProfitLossRepository.withDefaults({ required TransactionHistoryRepo transactionHistoryRepo, required KomodoDefiSdk sdk, - String cacheTableName = 'profit_loss', + String cacheTableName = profitLossCacheBoxName, PerformanceMode? demoMode, }) { if (demoMode != null) { @@ -62,10 +63,15 @@ class ProfitLossRepository { final _log = Logger('profit-loss-repository'); static Future ensureInitialized() async { - Hive - ..registerAdapter(FiatValueAdapter()) - ..registerAdapter(ProfitLossAdapter()) - ..registerAdapter(ProfitLossCacheAdapter()); + if (!Hive.isAdapterRegistered(fiatValueAdapterTypeId)) { + Hive.registerAdapter(FiatValueAdapter()); + } + if (!Hive.isAdapterRegistered(profitLossAdapterTypeId)) { + Hive.registerAdapter(ProfitLossAdapter()); + } + if (!Hive.isAdapterRegistered(profitLossCacheAdapterTypeId)) { + Hive.registerAdapter(ProfitLossCacheAdapter()); + } } Future clearCache() async { @@ -157,7 +163,24 @@ class ProfitLossRepository { ); if (transactions.isEmpty) { - _log.fine('No transactions found for ${coinId.id}, caching empty result'); + _log.fine('No transactions found for ${coinId.id}'); + + final String compoundKey = ProfitLossCache.getPrimaryKey( + coinId: coinId.id, + fiatCurrency: fiatCoinId, + walletId: walletId, + isHdWallet: currentUser.isHd, + ); + final existingCache = await _profitLossCacheProvider.get(compoundKey); + if (existingCache != null && existingCache.profitLosses.isNotEmpty) { + _log.fine( + 'Keeping existing non-empty cache for ${coinId.id} ' + '(${existingCache.profitLosses.length} entries) ' + 'instead of overwriting with empty transactions', + ); + methodStopwatch.stop(); + return existingCache.profitLosses; + } final cacheInsertStopwatch = Stopwatch()..start(); await _profitLossCacheProvider.insert( diff --git a/lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart b/lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart index 68d643f7af..9d9b4aa6ee 100644 --- a/lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart +++ b/lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart @@ -1,14 +1,17 @@ import 'dart:async'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; -import 'package:komodo_defi_types/komodo_defi_types.dart' - show Asset, NewAddressStatus, AssetPubkeys; +import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/analytics/events.dart'; import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; import 'package:web_dex/bloc/coin_addresses/bloc/coin_addresses_event.dart'; import 'package:web_dex/bloc/coin_addresses/bloc/coin_addresses_state.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; class CoinAddressesBloc extends Bloc { final KomodoDefiSdk sdk; @@ -82,7 +85,9 @@ class CoinAddressesBloc extends Bloc { emit( state.copyWith( createAddressStatus: () => FormStatus.failure, - errorMessage: () => newAddressState.error, + errorMessage: () => _buildDisplayError( + newAddressState.error ?? LocaleKeys.somethingWrong.tr(), + ), newAddressState: () => null, ), ); @@ -104,7 +109,7 @@ class CoinAddressesBloc extends Bloc { emit( state.copyWith( createAddressStatus: () => FormStatus.failure, - errorMessage: () => e.toString(), + errorMessage: () => _buildDisplayError(e), newAddressState: () => null, ), ); @@ -139,7 +144,7 @@ class CoinAddressesBloc extends Bloc { emit( state.copyWith( status: () => FormStatus.failure, - errorMessage: () => e.toString(), + errorMessage: () => _buildDisplayError(e), ), ); } @@ -168,7 +173,7 @@ class CoinAddressesBloc extends Bloc { ), ); } catch (e) { - emit(state.copyWith(errorMessage: () => e.toString())); + emit(state.copyWith(errorMessage: () => _buildDisplayError(e))); } } @@ -203,17 +208,79 @@ class CoinAddressesBloc extends Bloc { }, onError: (Object err) { if (!isClosed) { - add(CoinAddressesPubkeysSubscriptionFailed(err.toString())); + add( + CoinAddressesPubkeysSubscriptionFailed( + _buildDisplayError(err), + ), + ); } }, ); } catch (e) { if (!isClosed) { - add(CoinAddressesPubkeysSubscriptionFailed(e.toString())); + add(CoinAddressesPubkeysSubscriptionFailed(_buildDisplayError(e))); } } } + String _buildDisplayError(Object error) { + if (_isNetworkLikeError(error)) { + return LocaleKeys.connectionToServersFailing.tr(args: [assetId]); + } + + return formatKdfUserFacingError(error); + } + + bool _isNetworkLikeError(Object error) { + if (error is SdkError) { + return error.category == SdkErrorCategory.network; + } + + if (error is MmRpcException) { + const networkErrorTypes = { + 'Transport', + 'Timeout', + 'TaskTimedOut', + 'UnreachableNodes', + 'ClientConnectionFailed', + 'ConnectToNodeError', + }; + if (networkErrorTypes.contains(error.errorType)) { + return true; + } + return _containsNetworkMarkers( + '${error.message ?? ''} ${error.path ?? ''}', + ); + } + + if (error is GeneralErrorResponse) { + const networkErrorTypes = { + 'Transport', + 'Timeout', + 'TaskTimedOut', + 'UnreachableNodes', + 'ClientConnectionFailed', + 'ConnectToNodeError', + }; + if (error.errorType != null && + networkErrorTypes.contains(error.errorType)) { + return true; + } + return _containsNetworkMarkers(error.error ?? ''); + } + + return _containsNetworkMarkers(error.toString()); + } + + bool _containsNetworkMarkers(String input) { + final normalized = input.toLowerCase(); + return normalized.contains('failed to fetch') || + normalized.contains('network') || + normalized.contains('connection') || + normalized.contains('timeout') || + normalized.contains('unreachable'); + } + @override Future close() async { await _pubkeysSub?.cancel(); diff --git a/lib/bloc/coins_bloc/asset_coin_extension.dart b/lib/bloc/coins_bloc/asset_coin_extension.dart index 9e8db56af9..1eed26da30 100644 --- a/lib/bloc/coins_bloc/asset_coin_extension.dart +++ b/lib/bloc/coins_bloc/asset_coin_extension.dart @@ -24,6 +24,7 @@ extension AssetCoinExtension on Asset { platform: id.parentId?.id ?? platform ?? '', contractAddress: contractAddress ?? '', ); + final explorerPattern = protocol.explorerPattern; return Coin( type: protocol.subClass.toCoinType(), @@ -32,10 +33,9 @@ extension AssetCoinExtension on Asset { name: id.name, logoImageUrl: logoImageUrl ?? '', isCustomCoin: isCustomToken, - explorerUrl: config.valueOrNull('explorer_url') ?? '', - explorerTxUrl: config.valueOrNull('explorer_tx_url') ?? '', - explorerAddressUrl: - config.valueOrNull('explorer_address_url') ?? '', + explorerUrl: explorerPattern.baseUrl?.toString() ?? '', + explorerTxUrl: explorerPattern.txPattern ?? '', + explorerAddressUrl: explorerPattern.addressPattern ?? '', protocolType: protocol.subClass.ticker, protocolData: protocolData, isTestCoin: protocol.isTestnet, @@ -68,6 +68,10 @@ extension AssetCoinExtension on Asset { extension CoinTypeExtension on CoinSubClass { CoinType toCoinType() { switch (this) { + case CoinSubClass.trx: + return CoinType.trx; + case CoinSubClass.trc20: + return CoinType.trc20; case CoinSubClass.base: return CoinType.base20; case CoinSubClass.ftm20: @@ -109,7 +113,7 @@ extension CoinTypeExtension on CoinSubClass { case CoinSubClass.erc20: return CoinType.erc20; case CoinSubClass.grc20: - return CoinType.erc20; + return CoinType.grc20; case CoinSubClass.krc20: return CoinType.krc20; case CoinSubClass.zhtlc: @@ -128,6 +132,9 @@ extension CoinTypeExtension on CoinSubClass { switch (this) { case CoinSubClass.base: return true; + case CoinSubClass.trx: + case CoinSubClass.trc20: + return false; case CoinSubClass.avx20: case CoinSubClass.bep20: case CoinSubClass.ftm20: @@ -158,6 +165,10 @@ extension CoinTypeExtension on CoinSubClass { extension CoinSubClassExtension on CoinType { CoinSubClass toCoinSubClass() { switch (this) { + case CoinType.trx: + return CoinSubClass.trx; + case CoinType.trc20: + return CoinSubClass.trc20; case CoinType.base20: return CoinSubClass.base; case CoinType.ftm20: @@ -198,6 +209,8 @@ extension CoinSubClassExtension on CoinType { return CoinSubClass.smartBch; case CoinType.erc20: return CoinSubClass.erc20; + case CoinType.grc20: + return CoinSubClass.grc20; case CoinType.krc20: return CoinSubClass.krc20; case CoinType.zhtlc: diff --git a/lib/bloc/coins_bloc/coins_bloc.dart b/lib/bloc/coins_bloc/coins_bloc.dart index 5653c61c90..c81a8569d2 100644 --- a/lib/bloc/coins_bloc/coins_bloc.dart +++ b/lib/bloc/coins_bloc/coins_bloc.dart @@ -13,7 +13,6 @@ import 'package:web_dex/bloc/trading_status/trading_status_service.dart'; import 'package:web_dex/model/cex_price.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/wallet.dart'; -import 'package:web_dex/shared/utils/utils.dart'; part 'coins_event.dart'; part 'coins_state.dart'; @@ -178,7 +177,7 @@ class CoinsBloc extends Bloc { final coin = event.coin; final walletCoins = Map.of(state.walletCoins); - if (coin.isInactive) { + if (coin.isInactive || coin.isSuspended) { walletCoins.remove(coin.id.id); emit(state.copyWith(walletCoins: walletCoins)); return; @@ -208,9 +207,16 @@ class CoinsBloc extends Bloc { // Preserve persistent state fields such as activation state final merged = updated.copyWith(state: existing.state); + final walletCoins = Map.of(state.walletCoins); + if (merged.isActive || merged.isActivating) { + walletCoins[assetId] = merged; + } else { + walletCoins.remove(assetId); + } + emit( state.copyWith( - walletCoins: {...state.walletCoins, assetId: merged}, + walletCoins: walletCoins, coins: {...state.coins, assetId: merged}, ), ); @@ -228,10 +234,16 @@ class CoinsBloc extends Bloc { Emitter emit, ) async { _updateBalancesTimer?.cancel(); - _updateBalancesTimer = Timer.periodic(const Duration(minutes: 1), (timer) { + _updateBalancesTimer = Timer.periodic(const Duration(minutes: 3), (timer) { + final missingWatcherCount = _coinsRepo + .countMissingBalanceWatchersForActiveWalletCoins(state.walletCoins); + if (missingWatcherCount == 0) { + return; + } if (kDebugElectrumLogs) { _log.info( - '[POLLING] Triggering periodic balance refresh (every 1 minute)', + '[POLLING] Triggering fallback balance refresh (every 3 minutes) ' + 'for $missingWatcherCount active assets without live watchers', ); } add(CoinsBalancesRefreshed()); @@ -313,11 +325,15 @@ class CoinsBloc extends Bloc { Emitter emit, ) async { try { - final prices = await _coinsRepo.fetchCurrentPrices(); - if (prices == null) { + final fetchedPrices = await _coinsRepo.fetchCurrentPrices(); + if (fetchedPrices == null) { _log.severe('Coin prices list empty/null'); return; } + + final prices = Map.unmodifiable( + Map.from(fetchedPrices), + ); final didPricesChange = !const MapEquality().equals(state.prices, prices); if (!didPricesChange) { _log.info('Coin prices list unchanged'); @@ -328,20 +344,19 @@ class CoinsBloc extends Bloc { final map = coins.map((key, coin) { // Use configSymbol to lookup for backwards compatibility with the old, // string-based price list (and fallback) - final price = prices[coin.id.symbol.configSymbol]; + final price = prices[coin.id.symbol.configSymbol.toUpperCase()]; if (price != null) { return MapEntry(key, coin.copyWith(usdPrice: price)); } return MapEntry(key, coin); }); - // .map already returns a new map, so we don't need to create a new map - return map.unmodifiable(); + return Map.unmodifiable(map); } emit( state.copyWith( - prices: prices.unmodifiable(), + prices: prices, coins: updateCoinsWithPrices(state.coins), walletCoins: updateCoinsWithPrices(state.walletCoins), ), @@ -536,8 +551,19 @@ class CoinsBloc extends Bloc { ); } + // Batch-write all asset IDs to wallet metadata in a single call before + // launching parallel activations. This avoids N concurrent read-modify-write + // cycles on the same metadata key which caused last-write-wins data loss. + await _coinsRepo.addAssetsToWalletMetadata( + coinsToActivate.map((asset) => asset.id), + ); + final enableFutures = coinsToActivate - .map((asset) => _coinsRepo.activateAssetsSync([asset])) + .map( + (asset) => _coinsRepo.activateAssetsSync([ + asset, + ], addToWalletMetadata: false), + ) .toList(); // Ignore the return type here and let the broadcast handle the state updates as @@ -560,8 +586,9 @@ class CoinsBloc extends Bloc { } try { - final saved = - await _kdfSdk.activationConfigService.getSavedZhtlc(asset.id); + final saved = await _kdfSdk.activationConfigService.getSavedZhtlc( + asset.id, + ); if (saved != null) { filtered.add(asset); } else { diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index f0fd2830da..3637e33834 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -21,11 +21,13 @@ import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/mm2/mm2.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/mm2/mm2_api/rpc/bloc_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/disable_coin/disable_coin_req.dart'; import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_errors.dart'; import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_request.dart'; import 'package:web_dex/model/cex_price.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; +import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/model/withdraw_details/withdraw_details.dart'; import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; @@ -66,6 +68,8 @@ class CoinsRepo { final ArrrActivationService _arrrActivationService; final _log = Logger('CoinsRepo'); + static const _unsupportedTrezorSiaMessage = + 'SIA is not supported for Trezor wallets in this release.'; /// { acc: { abbr: address }}, used in Fiat Page final Map> _addressCache = {}; @@ -81,6 +85,23 @@ class CoinsRepo { // Map to keep track of active balance watchers final Map> _balanceWatchers = {}; + bool get hasActiveBalanceWatchers => _balanceWatchers.isNotEmpty; + + bool hasMissingBalanceWatchersForActiveWalletCoins( + Map walletCoins, + ) { + return countMissingBalanceWatchersForActiveWalletCoins(walletCoins) > 0; + } + + int countMissingBalanceWatchersForActiveWalletCoins( + Map walletCoins, + ) { + final activeAssetIds = walletCoins.values + .where((coin) => coin.isActive) + .map((coin) => coin.id) + .toSet(); + return _kdfSdk.balances.countMissingWatchersForAssets(activeAssetIds); + } /// Hack used to broadcast activated/deactivated coins to the CoinsBloc to /// update the status of the coins in the UI layer. This is needed as there @@ -122,27 +143,56 @@ class CoinsRepo { /// Subscribe to balance updates for an asset using the SDK's balance manager void _subscribeToBalanceUpdates(Asset asset) { + final assetId = asset.id; + // Cancel any existing subscription for this asset - _balanceWatchers[asset.id]?.cancel(); + _balanceWatchers[assetId]?.cancel(); + _balanceWatchers.remove(assetId); - if (_tradingStatusService.isAssetBlocked(asset.id)) { - _log.info('Asset ${asset.id.id} is blocked. Skipping balance updates.'); + if (_tradingStatusService.isAssetBlocked(assetId)) { + _log.info('Asset ${assetId.id} is blocked. Skipping balance updates.'); return; } + StreamSubscription? watcher; + // Start a new subscription - _balanceWatchers[asset.id] = _kdfSdk.balances.watchBalance(asset.id).listen( - (balanceInfo) { - // Update the balance cache with the new values - _balancesCache[asset.id.id] = ( - balance: balanceInfo.total.toDouble(), - spendable: balanceInfo.spendable.toDouble(), - ); + watcher = _kdfSdk.balances + .watchBalance(assetId) + .listen( + (balanceInfo) { + // Update the balance cache with the new values + _balancesCache[assetId.id] = ( + balance: balanceInfo.total.toDouble(), + spendable: balanceInfo.spendable.toDouble(), + ); - // Broadcast updated coin for UI to refresh via bloc - _broadcastBalanceChange(_assetToCoinWithoutAddress(asset)); - }, - ); + // Broadcast updated coin for UI to refresh via bloc + _broadcastBalanceChange(_assetToCoinWithoutAddress(asset)); + }, + onError: (Object error, StackTrace stackTrace) { + _log.warning( + 'Balance watcher failed for ${assetId.id}; fallback polling will cover this asset', + error, + stackTrace, + ); + final current = _balanceWatchers[assetId]; + if (watcher != null && identical(current, watcher)) { + _balanceWatchers.remove(assetId); + } + }, + onDone: () { + _log.info( + 'Balance watcher ended for ${assetId.id}; fallback polling will cover this asset', + ); + final current = _balanceWatchers[assetId]; + if (watcher != null && identical(current, watcher)) { + _balanceWatchers.remove(assetId); + } + }, + cancelOnError: true, + ); + _balanceWatchers[assetId] = watcher; } void flushCache() { @@ -336,6 +386,33 @@ class CoinsRepo { return; } + final walletType = (await _kdfSdk.currentWallet())?.config.type; + if (walletType == WalletType.trezor) { + final unsupportedSiaAssets = assets.where( + (asset) => asset.id.subClass == CoinSubClass.sia, + ); + if (unsupportedSiaAssets.isNotEmpty) { + _log.warning( + 'Skipping unsupported Trezor SIA activation for ' + '${unsupportedSiaAssets.map((a) => a.id.id).join(', ')}: ' + '$_unsupportedTrezorSiaMessage', + ); + for (final siaAsset in unsupportedSiaAssets) { + _broadcastAsset( + _assetToCoinWithoutAddress( + siaAsset, + ).copyWith(state: CoinState.suspended), + ); + } + } + assets = assets + .where((asset) => asset.id.subClass != CoinSubClass.sia) + .toList(); + if (assets.isEmpty) { + return; + } + } + // Debug logging for activation if (kDebugElectrumLogs) { final coinIdList = assets.map((e) => e.id.id).join(', '); @@ -394,10 +471,12 @@ class CoinsRepo { for (final asset in assets) { final coin = _assetToCoinWithoutAddress(asset); try { - // Check if asset is already activated to avoid SDK exception. - // The SDK throws an exception when trying to activate an already-activated - // asset, so we need this manual check to prevent unnecessary retries. - final isAlreadyActivated = await isAssetActivated(asset.id); + // Force-refresh activation state here to avoid racing on stale cache + // reads before attempting a coordinated activation. + final isAlreadyActivated = await isAssetActivated( + asset.id, + forceRefresh: true, + ); if (isAlreadyActivated) { _log.info( @@ -411,12 +490,9 @@ class CoinsRepo { // Use retry with exponential backoff for activation await retry( () async { - final progress = await _kdfSdk.assets.activateAsset(asset).last; - if (!progress.isSuccess) { - throw Exception( - progress.errorMessage ?? - 'Activation failed for ${asset.id.id}', - ); + final didActivate = await _kdfSdk.ensureAssetActivated(asset); + if (!didActivate) { + throw Exception('Activation failed for ${asset.id.id}'); } }, maxAttempts: maxRetryAttempts, @@ -506,13 +582,18 @@ class CoinsRepo { } } + /// Adds the given assets (and their parent coins) to wallet metadata. + /// + /// This is exposed so callers can batch-write metadata before launching + /// parallel activations with `addToWalletMetadata: false`. + Future addAssetsToWalletMetadata(Iterable assets) => + _addAssetsToWalletMetdata(assets); + Future _addAssetsToWalletMetdata(Iterable assets) async { - final parentIds = {}; - for (final assetId in assets) { - if (assetId.parentId != null) { - parentIds.add(assetId.parentId!.id); - } - } + final parentIds = assets + .where((assetId) => assetId.parentId != null) + .map((assetId) => assetId.parentId!.id) + .toSet(); if (assets.isNotEmpty || parentIds.isNotEmpty) { final allIdsToAdd = {...assets.map((e) => e.id), ...parentIds}; @@ -605,10 +686,22 @@ class CoinsRepo { allCoinIds.addAll(children.map((child) => child.id.id)); } + final Future removeMetadataFuture; if (allCoinIds.isNotEmpty) { - // assume success here, so we don't await this call and - // block the deactivation process - unawaited(_kdfSdk.removeActivatedCoins(allCoinIds.toList())); + // Keep metadata in sync so disabled coins do not re-enable on login. + removeMetadataFuture = () async { + try { + await _kdfSdk.removeActivatedCoins(allCoinIds.toList()); + } catch (e, s) { + _log.warning( + 'Failed to update wallet metadata for deactivated coins', + e, + s, + ); + } + }(); + } else { + removeMetadataFuture = Future.value(); } final parentCancelFutures = coins.map((coin) async { @@ -639,21 +732,114 @@ class CoinsRepo { }), ]; await Future.wait(deactivationTasks); - await Future.wait([...parentCancelFutures, ...childCancelFutures]); + await Future.wait([ + ...parentCancelFutures, + ...childCancelFutures, + removeMetadataFuture, + ]); _invalidateActivatedAssetsCache(); } - double? getUsdPriceByAmount(String amount, String coinAbbr) { + /// Performs a full rollback for preview-only asset activations. + /// + /// Unlike [deactivateCoinsSync], this disables the assets in MM2 so + /// temporary preview activations do not remain active for the rest of the + /// session. This should only be used for short-lived preview flows where a + /// real rollback is required. + Future rollbackPreviewAssets( + Iterable assets, { + Set deleteCustomTokens = const {}, + Set removeWalletMetadataAssets = const {}, + bool notifyListeners = false, + }) async { + final uniqueAssets = Map.fromEntries( + assets.map((asset) => MapEntry(asset.id, asset)), + ); + if (uniqueAssets.isEmpty) { + return; + } + + final orderedAssets = uniqueAssets.values.toList() + ..sort((a, b) { + final aPriority = a.id.parentId == null ? 1 : 0; + final bPriority = b.id.parentId == null ? 1 : 0; + return aPriority.compareTo(bPriority); + }); + + for (final asset in orderedAssets) { + await _balanceWatchers[asset.id]?.cancel(); + _balanceWatchers.remove(asset.id); + + try { + if (await isAssetActivated(asset.id, forceRefresh: true)) { + await _mm2.call(DisableCoinReq(coin: asset.id.id)); + } + } catch (e, s) { + _log.warning('Failed to disable preview asset ${asset.id.id}', e, s); + } + + if (notifyListeners) { + _broadcastAsset(asset.toCoin().copyWith(state: CoinState.inactive)); + } + } + + if (removeWalletMetadataAssets.isNotEmpty) { + try { + await _kdfSdk.removeActivatedCoins( + removeWalletMetadataAssets.map((assetId) => assetId.id).toList(), + ); + } catch (e, s) { + _log.warning( + 'Failed to remove preview assets from wallet metadata', + e, + s, + ); + } + } + + for (final assetId in deleteCustomTokens) { + try { + await _kdfSdk.deleteCustomToken(assetId); + } catch (e, s) { + _log.warning('Failed to delete preview custom token $assetId', e, s); + } + } + + _invalidateActivatedAssetsCache(); + } + + /// Calculates USD value for a numeric [amount] of [coinAbbr]. + /// + /// Prefer this method over [getUsdPriceByAmount] to avoid string parsing + /// issues (e.g. accidentally passing display-formatted values like + /// `"1.1 TRX"`). + double? getUsdPriceForAmount(num amount, String coinAbbr) { final Coin? coin = getCoin(coinAbbr); - final double? parsedAmount = double.tryParse(amount); + final double parsedAmount = amount.toDouble(); final double? usdPrice = coin?.usdPrice?.price?.toDouble(); - if (coin == null || usdPrice == null || parsedAmount == null) { + if (coin == null || usdPrice == null) { return null; } return parsedAmount * usdPrice; } + @Deprecated( + 'Use getUsdPriceForAmount(num amount, String coinAbbr) to avoid ' + 'string-parsing bugs from display-formatted values.', + ) + double? getUsdPriceByAmount(String amount, String coinAbbr) { + final double? parsedAmount = double.tryParse(amount); + if (parsedAmount == null) { + _log.warning( + 'Invalid amount "$amount" passed to getUsdPriceByAmount for $coinAbbr. ' + 'Use getUsdPriceForAmount() with a numeric value.', + ); + return null; + } + return getUsdPriceForAmount(parsedAmount, coinAbbr); + } + /// Fetches current prices for a broad set of assets /// /// This method is used to fetch prices for a broad set of assets so unauthenticated users @@ -688,7 +874,9 @@ class CoinsRepo { // Process assets with bounded parallelism to avoid overwhelming providers await _fetchAssetPricesInChunks(validAssets); - return _pricesCache; + return Map.unmodifiable( + Map.from(_pricesCache), + ); } /// Processes assets in chunks with bounded parallelism to avoid @@ -994,7 +1182,7 @@ class CoinsRepo { _log.severe('Error activating ZHTLC asset ${asset.id.id}', e, s); // Broadcast suspended state if requested - if (notifyListeners) { + if (notifyListeners && e is! ZhtlcActivationCancelled) { _broadcastAsset(coin.copyWith(state: CoinState.suspended)); } diff --git a/lib/bloc/coins_bloc/coins_state.dart b/lib/bloc/coins_bloc/coins_state.dart index 4037ac451d..62266988b2 100644 --- a/lib/bloc/coins_bloc/coins_state.dart +++ b/lib/bloc/coins_bloc/coins_state.dart @@ -9,15 +9,15 @@ class CoinsState extends Equatable { required Map walletCoins, required this.pubkeys, required this.prices, - }) : coins = _filterExcludedAssets(coins), - walletCoins = _filterExcludedAssets(walletCoins); + }) : coins = _filterExcludedAssets(coins), + walletCoins = _filterExcludedAssets(walletCoins); factory CoinsState.initial() => CoinsState( - coins: const {}, - walletCoins: const {}, - pubkeys: const {}, - prices: const {}, - ); + coins: const {}, + walletCoins: const {}, + pubkeys: const {}, + prices: const {}, + ); final Map coins; final Map walletCoins; @@ -25,8 +25,7 @@ class CoinsState extends Equatable { final Map prices; @override - List get props => - [coins, walletCoins, pubkeys, prices]; + List get props => [coins, walletCoins, pubkeys, prices]; /// Creates a copy of the current state with the option to update /// specific fields. @@ -41,10 +40,12 @@ class CoinsState extends Equatable { // Filtering is required to avoid including "NFT_*" assets in the coins // or walletCoins maps. The user should not see these assets, as they are // only needed to support the NFT feature. - final walletCoinsWithoutExcludedCoins = - _filterExcludedAssets(walletCoins ?? this.walletCoins); - final coinsWithoutExcludedCoins = - _filterExcludedAssets(coins ?? this.coins); + final walletCoinsWithoutExcludedCoins = _filterExcludedAssets( + walletCoins ?? this.walletCoins, + ); + final coinsWithoutExcludedCoins = _filterExcludedAssets( + coins ?? this.coins, + ); return CoinsState( coins: coinsWithoutExcludedCoins, @@ -63,7 +64,11 @@ class CoinsState extends Equatable { ); } - /// Gets the price for a given asset ID + /// CEX quote for [assetId] from [prices] (keys: uppercased [AssetSymbol.configSymbol]). + /// + /// Distinct from [KomodoDefiSdk.marketData] spot pricing: wallet chrome totals use this + /// feed with SDK balances (`computeWalletTotalUsd`); sorting and portfolio growth may + /// still use SDK prices until a single pricing path exists. CexPrice? getPriceForAsset(AssetId assetId) { return prices[assetId.symbol.configSymbol.toUpperCase()]; } @@ -73,28 +78,34 @@ class CoinsState extends Equatable { return getPriceForAsset(assetId)?.change24h?.toDouble(); } - /// Calculates the USD price for a given amount of a coin - /// - /// [amount] The amount of the coin as a string - /// [coinAbbr] The abbreviation/symbol of the coin + /// Calculates the USD price for a given numeric [amount] of [coinAbbr]. /// /// Returns null if: /// - The coin is not found in the state - /// - The amount cannot be parsed to a double /// - The coin does not have a USD price /// /// Note: This will be migrated to use the SDK's price functionality in the future. /// See the MarketDataManager in the SDK for the new implementation. - @Deprecated('Use sdk.prices.fiatPrice(assetId) * amount instead') - double? getUsdPriceByAmount(String amount, String coinAbbr) { + double? getUsdPriceForAmount(num amount, String coinAbbr) { final Coin? coin = coins[coinAbbr]; - final double? parsedAmount = double.tryParse(amount); + final double parsedAmount = amount.toDouble(); final CexPrice? cexPrice = prices[coinAbbr.toUpperCase()]; final double? usdPrice = cexPrice?.price?.toDouble(); - if (coin == null || usdPrice == null || parsedAmount == null) { + if (coin == null || usdPrice == null) { return null; } return parsedAmount * usdPrice; } + + /// Backward-compatible string overload. + @Deprecated( + 'Use getUsdPriceForAmount(num amount, String coinAbbr) to avoid ' + 'string-parsing bugs from display-formatted values.', + ) + double? getUsdPriceByAmount(String amount, String coinAbbr) { + final double? parsedAmount = double.tryParse(amount); + if (parsedAmount == null) return null; + return getUsdPriceForAmount(parsedAmount, coinAbbr); + } } diff --git a/lib/bloc/coins_manager/coins_manager_bloc.dart b/lib/bloc/coins_manager/coins_manager_bloc.dart index 2b0da74164..b64f28739f 100644 --- a/lib/bloc/coins_manager/coins_manager_bloc.dart +++ b/lib/bloc/coins_manager/coins_manager_bloc.dart @@ -15,6 +15,7 @@ import 'package:web_dex/bloc/settings/settings_repository.dart'; import 'package:web_dex/blocs/trading_entities_bloc.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_utils.dart'; +import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/utils/extensions/kdf_user_extensions.dart'; import 'package:web_dex/router/state/wallet_state.dart'; import 'package:web_dex/views/wallet/coins_manager/coins_manager_helpers.dart'; @@ -68,13 +69,17 @@ class CoinsManagerBloc extends Bloc { ) async { final List filters = []; - final mergedCoinsList = _mergeCoinLists( + final originalCoins = await _filterUnsupportedHardwareCoins( await _getOriginalCoinList( _coinsRepo, event.action, cachedKnownCoinsMap: _cachedKnownCoinsMap, cachedWalletCoins: _cachedWalletCoins, ), + event.action, + ); + final mergedCoinsList = _mergeCoinLists( + originalCoins, state.coins, ).toList(); @@ -86,8 +91,15 @@ class CoinsManagerBloc extends Bloc { state.selectedCoins, event.action, ); + final visibleSelectedCoins = await _filterUnsupportedHardwareCoins( + selectedCoins, + event.action, + ); - final uniqueCombinedList = {...mergedCoinsList, ...selectedCoins}; + final uniqueCombinedList = { + ...mergedCoinsList, + ...visibleSelectedCoins, + }; final testFilteredCoins = await _filterTestCoinsIfNeeded( uniqueCombinedList.toList(), @@ -111,7 +123,7 @@ class CoinsManagerBloc extends Bloc { state.copyWith( coins: sortedCoins.unique((coin) => coin.id), action: event.action, - selectedCoins: selectedCoins, + selectedCoins: visibleSelectedCoins, ), ); } @@ -147,11 +159,14 @@ class CoinsManagerBloc extends Bloc { _cachedTestCoinsEnabled = (await _settingsRepository.loadSettings()).testCoinsEnabled; - final List coins = await _getOriginalCoinList( - _coinsRepo, + final List coins = await _filterUnsupportedHardwareCoins( + await _getOriginalCoinList( + _coinsRepo, + event.action, + cachedKnownCoinsMap: _cachedKnownCoinsMap, + cachedWalletCoins: _cachedWalletCoins, + ), event.action, - cachedKnownCoinsMap: _cachedKnownCoinsMap, - cachedWalletCoins: _cachedWalletCoins, ); // Add wallet coins to selected coins if in add mode so that they @@ -164,9 +179,13 @@ class CoinsManagerBloc extends Bloc { : [], event.action, ); + final visibleSelectedCoins = await _filterUnsupportedHardwareCoins( + selectedCoins, + event.action, + ); final filteredCoins = await _filterTestCoinsIfNeeded( - {...coins, ...selectedCoins}.toList(), + {...coins, ...visibleSelectedCoins}.toList(), ); final sortedCoins = _sortCoins(filteredCoins, event.action, state.sortData); @@ -174,7 +193,7 @@ class CoinsManagerBloc extends Bloc { state.copyWith( coins: sortedCoins.unique((coin) => coin.id), action: event.action, - selectedCoins: selectedCoins, + selectedCoins: visibleSelectedCoins, ), ); } @@ -185,9 +204,7 @@ class CoinsManagerBloc extends Bloc { ) { final List newTypes = state.selectedCoinTypes.contains(event.type) - ? state.selectedCoinTypes - .where((type) => type != event.type) - .toList() + ? state.selectedCoinTypes.where((type) => type != event.type).toList() : [...state.selectedCoinTypes, event.type]; emit(state.copyWith(selectedCoinTypes: newTypes)); @@ -249,10 +266,12 @@ class CoinsManagerBloc extends Bloc { } on ZhtlcActivationCancelled { // Revert optimistic selection and show a friendly message selectedCoins.remove(coin); - emit(state.copyWith( - selectedCoins: selectedCoins.toList(), - errorMessage: 'Activation canceled.', - )); + emit( + state.copyWith( + selectedCoins: selectedCoins.toList(), + errorMessage: 'Activation canceled.', + ), + ); return; } } else { @@ -358,12 +377,26 @@ class CoinsManagerBloc extends Bloc { List _filterByType(List coins) { return coins - .where( - (coin) => state.selectedCoinTypes.contains(coin.id.subClass), - ) + .where((coin) => state.selectedCoinTypes.contains(coin.id.subClass)) .toList(); } + Future> _filterUnsupportedHardwareCoins( + List coins, + CoinsManagerAction action, + ) async { + if (action != CoinsManagerAction.add) { + return coins; + } + + final currentWalletType = (await _sdk.auth.currentUser)?.wallet.config.type; + if (currentWalletType != WalletType.trezor) { + return coins; + } + + return coins.where((coin) => coin.id.subClass != CoinSubClass.sia).toList(); + } + /// Merges wallet coins into selected coins list when in add mode Future> _mergeWalletCoinsIfNeeded( List selectedCoins, @@ -383,8 +416,9 @@ class CoinsManagerBloc extends Bloc { // This ensures toggles remain OFF if auto-activation was bypassed. if (walletCoin.id.subClass == CoinSubClass.zhtlc) { try { - final saved = - await _sdk.activationConfigService.getSavedZhtlc(walletCoin.id); + final saved = await _sdk.activationConfigService.getSavedZhtlc( + walletCoin.id, + ); if (saved == null) { continue; } diff --git a/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart b/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart index 4b526a42f2..96abc96975 100644 --- a/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart +++ b/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart @@ -14,8 +14,55 @@ import 'package:web_dex/bloc/custom_token_import/bloc/custom_token_import_event. import 'package:web_dex/bloc/custom_token_import/bloc/custom_token_import_state.dart'; import 'package:web_dex/bloc/custom_token_import/data/custom_token_import_repository.dart'; import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/shared/utils/extensions/kdf_user_extensions.dart'; +class _CustomTokenPreviewSession { + const _CustomTokenPreviewSession({ + required this.platformAsset, + required this.wasPlatformAlreadyActivated, + required this.wasPlatformAlreadyInWalletMetadata, + this.tokenAsset, + this.wasTokenAlreadyActivated = false, + this.wasTokenAlreadyInWalletMetadata = false, + this.wasTokenAlreadyKnown = false, + }); + + final Asset platformAsset; + final bool wasPlatformAlreadyActivated; + final bool wasPlatformAlreadyInWalletMetadata; + final Asset? tokenAsset; + final bool wasTokenAlreadyActivated; + final bool wasTokenAlreadyInWalletMetadata; + final bool wasTokenAlreadyKnown; + + _CustomTokenPreviewSession copyWith({ + Asset? platformAsset, + bool? wasPlatformAlreadyActivated, + bool? wasPlatformAlreadyInWalletMetadata, + Asset? Function()? tokenAsset, + bool? wasTokenAlreadyActivated, + bool? wasTokenAlreadyInWalletMetadata, + bool? wasTokenAlreadyKnown, + }) { + return _CustomTokenPreviewSession( + platformAsset: platformAsset ?? this.platformAsset, + wasPlatformAlreadyActivated: + wasPlatformAlreadyActivated ?? this.wasPlatformAlreadyActivated, + wasPlatformAlreadyInWalletMetadata: + wasPlatformAlreadyInWalletMetadata ?? + this.wasPlatformAlreadyInWalletMetadata, + tokenAsset: tokenAsset != null ? tokenAsset() : this.tokenAsset, + wasTokenAlreadyActivated: + wasTokenAlreadyActivated ?? this.wasTokenAlreadyActivated, + wasTokenAlreadyInWalletMetadata: + wasTokenAlreadyInWalletMetadata ?? + this.wasTokenAlreadyInWalletMetadata, + wasTokenAlreadyKnown: wasTokenAlreadyKnown ?? this.wasTokenAlreadyKnown, + ); + } +} + class CustomTokenImportBloc extends Bloc { CustomTokenImportBloc( @@ -36,19 +83,21 @@ class CustomTokenImportBloc final KomodoDefiSdk _sdk; final AnalyticsBloc _analyticsBloc; final _log = Logger('CustomTokenImportBloc'); + _CustomTokenPreviewSession? _previewSession; - void _onResetFormStatus( + Future _onResetFormStatus( ResetFormStatusEvent event, Emitter emit, - ) { + ) async { + await _rollbackPreviewIfNeeded(); + final availableCoinTypes = CoinType.values.map( (CoinType type) => type.toCoinSubClass(), ); final items = CoinSubClass.values.where((CoinSubClass type) { - final isEvm = type.isEvmProtocol(); final isAvailable = availableCoinTypes.contains(type); final isSupported = _repository.getNetworkApiName(type) != null; - return isEvm && isAvailable && isSupported; + return isAvailable && isSupported; }).toList()..sort((a, b) => a.name.compareTo(b.name)); emit( @@ -57,7 +106,7 @@ class CustomTokenImportBloc formErrorMessage: '', importStatus: FormStatus.initial, importErrorMessage: '', - evmNetworks: items, + supportedNetworks: items, ), ); } @@ -85,21 +134,46 @@ class CustomTokenImportBloc ) async { emit(state.copyWith(formStatus: FormStatus.submitting)); - Asset? tokenData; try { - final networkAsset = _sdk.getSdkAsset(state.network.ticker); + final walletCoinIds = (await _sdk.getWalletCoinIds()).toSet(); + final platformAsset = _sdk.getSdkAsset(state.network.ticker); + final wasPlatformAlreadyActivated = await _coinsRepo.isAssetActivated( + platformAsset.id, + ); + _previewSession = _CustomTokenPreviewSession( + platformAsset: platformAsset, + wasPlatformAlreadyActivated: wasPlatformAlreadyActivated, + wasPlatformAlreadyInWalletMetadata: walletCoinIds.contains( + platformAsset.id.id, + ), + ); // Network (parent) asset must be active before attempting to fetch the // custom token data await _coinsRepo.activateAssetsSync( - [networkAsset], + [platformAsset], notifyListeners: false, addToWalletMetadata: false, ); - tokenData = await _repository.fetchCustomToken( - networkAsset.id, - state.address, + final tokenData = await _repository.fetchCustomToken( + network: state.network, + platformAsset: platformAsset, + address: state.address, + ); + final wasTokenAlreadyKnown = _sdk.assets.available.containsKey( + tokenData.id, + ); + final wasTokenAlreadyActivated = await _coinsRepo.isAssetActivated( + tokenData.id, + ); + _previewSession = _previewSession?.copyWith( + tokenAsset: () => tokenData, + wasTokenAlreadyActivated: wasTokenAlreadyActivated, + wasTokenAlreadyInWalletMetadata: walletCoinIds.contains( + tokenData.id.id, + ), + wasTokenAlreadyKnown: wasTokenAlreadyKnown, ); await _coinsRepo.activateAssetsSync( [tokenData], @@ -113,8 +187,8 @@ class CustomTokenImportBloc final balanceInfo = await _coinsRepo.tryGetBalanceInfo(tokenData.id); final balance = balanceInfo.spendable; - final usdBalance = _coinsRepo.getUsdPriceByAmount( - balance.toString(), + final usdBalance = _coinsRepo.getUsdPriceForAmount( + balance.toDouble(), tokenData.id.id, ); @@ -130,19 +204,14 @@ class CustomTokenImportBloc ); } catch (e, s) { _log.severe('Error fetching custom token', e, s); + await _rollbackPreviewIfNeeded(); emit( state.copyWith( formStatus: FormStatus.failure, tokenData: () => null, - formErrorMessage: e.toString(), + formErrorMessage: _formatImportError(e), ), ); - } finally { - if (tokenData != null) { - // Activate to get balance, then deactivate to avoid confusion if the user - // does not proceed with the import (exits the dialog). - await _coinsRepo.deactivateCoinsSync([tokenData.toCoin()]); - } } } @@ -177,6 +246,7 @@ class CustomTokenImportBloc try { await _repository.importCustomToken(state.coin!); + _previewSession = null; final walletType = (await _sdk.auth.currentUser)?.type ?? ''; _analyticsBloc.logEvent( @@ -198,14 +268,71 @@ class CustomTokenImportBloc emit( state.copyWith( importStatus: FormStatus.failure, - importErrorMessage: e.toString(), + importErrorMessage: _formatImportError(e), ), ); } } + String _formatImportError(Object error) { + return switch (error) { + final CustomTokenConflictException e => e.message, + final UnsupportedCustomTokenNetworkException e => e.message, + _ => error.toString(), + }; + } + + Future _rollbackPreviewIfNeeded() async { + final previewSession = _previewSession; + _previewSession = null; + + if (previewSession == null) { + return; + } + + final rollbackAssets = []; + final deleteCustomTokens = {}; + final removeWalletMetadataAssets = {}; + + final tokenAsset = previewSession.tokenAsset; + if (tokenAsset != null && !previewSession.wasTokenAlreadyActivated) { + rollbackAssets.add(tokenAsset); + if (!previewSession.wasTokenAlreadyInWalletMetadata) { + removeWalletMetadataAssets.add(tokenAsset.id); + } + if (!previewSession.wasTokenAlreadyKnown && + !previewSession.wasTokenAlreadyInWalletMetadata) { + deleteCustomTokens.add(tokenAsset.id); + } + } + + if (!previewSession.wasPlatformAlreadyActivated) { + rollbackAssets.add(previewSession.platformAsset); + if (!previewSession.wasPlatformAlreadyInWalletMetadata) { + removeWalletMetadataAssets.add(previewSession.platformAsset.id); + } + } + + if (rollbackAssets.isEmpty && + deleteCustomTokens.isEmpty && + removeWalletMetadataAssets.isEmpty) { + return; + } + + try { + await _coinsRepo.rollbackPreviewAssets( + rollbackAssets, + deleteCustomTokens: deleteCustomTokens, + removeWalletMetadataAssets: removeWalletMetadataAssets, + ); + } catch (e, s) { + _log.warning('Failed to rollback preview activation state', e, s); + } + } + @override Future close() async { + await _rollbackPreviewIfNeeded(); _repository.dispose(); await super.close(); } diff --git a/lib/bloc/custom_token_import/bloc/custom_token_import_state.dart b/lib/bloc/custom_token_import/bloc/custom_token_import_state.dart index 3c523749aa..71d8174045 100644 --- a/lib/bloc/custom_token_import/bloc/custom_token_import_state.dart +++ b/lib/bloc/custom_token_import/bloc/custom_token_import_state.dart @@ -15,7 +15,7 @@ class CustomTokenImportState extends Equatable { required this.coin, required this.coinBalance, required this.coinBalanceUsd, - required this.evmNetworks, + required this.supportedNetworks, }); CustomTokenImportState.defaults({ @@ -26,9 +26,9 @@ class CustomTokenImportState extends Equatable { this.formErrorMessage = '', this.importErrorMessage = '', this.coin, - this.evmNetworks = const [], - }) : coinBalance = Decimal.zero, - coinBalanceUsd = Decimal.zero; + this.supportedNetworks = const [], + }) : coinBalance = Decimal.zero, + coinBalanceUsd = Decimal.zero; final FormStatus formStatus; final FormStatus importStatus; @@ -39,7 +39,7 @@ class CustomTokenImportState extends Equatable { final Asset? coin; final Decimal coinBalance; final Decimal coinBalanceUsd; - final Iterable evmNetworks; + final Iterable supportedNetworks; CustomTokenImportState copyWith({ FormStatus? formStatus, @@ -51,7 +51,7 @@ class CustomTokenImportState extends Equatable { Asset? Function()? tokenData, Decimal? tokenBalance, Decimal? tokenBalanceUsd, - Iterable? evmNetworks, + Iterable? supportedNetworks, }) { return CustomTokenImportState( formStatus: formStatus ?? this.formStatus, @@ -61,7 +61,7 @@ class CustomTokenImportState extends Equatable { formErrorMessage: formErrorMessage ?? this.formErrorMessage, importErrorMessage: importErrorMessage ?? this.importErrorMessage, coin: tokenData?.call() ?? coin, - evmNetworks: evmNetworks ?? this.evmNetworks, + supportedNetworks: supportedNetworks ?? this.supportedNetworks, coinBalance: tokenBalance ?? coinBalance, coinBalanceUsd: tokenBalanceUsd ?? coinBalanceUsd, ); @@ -69,14 +69,14 @@ class CustomTokenImportState extends Equatable { @override List get props => [ - formStatus, - importStatus, - network, - address, - formErrorMessage, - importErrorMessage, - coin, - coinBalance, - evmNetworks, - ]; + formStatus, + importStatus, + network, + address, + formErrorMessage, + importErrorMessage, + coin, + coinBalance, + supportedNetworks, + ]; } diff --git a/lib/bloc/custom_token_import/data/custom_token_import_repository.dart b/lib/bloc/custom_token_import/data/custom_token_import_repository.dart index 2d2fc486c4..5d90a685d2 100644 --- a/lib/bloc/custom_token_import/data/custom_token_import_repository.dart +++ b/lib/bloc/custom_token_import/data/custom_token_import_repository.dart @@ -16,10 +16,15 @@ import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; /// Implementations should resolve token metadata and activate tokens so they /// become available to the user within the wallet. abstract class ICustomTokenImportRepository { - /// Fetch an [Asset] for a custom token on [network] using [address]. + /// Fetch an [Asset] for a custom token on [network] using [address] and the + /// resolved parent [platformAsset]. /// /// May return an existing known asset or construct a new one when absent. - Future fetchCustomToken(AssetId networkId, String address); + Future fetchCustomToken({ + required CoinSubClass network, + required Asset platformAsset, + required String address, + }); /// Import the provided custom token [asset] into the wallet (e.g. activate it). Future importCustomToken(Asset asset); @@ -46,64 +51,108 @@ class KdfCustomTokenImportRepository implements ICustomTokenImportRepository { final _log = Logger('KdfCustomTokenImportRepository'); @override - Future fetchCustomToken(AssetId networkId, String address) async { - final networkSubclass = networkId.subClass; + Future fetchCustomToken({ + required CoinSubClass network, + required Asset platformAsset, + required String address, + }) async { + _assertSupportedNetwork(network); + _assertPlatformAsset(network, platformAsset); + final convertAddressResponse = await _kdfSdk.client.rpc.address .convertAddress( from: address, - coin: networkSubclass.ticker, - toFormat: AddressFormat.fromCoinSubClass(CoinSubClass.erc20), + coin: platformAsset.id.id, + toFormat: AddressFormat.fromCoinSubClass(network), ); final contractAddress = convertAddressResponse.address; - final knownCoin = _kdfSdk.assets.available.values.firstWhereOrNull( - (asset) => - asset.contractAddress == contractAddress && - asset.id.subClass == networkSubclass, + final knownCoin = _findKnownAssetByContract( + network: network, + platformAsset: platformAsset, + contractAddress: contractAddress, ); - if (knownCoin == null) { - return _createNewCoin(contractAddress, networkId); + if (knownCoin != null) { + return knownCoin; } - return knownCoin; + return _createNewCoin( + contractAddress: contractAddress, + network: network, + platformAsset: platformAsset, + ); } - Future _createNewCoin( - String contractAddress, - AssetId networkId, - ) async { - final network = networkId.subClass; - + Future _createNewCoin({ + required String contractAddress, + required CoinSubClass network, + required Asset platformAsset, + }) async { _log.info('Creating new coin for $contractAddress on $network'); final response = await _kdfSdk.client.rpc.utility.getTokenInfo( contractAddress: contractAddress, - platform: network.ticker, - protocolType: - CoinSubClass.erc20.tokenStandardSuffix ?? - CoinSubClass.erc20.name.toUpperCase(), + platform: platformAsset.id.id, + protocolType: _protocolTypeFor(network), + ); + + final platformConfig = platformAsset.protocol.config; + final String ticker = response.info.symbol; + final int tokenDecimals = response.info.decimals; + final int? platformChainId = platformConfig.valueOrNull('chain_id'); + final coinId = '$ticker-${network.tokenStandardSuffix}'; + final conflictingAsset = _findExistingAssetByGeneratedId( + network: network, + platformAsset: platformAsset, + assetId: coinId, ); + if (conflictingAsset != null) { + if (_hasMatchingContract( + network: network, + existingContractAddress: conflictingAsset.contractAddress, + requestedContractAddress: contractAddress, + )) { + return conflictingAsset; + } - final platformAssets = _kdfSdk.assets.findAssetsByConfigId(network.ticker); - if (platformAssets.length != 1) { - throw Exception( - 'Platform asset not found. ${platformAssets.length} ' - 'results returned.', + throw CustomTokenConflictException( + assetId: coinId, + network: network, + existingContractAddress: conflictingAsset.contractAddress ?? '', + requestedContractAddress: contractAddress, ); } - final platformAsset = platformAssets.single; - final platformConfig = platformAsset.protocol.config; - final String ticker = response.info.symbol; final tokenApi = await fetchTokenInfoFromApi(network, contractAddress); - final platformChainId = int.parse( - platformAsset.id.chainId.formattedChainId, - ); - final coinId = '$ticker-${network.tokenStandardSuffix}'; + final String? logoImageUrl = tokenApi?['image']?['large'] ?? tokenApi?['image']?['small'] ?? tokenApi?['image']?['thumb']; _log.info('Creating new coin for $coinId on $network'); + final protocol = switch (network) { + CoinSubClass.trc20 => + _buildTrc20Protocol(platformConfig).copyWithProtocolData( + coin: coinId, + type: network.tokenStandardSuffix, + chainId: platformChainId, + decimals: tokenDecimals, + contractAddress: contractAddress, + platform: network.ticker, + logoImageUrl: logoImageUrl, + isCustomToken: true, + ), + _ => Erc20Protocol.fromJson(platformConfig).copyWithProtocolData( + coin: coinId, + type: network.tokenStandardSuffix, + chainId: platformChainId, + decimals: tokenDecimals, + contractAddress: contractAddress, + platform: network.ticker, + logoImageUrl: logoImageUrl, + isCustomToken: true, + ), + }; + final newCoin = Asset( signMessagePrefix: null, id: AssetId( @@ -114,21 +163,13 @@ class KdfCustomTokenImportRepository implements ICustomTokenImportRepository { coinGeckoId: tokenApi?['id'], coinPaprikaId: tokenApi?['id'], ), - chainId: platformAsset.id.chainId, + chainId: ChainId.parse(protocol.config), subClass: network, derivationPath: platformAsset.id.derivationPath, parentId: platformAsset.id, ), isWalletOnly: false, - protocol: Erc20Protocol.fromJson(platformConfig).copyWithProtocolData( - coin: coinId, - type: network.tokenStandardSuffix, - chainId: platformChainId, - contractAddress: contractAddress, - platform: network.ticker, - logoImageUrl: logoImageUrl, - isCustomToken: true, - ), + protocol: protocol, ); if (logoImageUrl != null && logoImageUrl.isNotEmpty) { @@ -138,6 +179,89 @@ class KdfCustomTokenImportRepository implements ICustomTokenImportRepository { return newCoin; } + void _assertSupportedNetwork(CoinSubClass network) { + if (network.tokenStandardSuffix == null || + getNetworkApiName(network) == null) { + throw UnsupportedCustomTokenNetworkException(network); + } + } + + void _assertPlatformAsset(CoinSubClass network, Asset platformAsset) { + if (!platformAsset.id.subClass.canBeParentOf(network)) { + throw ArgumentError.value( + platformAsset.id, + 'platformAsset', + 'is not a valid parent for ${network.formatted} tokens', + ); + } + } + + String _protocolTypeFor(CoinSubClass network) { + final protocolType = network.tokenStandardSuffix; + if (protocolType == null) { + throw UnsupportedCustomTokenNetworkException(network); + } + return protocolType; + } + + Asset? _findKnownAssetByContract({ + required CoinSubClass network, + required Asset platformAsset, + required String contractAddress, + }) { + return _kdfSdk.assets.available.values.firstWhereOrNull( + (asset) => + asset.id.subClass == network && + asset.id.parentId == platformAsset.id && + _hasMatchingContract( + network: network, + existingContractAddress: asset.contractAddress, + requestedContractAddress: contractAddress, + ), + ); + } + + Asset? _findExistingAssetByGeneratedId({ + required CoinSubClass network, + required Asset platformAsset, + required String assetId, + }) { + return _kdfSdk.assets.available.values.firstWhereOrNull( + (asset) => + asset.id.id == assetId && + asset.id.subClass == network && + asset.id.parentId == platformAsset.id, + ); + } + + bool _hasMatchingContract({ + required CoinSubClass network, + required String? existingContractAddress, + required String requestedContractAddress, + }) { + if (existingContractAddress == null) { + return false; + } + + return _normalizeContractAddress( + network: network, + contractAddress: existingContractAddress, + ) == + _normalizeContractAddress( + network: network, + contractAddress: requestedContractAddress, + ); + } + + String _normalizeContractAddress({ + required CoinSubClass network, + required String contractAddress, + }) { + return network == CoinSubClass.trc20 + ? contractAddress + : contractAddress.toLowerCase(); + } + @override Future importCustomToken(Asset asset) async { await _coinsRepo.activateAssetsSync([asset], maxRetryAttempts: 10); @@ -181,6 +305,8 @@ class KdfCustomTokenImportRepository implements ICustomTokenImportRepository { @override String? getNetworkApiName(CoinSubClass coinType) { switch (coinType) { + case CoinSubClass.trc20: + return 'tron'; // https://api.coingecko.com/api/v3/coins/tron/contract/TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t case CoinSubClass.erc20: return 'ethereum'; // https://api.coingecko.com/api/v3/coins/ethereum/contract/0x56072C95FAA701256059aa122697B133aDEd9279 case CoinSubClass.bep20: @@ -228,12 +354,14 @@ extension on Erc20Protocol { String? logoImageUrl, bool? isCustomToken, int? chainId, + int? decimals, }) { final currentConfig = JsonMap.from(config); currentConfig.addAll({ if (coin != null) 'coin': coin, if (type != null) 'type': type, if (chainId != null) 'chain_id': chainId, + if (decimals != null) 'decimals': decimals, if (platform != null) 'parent_coin': platform, if (logoImageUrl != null) 'logo_image_url': logoImageUrl, if (isCustomToken != null) 'is_custom_token': isCustomToken, @@ -249,3 +377,51 @@ extension on Erc20Protocol { return Erc20Protocol.fromJson(currentConfig); } } + +Trc20Protocol _buildTrc20Protocol(JsonMap platformConfig) { + final config = JsonMap.from(platformConfig); + config['protocol'] = { + 'type': 'TRC20', + 'protocol_data': { + 'platform': config.valueOrNull('coin') ?? CoinSubClass.trx.ticker, + 'contract_address': config.valueOrNull('contract_address') ?? '', + }, + }; + config['contract_address'] = + config.valueOrNull('contract_address') ?? ''; + return Trc20Protocol.fromJson(config); +} + +extension on Trc20Protocol { + Trc20Protocol copyWithProtocolData({ + String? coin, + String? type, + String? contractAddress, + String? platform, + String? logoImageUrl, + bool? isCustomToken, + int? chainId, + int? decimals, + }) { + final currentConfig = JsonMap.from(config); + currentConfig.addAll({ + if (coin != null) 'coin': coin, + if (type != null) 'type': type, + if (chainId != null) 'chain_id': chainId, + if (decimals != null) 'decimals': decimals, + if (platform != null) 'parent_coin': platform, + if (logoImageUrl != null) 'logo_image_url': logoImageUrl, + if (isCustomToken != null) 'is_custom_token': isCustomToken, + if (contractAddress != null) 'contract_address': contractAddress, + if (contractAddress != null || platform != null) + 'protocol': { + 'type': 'TRC20', + 'protocol_data': { + 'contract_address': contractAddress ?? this.contractAddress, + 'platform': platform ?? this.platform, + }, + }, + }); + return Trc20Protocol.fromJson(currentConfig); + } +} diff --git a/lib/bloc/dex_repository.dart b/lib/bloc/dex_repository.dart index afceae9f20..afde19bc20 100644 --- a/lib/bloc/dex_repository.dart +++ b/lib/bloc/dex_repository.dart @@ -32,6 +32,11 @@ class DexRepository { DexRepository(this._mm2Api); final Mm2Api _mm2Api; + final _rpcCache = _RpcRequestCache(); + + static const Duration _tradePreimageCacheTtl = Duration(seconds: 2); + static const Duration _maxVolumeCacheTtl = Duration(seconds: 5); + static const Duration _minVolumeCacheTtl = Duration(seconds: 10); Future sell(SellRequest request) async { try { @@ -50,81 +55,108 @@ class DexRepository { Rational? volume, bool max = false, ]) async { - final request = TradePreimageRequest( - base: base, - rel: rel, - price: price, - volume: volume, - swapMethod: swapMethod, - max: max, + final cacheKey = + 'trade_preimage:$base:$rel:${price.toString()}:$swapMethod:${volume?.toString() ?? 'null'}:$max'; + + return _rpcCache.getOrCreate( + cacheKey, + ttl: _tradePreimageCacheTtl, + request: () async { + final request = TradePreimageRequest( + base: base, + rel: rel, + price: price, + volume: volume, + swapMethod: swapMethod, + max: max, + ); + final ApiResponse< + TradePreimageRequest, + TradePreimageResponseResult, + Map + > + response = await _mm2Api.getTradePreimage(request); + + final Map? error = response.error; + final TradePreimageResponseResult? result = response.result; + if (error != null) { + return DataFromService( + error: tradePreimageErrorFactory.getError(error, response.request), + ); + } + if (result == null) { + return DataFromService(error: TextError(error: 'Something wrong')); + } + try { + return DataFromService( + data: mapTradePreimageResponseResultToTradePreimage( + result, + response.request, + ), + ); + } catch (e, s) { + log( + e.toString(), + path: + 'swaps_service => getTradePreimage => mapTradePreimageResponseToTradePreimage', + trace: s, + isError: true, + ); + return DataFromService(error: TextError(error: 'Something wrong')); + } + }, ); - final ApiResponse< - TradePreimageRequest, - TradePreimageResponseResult, - Map - > - response = await _mm2Api.getTradePreimage(request); - - final Map? error = response.error; - final TradePreimageResponseResult? result = response.result; - if (error != null) { - return DataFromService( - error: tradePreimageErrorFactory.getError(error, response.request), - ); - } - if (result == null) { - return DataFromService(error: TextError(error: 'Something wrong')); - } - try { - return DataFromService( - data: mapTradePreimageResponseResultToTradePreimage( - result, - response.request, - ), - ); - } catch (e, s) { - log( - e.toString(), - path: - 'swaps_service => getTradePreimage => mapTradePreimageResponseToTradePreimage', - trace: s, - isError: true, - ); - return DataFromService(error: TextError(error: 'Something wrong')); - } } Future getMaxTakerVolume(String coinAbbr) async { - final MaxTakerVolResponse? response = await _mm2Api.getMaxTakerVolume( - MaxTakerVolRequest(coin: coinAbbr), - ); - if (response == null) { - return null; - } + return _rpcCache.getOrCreate( + 'max_taker_vol:$coinAbbr', + ttl: _maxVolumeCacheTtl, + request: () async { + final MaxTakerVolResponse? response = await _mm2Api.getMaxTakerVolume( + MaxTakerVolRequest(coin: coinAbbr), + ); + if (response == null) { + return null; + } - return fract2rat(response.result.toJson()); + return fract2rat(response.result.toJson()); + }, + ); } Future getMaxMakerVolume(String coinAbbr) async { - final MaxMakerVolResponse? response = await _mm2Api.getMaxMakerVolume( - MaxMakerVolRequest(coin: coinAbbr), - ); - if (response == null) { - return null; - } + return _rpcCache.getOrCreate( + 'max_maker_vol:$coinAbbr', + ttl: _maxVolumeCacheTtl, + request: () async { + final MaxMakerVolResponse? response = await _mm2Api.getMaxMakerVolume( + MaxMakerVolRequest(coin: coinAbbr), + ); + if (response == null) { + return null; + } - return fract2rat(response.volume.toFractionalJson()); + return fract2rat(response.volume.toFractionalJson()); + }, + ); } Future getMinTradingVolume(String coinAbbr) async { - final MinTradingVolResponse? response = await _mm2Api.getMinTradingVol( - MinTradingVolRequest(coin: coinAbbr), - ); - if (response == null) { - return null; - } + return _rpcCache.getOrCreate( + 'min_trading_vol:$coinAbbr', + ttl: _minVolumeCacheTtl, + request: () async { + final MinTradingVolResponse? response = await _mm2Api.getMinTradingVol( + MinTradingVolRequest(coin: coinAbbr), + ); + if (response == null) { + return null; + } - return fract2rat(response.result.toJson()); + return fract2rat(response.result.toJson()); + }, + ); } Future?> getRecentSwaps(MyRecentSwapsRequest request) async { @@ -255,3 +287,42 @@ class DexRepository { } } } + +class _RpcRequestCache { + final Map> _resolved = {}; + final Map> _inFlight = {}; + + Future getOrCreate( + String key, { + required Duration ttl, + required Future Function() request, + }) async { + final now = DateTime.now(); + final cached = _resolved[key]; + if (cached != null && now.difference(cached.cachedAt) < ttl) { + return cached.value as T; + } + + final inFlight = _inFlight[key]; + if (inFlight != null) { + return await inFlight as T; + } + + final future = request(); + _inFlight[key] = future; + try { + final value = await future; + _resolved[key] = _RpcCacheEntry(value: value, cachedAt: DateTime.now()); + return value; + } finally { + _inFlight.remove(key); + } + } +} + +class _RpcCacheEntry { + _RpcCacheEntry({required this.value, required this.cachedAt}); + + final T value; + final DateTime cachedAt; +} diff --git a/lib/bloc/fiat/banxa_fiat_provider.dart b/lib/bloc/fiat/banxa_fiat_provider.dart index 27c79fd05e..34c0118318 100644 --- a/lib/bloc/fiat/banxa_fiat_provider.dart +++ b/lib/bloc/fiat/banxa_fiat_provider.dart @@ -14,6 +14,28 @@ class BanxaFiatProvider extends BaseFiatProvider { final String apiEndpoint = '/api/v1/banxa'; static final _log = Logger('BanxaFiatProvider'); + bool _isUnsupportedCoinForChain(String coinCode, CoinType coinType) { + switch (coinCode) { + case 'AVAX': + case 'DOT': + case 'FIL': + case 'TRX': + return coinType == CoinType.bep20; + case 'TON': + return coinType == CoinType.erc20; + default: + return banxaUnsupportedCoinsList.contains(coinCode); + } + } + + bool _isUnsupportedCurrency(ICurrency target) { + if (target is! CryptoCurrency) { + return false; + } + + return _isUnsupportedCoinForChain(target.configSymbol, target.chainType); + } + @override String getProviderId() { return providerId; @@ -32,70 +54,57 @@ class BanxaFiatProvider extends BaseFiatProvider { String source, ICurrency target, { String? sourceAmount, - }) => - apiRequest( - 'GET', - apiEndpoint, - queryParams: { - 'endpoint': '/api/payment-methods', - 'source': source, - 'target': target.configSymbol, - }, - ); + }) => apiRequest( + 'GET', + apiEndpoint, + queryParams: { + 'endpoint': '/api/payment-methods', + 'source': source, + 'target': target.configSymbol, + }, + ); Future _getPricesWithPaymentMethod( String source, ICurrency target, String sourceAmount, FiatPaymentMethod paymentMethod, - ) => - apiRequest( - 'GET', - apiEndpoint, - queryParams: { - 'endpoint': '/api/prices', - 'source': source, - 'target': target.configSymbol, - 'source_amount': sourceAmount, - 'payment_method_id': paymentMethod.id, - }, - ); + ) => apiRequest( + 'GET', + apiEndpoint, + queryParams: { + 'endpoint': '/api/prices', + 'source': source, + 'target': target.configSymbol, + 'source_amount': sourceAmount, + 'payment_method_id': paymentMethod.id, + }, + ); Future _createOrder(Map payload) => apiRequest( - 'POST', - apiEndpoint, - queryParams: { - 'endpoint': '/api/orders', - }, - body: payload, - ); + 'POST', + apiEndpoint, + queryParams: {'endpoint': '/api/orders'}, + body: payload, + ); Future _getOrder(String orderId) => apiRequest( - 'GET', - apiEndpoint, - queryParams: { - 'endpoint': '/api/orders', - 'order_id': orderId, - }, - ); + 'GET', + apiEndpoint, + queryParams: {'endpoint': '/api/orders', 'order_id': orderId}, + ); Future _getFiats() => apiRequest( - 'GET', - apiEndpoint, - queryParams: { - 'endpoint': '/api/fiats', - 'orderType': 'buy', - }, - ); + 'GET', + apiEndpoint, + queryParams: {'endpoint': '/api/fiats', 'orderType': 'buy'}, + ); Future _getCoins() => apiRequest( - 'GET', - apiEndpoint, - queryParams: { - 'endpoint': '/api/coins', - 'orderType': 'buy', - }, - ); + 'GET', + apiEndpoint, + queryParams: {'endpoint': '/api/coins', 'orderType': 'buy'}, + ); // These will be in BLOC: @override @@ -107,12 +116,14 @@ class BanxaFiatProvider extends BaseFiatProvider { // message, but adds the challenge that we add further web-only code that // needs to be re-implemented for mobile/desktop. while (true) { - final response = await _getOrder(orderId) - .catchError((e) => Future.error('Error fetching order: $e')); + final response = await _getOrder( + orderId, + ).catchError((e) => Future.error('Error fetching order: $e')); _log.fine('Fiat order status response:\n${jsonEncode(response)}'); - final status = - _parseStatusFromResponse(response as Map? ?? {}); + final status = _parseStatusFromResponse( + response as Map? ?? {}, + ); final isCompleted = status == FiatOrderStatus.success || status == FiatOrderStatus.failed; @@ -153,19 +164,21 @@ class BanxaFiatProvider extends BaseFiatProvider { final List currencyList = []; for (final item in data) { final coinCode = item['coin_code'] as String; - if (banxaUnsupportedCoinsList.contains(coinCode)) { - _log.warning('Banxa does not support $coinCode'); - continue; - } - final coinName = item['coin_name'] as String; final blockchains = item['blockchains'] as List; for (final blockchain in blockchains) { - final coinType = getCoinType(blockchain['code'] as String); + final coinType = getCoinType( + blockchain['code'] as String, + coinSymbol: coinCode, + ); if (coinType == null) { continue; } + if (_isUnsupportedCoinForChain(coinCode, coinType)) { + _log.warning('Banxa does not support $coinCode on ${coinType.name}'); + continue; + } // Parse min_value which can be a string, int, or double final dynamic minValue = blockchain['min_value']; @@ -208,20 +221,24 @@ class BanxaFiatProvider extends BaseFiatProvider { String sourceAmount, ) async { try { - if (banxaUnsupportedCoinsList.contains(target.configSymbol)) { - _log.warning('Banxa does not support ${target.configSymbol}'); + if (_isUnsupportedCurrency(target)) { + _log.warning('Banxa does not support ${target.getAbbr()}'); return []; } - final response = - await _getPaymentMethods(source, target, sourceAmount: sourceAmount); - final List paymentMethods = (response['data'] - ['payment_methods'] as List) - .map( - (json) => - FiatPaymentMethod.fromJson(json as Map? ?? {}), - ) - .toList(); + final response = await _getPaymentMethods( + source, + target, + sourceAmount: sourceAmount, + ); + final List paymentMethods = + (response['data']['payment_methods'] as List) + .map( + (json) => FiatPaymentMethod.fromJson( + json as Map? ?? {}, + ), + ) + .toList(); final List> priceFutures = []; for (final paymentMethod in paymentMethods) { @@ -239,9 +256,7 @@ class BanxaFiatProvider extends BaseFiatProvider { // Combine price information with payment methods for (int i = 0; i < paymentMethods.length; i++) { - paymentMethods[i] = paymentMethods[i].copyWith( - priceInfo: prices[i], - ); + paymentMethods[i] = paymentMethods[i].copyWith(priceInfo: prices[i]); } return paymentMethods; @@ -259,12 +274,14 @@ class BanxaFiatProvider extends BaseFiatProvider { FiatPaymentMethod paymentMethod, ) async { try { - final response = await _getPricesWithPaymentMethod( - source, - target, - sourceAmount, - paymentMethod, - ) as Map? ?? + final response = + await _getPricesWithPaymentMethod( + source, + target, + sourceAmount, + paymentMethod, + ) + as Map? ?? {}; final responseData = response['data'] as Map? ?? {}; final prices = responseData['prices'] as List; diff --git a/lib/bloc/fiat/base_fiat_provider.dart b/lib/bloc/fiat/base_fiat_provider.dart index 841e125d03..5a01e8aa65 100644 --- a/lib/bloc/fiat/base_fiat_provider.dart +++ b/lib/bloc/fiat/base_fiat_provider.dart @@ -109,6 +109,9 @@ abstract class BaseFiatProvider { case CoinType.utxo: // BTC, BCH, DOGE, LTC return currency.configSymbol; + case CoinType.trx: + case CoinType.trc20: + return 'TRON'; case CoinType.erc20: return 'ETH'; case CoinType.bep20: @@ -200,7 +203,6 @@ abstract class BaseFiatProvider { // TERNOA // TERRA // TEZOS - // TRON // WAX // XCH // XDAI @@ -212,13 +214,19 @@ abstract class BaseFiatProvider { } // TODO: migrate to SDK [CoinSubClass] ticker/formatted getters - CoinType? getCoinType(String chain) { + CoinType? getCoinType(String chain, {String? coinSymbol}) { switch (chain) { case 'BTC': case 'BCH': case 'DOGE': case 'LTC': return CoinType.utxo; + case 'TRX': + case 'TRON': + if (coinSymbol == null || coinSymbol == 'TRX') { + return CoinType.trx; + } + return CoinType.trc20; case 'ETH': return CoinType.erc20; case 'BSC': diff --git a/lib/bloc/fiat/models/i_currency.dart b/lib/bloc/fiat/models/i_currency.dart index e5940eca80..46314a0186 100644 --- a/lib/bloc/fiat/models/i_currency.dart +++ b/lib/bloc/fiat/models/i_currency.dart @@ -124,12 +124,17 @@ class CryptoCurrency extends ICurrency { @override String getAbbr() { + if (symbol.contains('-')) { + return symbol; + } + // TODO: look into a better way to do this when migrating to the SDK // Providers return "ETH" with chain type "ERC20", resultning in abbr of // "ETH-ERC20", which is not how it is stored in our coins configuration // files. "ETH" is the expected abbreviation, which would just be `symbol`. if (chainType == CoinType.utxo || (chainType == CoinType.tendermint && symbol == 'ATOM') || + (chainType == CoinType.trx && symbol == 'TRX') || (chainType == CoinType.erc20 && symbol == 'ETH') || (chainType == CoinType.bep20 && symbol == 'BNB') || (chainType == CoinType.avx20 && symbol == 'AVAX') || diff --git a/lib/bloc/fiat/ramp/models/ramp_asset_info.dart b/lib/bloc/fiat/ramp/models/ramp_asset_info.dart index 72add2ffc6..c8fa79f198 100644 --- a/lib/bloc/fiat/ramp/models/ramp_asset_info.dart +++ b/lib/bloc/fiat/ramp/models/ramp_asset_info.dart @@ -105,7 +105,7 @@ class RampAssetInfo { 'logoUrl', 'minPurchaseCryptoAmount', 'networkFee', - 'type' + 'type', ]; for (final field in requiredFields) { @@ -117,18 +117,24 @@ class RampAssetInfo { // Validate types for critical fields if (json['price'] is! Map) { throw ArgumentError.value( - json['price'], 'price', 'Must be a JSON object'); + json['price'], + 'price', + 'Must be a JSON object', + ); } - if (json['decimals'] is! int) { + if (json['decimals'] is! num) { throw ArgumentError.value( - json['decimals'], 'decimals', 'Must be an integer'); + json['decimals'], + 'decimals', + 'Must be an integer', + ); } return RampAssetInfo( name: json['name'] as String, symbol: json['symbol'] as String, - decimals: json['decimals'] as int, + decimals: (json['decimals'] as num).toInt(), price: json['price'] as Map, minPurchaseAmount: json['minPurchaseAmount'] != null ? Decimal.tryParse(json['minPurchaseAmount'].toString()) diff --git a/lib/bloc/fiat/ramp/ramp_fiat_provider.dart b/lib/bloc/fiat/ramp/ramp_fiat_provider.dart index 6628d20ce1..4c35d62a2d 100644 --- a/lib/bloc/fiat/ramp/ramp_fiat_provider.dart +++ b/lib/bloc/fiat/ramp/ramp_fiat_provider.dart @@ -118,7 +118,7 @@ class RampFiatProvider extends BaseFiatProvider { return config.assets .map((asset) { - final coinType = getCoinType(asset.chain); + final coinType = getCoinType(asset.chain, coinSymbol: asset.symbol); if (coinType == null) { return null; } @@ -289,7 +289,7 @@ class RampFiatProvider extends BaseFiatProvider { ).toString(), 'coin_amount': getFormattedCryptoAmount( response[paymentMethod.id]['cryptoAmount'] as String, - asset['decimals'] as int, + (asset['decimals'] as num).toInt(), ), }; diff --git a/lib/bloc/settings/settings_bloc.dart b/lib/bloc/settings/settings_bloc.dart index 93a2f6f4f4..6a8811d30b 100644 --- a/lib/bloc/settings/settings_bloc.dart +++ b/lib/bloc/settings/settings_bloc.dart @@ -11,11 +11,11 @@ import 'package:web_dex/shared/utils/utils.dart'; class SettingsBloc extends Bloc { SettingsBloc(StoredSettings stored, SettingsRepository repository) - : _settingsRepo = repository, - super(SettingsState.fromStored(stored)) { + : _settingsRepo = repository, + super(SettingsState.fromStored(stored)) { _storedSettings = stored; theme.mode = state.themeMode; - + // Initialize diagnostic logging with the stored setting KdfLoggingConfig.verboseLogging = stored.diagnosticLoggingEnabled; KdfApiClient.enableDebugLogging = stored.diagnosticLoggingEnabled; @@ -27,6 +27,7 @@ class SettingsBloc extends Bloc { on(_onWeakPasswordsAllowedChanged); on(_onHideZeroBalanceAssetsChanged); on(_onDiagnosticLoggingChanged); + on(_onHideBalancesChanged); } late StoredSettings _storedSettings; @@ -51,7 +52,9 @@ class SettingsBloc extends Bloc { MarketMakerBotSettingsChanged event, Emitter emitter, ) async { - _storedSettings = _storedSettings.copyWith(marketMakerBotSettings: event.settings); + _storedSettings = _storedSettings.copyWith( + marketMakerBotSettings: event.settings, + ); await _settingsRepo.updateSettings(_storedSettings); emitter(state.copyWith(marketMakerBotSettings: event.settings)); } @@ -60,7 +63,9 @@ class SettingsBloc extends Bloc { TestCoinsEnabledChanged event, Emitter emitter, ) async { - _storedSettings = _storedSettings.copyWith(testCoinsEnabled: event.testCoinsEnabled); + _storedSettings = _storedSettings.copyWith( + testCoinsEnabled: event.testCoinsEnabled, + ); await _settingsRepo.updateSettings(_storedSettings); emitter(state.copyWith(testCoinsEnabled: event.testCoinsEnabled)); } @@ -70,7 +75,8 @@ class SettingsBloc extends Bloc { Emitter emitter, ) async { _storedSettings = _storedSettings.copyWith( - weakPasswordsAllowed: event.weakPasswordsAllowed); + weakPasswordsAllowed: event.weakPasswordsAllowed, + ); await _settingsRepo.updateSettings(_storedSettings); emitter(state.copyWith(weakPasswordsAllowed: event.weakPasswordsAllowed)); } @@ -94,11 +100,24 @@ class SettingsBloc extends Bloc { KdfLoggingConfig.verboseLogging = event.diagnosticLoggingEnabled; KdfApiClient.enableDebugLogging = event.diagnosticLoggingEnabled; KomodoDefiFramework.enableDebugLogging = event.diagnosticLoggingEnabled; - + _storedSettings = _storedSettings.copyWith( diagnosticLoggingEnabled: event.diagnosticLoggingEnabled, ); await _settingsRepo.updateSettings(_storedSettings); - emitter(state.copyWith(diagnosticLoggingEnabled: event.diagnosticLoggingEnabled)); + emitter( + state.copyWith(diagnosticLoggingEnabled: event.diagnosticLoggingEnabled), + ); + } + + Future _onHideBalancesChanged( + HideBalancesChanged event, + Emitter emitter, + ) async { + _storedSettings = _storedSettings.copyWith( + hideBalances: event.hideBalances, + ); + await _settingsRepo.updateSettings(_storedSettings); + emitter(state.copyWith(hideBalances: event.hideBalances)); } } diff --git a/lib/bloc/settings/settings_event.dart b/lib/bloc/settings/settings_event.dart index 9f52cfabdc..a18f8046e4 100644 --- a/lib/bloc/settings/settings_event.dart +++ b/lib/bloc/settings/settings_event.dart @@ -48,3 +48,11 @@ class DiagnosticLoggingChanged extends SettingsEvent { @override List get props => [diagnosticLoggingEnabled]; } + +class HideBalancesChanged extends SettingsEvent { + const HideBalancesChanged({required this.hideBalances}); + final bool hideBalances; + + @override + List get props => [hideBalances]; +} diff --git a/lib/bloc/settings/settings_state.dart b/lib/bloc/settings/settings_state.dart index cc15ea2a05..60f258570c 100644 --- a/lib/bloc/settings/settings_state.dart +++ b/lib/bloc/settings/settings_state.dart @@ -11,6 +11,7 @@ class SettingsState extends Equatable { required this.weakPasswordsAllowed, required this.hideZeroBalanceAssets, required this.diagnosticLoggingEnabled, + required this.hideBalances, }); factory SettingsState.fromStored(StoredSettings stored) { @@ -21,6 +22,7 @@ class SettingsState extends Equatable { weakPasswordsAllowed: stored.weakPasswordsAllowed, hideZeroBalanceAssets: stored.hideZeroBalanceAssets, diagnosticLoggingEnabled: stored.diagnosticLoggingEnabled, + hideBalances: stored.hideBalances, ); } @@ -30,16 +32,18 @@ class SettingsState extends Equatable { final bool weakPasswordsAllowed; final bool hideZeroBalanceAssets; final bool diagnosticLoggingEnabled; + final bool hideBalances; @override List get props => [ - themeMode, - mmBotSettings, - testCoinsEnabled, - weakPasswordsAllowed, - hideZeroBalanceAssets, - diagnosticLoggingEnabled, - ]; + themeMode, + mmBotSettings, + testCoinsEnabled, + weakPasswordsAllowed, + hideZeroBalanceAssets, + diagnosticLoggingEnabled, + hideBalances, + ]; SettingsState copyWith({ ThemeMode? mode, @@ -48,6 +52,7 @@ class SettingsState extends Equatable { bool? weakPasswordsAllowed, bool? hideZeroBalanceAssets, bool? diagnosticLoggingEnabled, + bool? hideBalances, }) { return SettingsState( themeMode: mode ?? themeMode, @@ -58,6 +63,7 @@ class SettingsState extends Equatable { hideZeroBalanceAssets ?? this.hideZeroBalanceAssets, diagnosticLoggingEnabled: diagnosticLoggingEnabled ?? this.diagnosticLoggingEnabled, + hideBalances: hideBalances ?? this.hideBalances, ); } } diff --git a/lib/bloc/system_health/providers/binance_time_provider.dart b/lib/bloc/system_health/providers/binance_time_provider.dart index 1f82d8c947..33dfcf03fb 100644 --- a/lib/bloc/system_health/providers/binance_time_provider.dart +++ b/lib/bloc/system_health/providers/binance_time_provider.dart @@ -14,8 +14,8 @@ class BinanceTimeProvider extends TimeProvider { this.timeout = const Duration(seconds: 2), this.maxRetries = 3, Logger? logger, - }) : _httpClient = httpClient ?? http.Client(), - _logger = logger ?? Logger('BinanceTimeProvider'); + }) : _httpClient = httpClient ?? http.Client(), + _logger = logger ?? Logger('BinanceTimeProvider'); /// The URL of the Binance time API final String url; @@ -83,7 +83,7 @@ class BinanceTimeProvider extends TimeProvider { } final jsonData = jsonDecode(response.body) as Map; - final serverTime = jsonData['serverTime'] as int?; + final serverTime = (jsonData['serverTime'] as num?)?.toInt(); if (serverTime == null) { throw const FormatException( diff --git a/lib/bloc/taker_form/taker_bloc.dart b/lib/bloc/taker_form/taker_bloc.dart index 8f1a679ed1..c2584e8041 100644 --- a/lib/bloc/taker_form/taker_bloc.dart +++ b/lib/bloc/taker_form/taker_bloc.dart @@ -28,6 +28,7 @@ import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/data_from_service.dart'; import 'package:web_dex/model/dex_form_error.dart'; import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; import 'package:web_dex/model/trade_preimage.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -608,7 +609,12 @@ class TakerBloc extends Bloc { path: 'taker_bloc::_getFeesData', isError: true, ); - return DataFromService(error: TextError(error: 'Failed to request fees')); + return DataFromService( + error: TextError( + error: formatKdfUserFacingError(e), + technicalDetails: extractKdfTechnicalDetails(e), + ), + ); } } diff --git a/lib/bloc/taker_form/taker_validator.dart b/lib/bloc/taker_form/taker_validator.dart index 40a51fb6f1..f6c3f82f08 100644 --- a/lib/bloc/taker_form/taker_validator.dart +++ b/lib/bloc/taker_form/taker_validator.dart @@ -16,6 +16,7 @@ import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/data_from_service.dart'; import 'package:web_dex/model/dex_form_error.dart'; import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; import 'package:web_dex/model/trade_preimage.dart'; import 'package:web_dex/shared/utils/formatters.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -87,6 +88,10 @@ class TakerValidator { ); } else if (error is TradePreimageTransportError) { return DexFormError(error: LocaleKeys.notEnoughBalanceForGasError.tr()); + } else if (error is TradePreimageNoSuchCoinError) { + return DexFormError( + error: LocaleKeys.connectionToServersFailing.tr(args: [error.coin]), + ); } else if (error is TradePreimageVolumeTooLowError) { return DexFormError( error: LocaleKeys.lowTradeVolumeError.tr( @@ -296,7 +301,23 @@ class TakerValidator { } } + DataFromService? get _cachedPreimage { + final preimage = state.tradePreimage; + if (preimage == null) return null; + + final request = preimage.request; + if (state.sellCoin?.abbr != request.base) return null; + if (state.selectedOrder?.coin != request.rel) return null; + if (state.selectedOrder?.price != request.price) return null; + if (state.sellAmount != request.volume) return null; + + return DataFromService(data: preimage); + } + Future> _getPreimageData() async { + final cached = _cachedPreimage; + if (cached != null) return cached; + try { return await _dexRepo.getTradePreimage( state.sellCoin!.abbr, @@ -313,7 +334,10 @@ class TakerValidator { isError: true, ); return DataFromService( - error: TextError(error: 'Failed to request trade preimage'), + error: TextError( + error: formatKdfUserFacingError(e), + technicalDetails: extractKdfTechnicalDetails(e), + ), ); } } diff --git a/lib/bloc/transaction_history/transaction_history_bloc.dart b/lib/bloc/transaction_history/transaction_history_bloc.dart index d5663dc004..e40b8484e2 100644 --- a/lib/bloc/transaction_history/transaction_history_bloc.dart +++ b/lib/bloc/transaction_history/transaction_history_bloc.dart @@ -1,17 +1,15 @@ import 'dart:async'; import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; -import 'package:komodo_defi_sdk/src/activation/activation_exceptions.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/bloc/transaction_history/transaction_history_event.dart'; import 'package:web_dex/bloc/transaction_history/transaction_history_state.dart'; -import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/shared/utils/extensions/transaction_extensions.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; import 'package:web_dex/shared/utils/utils.dart'; class TransactionHistoryBloc @@ -27,18 +25,12 @@ class TransactionHistoryBloc final KomodoDefiSdk _sdk; StreamSubscription>? _historySubscription; - StreamSubscription? _newTransactionsSubscription; - // TODO: Remove or move to SDK - final Set _processedTxIds = {}; - // Stable in-memory clock for transactions that arrive with a zero timestamp. - // Ensures deterministic ordering of unconfirmed and just-confirmed items. - final Map _firstSeenAtById = {}; + String _errorMessageFrom(Object error) => formatKdfUserFacingError(error); @override Future close() async { await _historySubscription?.cancel(); - await _newTransactionsSubscription?.cancel(); return super.close(); } @@ -63,9 +55,6 @@ class TransactionHistoryBloc try { await _historySubscription?.cancel(); - await _newTransactionsSubscription?.cancel(); - _processedTxIds.clear(); - _firstSeenAtById.clear(); add(const TransactionHistoryStartedLoading()); final asset = _sdk.assets.available[event.coin.id]; @@ -78,48 +67,16 @@ class TransactionHistoryBloc await _sdk.pubkeys.getPubkeys(asset); final myAddresses = pubkeys.keys.map((p) => p.address).toSet(); - // Subscribe to historical transactions + Transaction sanitize(Transaction transaction) { + return transaction.sanitize(myAddresses); + } + + // High-level merged stream from SDK handles history + live updates. _historySubscription = _sdk.transactions - .getTransactionsStreamed(asset) + .watchTransactionHistoryMerged(asset, transform: sanitize) .listen( - (newTransactions) { - if (newTransactions.isEmpty) return; - - // Merge incoming batch by internalId, updating confirmations and other fields - final Map byId = { - for (final t in state.transactions) t.internalId: t, - }; - - for (final tx in newTransactions) { - final sanitized = tx.sanitize(myAddresses); - // Capture first-seen time for stable ordering where timestamp may be zero - _firstSeenAtById.putIfAbsent( - sanitized.internalId, - () => sanitized.timestamp.millisecondsSinceEpoch != 0 - ? sanitized.timestamp - : DateTime.now(), - ); - final existing = byId[sanitized.internalId]; - if (existing == null) { - byId[sanitized.internalId] = sanitized; - _processedTxIds.add(sanitized.internalId); - continue; - } - - // Update existing entry with fresher data (confirmations, blockHeight, fee, memo) - byId[sanitized.internalId] = existing.copyWith( - confirmations: sanitized.confirmations, - blockHeight: sanitized.blockHeight, - fee: sanitized.fee ?? existing.fee, - memo: sanitized.memo ?? existing.memo, - // Update the timestamp to change date from "Now" once tx - // is confirmed on the blockchain - timestamp: sanitized.timestamp, - ); - } - - final updatedTransactions = byId.values.toList() - ..sort(_compareTransactions); + (transactions) { + final updatedTransactions = transactions.toList(growable: true); if (event.coin.isErcType) { _flagTransactions(updatedTransactions, event.coin); @@ -130,19 +87,10 @@ class TransactionHistoryBloc onError: (error) { add( TransactionHistoryFailure( - error: TextError(error: LocaleKeys.somethingWrong.tr()), + error: TextError(error: _errorMessageFrom(error)), ), ); }, - onDone: () { - if (state.error == null && state.loading) { - add( - TransactionHistoryUpdated(transactions: state.transactions), - ); - } - // Once historical load is complete, start watching for new transactions - _subscribeToNewTransactions(asset, event.coin, myAddresses); - }, ); } catch (e, s) { log( @@ -152,81 +100,14 @@ class TransactionHistoryBloc trace: s, ); - String errorMessage; - if (e is ActivationFailedException) { - errorMessage = 'Asset activation failed: ${e.message}'; - } else { - errorMessage = LocaleKeys.somethingWrong.tr(); - } - - add(TransactionHistoryFailure(error: TextError(error: errorMessage))); + add( + TransactionHistoryFailure( + error: TextError(error: _errorMessageFrom(e)), + ), + ); } } - void _subscribeToNewTransactions( - Asset asset, - Coin coin, - Set myAddresses, - ) { - _newTransactionsSubscription = _sdk.transactions - .watchTransactions(asset) - .listen( - (newTransaction) { - final sanitized = newTransaction.sanitize(myAddresses); - // Capture first-seen time once for stable ordering when timestamp is zero - _firstSeenAtById.putIfAbsent( - sanitized.internalId, - () => sanitized.timestamp.millisecondsSinceEpoch != 0 - ? sanitized.timestamp - : DateTime.now(), - ); - - // Merge single update by internalId - final Map byId = { - for (final t in state.transactions) t.internalId: t, - }; - - final existing = byId[sanitized.internalId]; - if (existing == null) { - byId[sanitized.internalId] = sanitized; - } else { - byId[sanitized.internalId] = existing.copyWith( - confirmations: sanitized.confirmations, - blockHeight: sanitized.blockHeight, - fee: sanitized.fee ?? existing.fee, - memo: sanitized.memo ?? existing.memo, - // Update the timestamp to change date from "Now" once tx - // is confirmed on the blockchain - timestamp: sanitized.timestamp, - ); - } - - _processedTxIds.add(sanitized.internalId); - - final updatedTransactions = byId.values.toList() - ..sort(_compareTransactions); - - if (coin.isErcType) { - _flagTransactions(updatedTransactions, coin); - } - - add(TransactionHistoryUpdated(transactions: updatedTransactions)); - }, - onError: (error) { - String errorMessage; - if (error is ActivationFailedException) { - errorMessage = 'Asset activation failed: ${error.message}'; - } else { - errorMessage = LocaleKeys.somethingWrong.tr(); - } - - add( - TransactionHistoryFailure(error: TextError(error: errorMessage)), - ); - }, - ); - } - void _onUpdated( TransactionHistoryUpdated event, Emitter emit, @@ -247,117 +128,11 @@ class TransactionHistoryBloc ) { emit(state.copyWith(loading: false, error: event.error)); } - - int _compareTransactions(Transaction left, Transaction right) { - final unconfirmedTimestamp = DateTime.fromMillisecondsSinceEpoch(0); - if (right.timestamp == unconfirmedTimestamp) { - return 1; - } else if (left.timestamp == unconfirmedTimestamp) { - return -1; - } - return right.timestamp.compareTo(left.timestamp); - } } -// Instance comparator now used; legacy top-level comparator removed. - void _flagTransactions(List transactions, Coin coin) { if (!coin.isErcType) return; transactions.removeWhere( (tx) => tx.balanceChanges.totalAmount.toDouble() == 0.0, ); } - -class Pagination { - Pagination({this.fromId, this.pageNumber}); - final String? fromId; - final int? pageNumber; - - Map toJson() => { - if (fromId != null) 'FromId': fromId, - if (pageNumber != null) 'PageNumber': pageNumber, - }; -} - -/// Represents different ways to paginate transaction history -sealed class TransactionPagination { - const TransactionPagination(); - - /// Get the limit of transactions to return, if applicable - int? get limit; -} - -/// Standard page-based pagination -class PagePagination extends TransactionPagination { - const PagePagination({required this.pageNumber, required this.itemsPerPage}); - - final int pageNumber; - final int itemsPerPage; - - @override - int get limit => itemsPerPage; -} - -/// Pagination from a specific transaction ID -class TransactionBasedPagination extends TransactionPagination { - const TransactionBasedPagination({ - required this.fromId, - required this.itemCount, - }); - - final String fromId; - final int itemCount; - - @override - int get limit => itemCount; -} - -/// Pagination by block range -class BlockRangePagination extends TransactionPagination { - const BlockRangePagination({ - required this.fromBlock, - required this.toBlock, - this.maxItems, - }); - - final int fromBlock; - final int toBlock; - final int? maxItems; - - @override - int? get limit => maxItems; -} - -/// Pagination by timestamp range -class TimestampRangePagination extends TransactionPagination { - const TimestampRangePagination({ - required this.fromTimestamp, - required this.toTimestamp, - this.maxItems, - }); - - final DateTime fromTimestamp; - final DateTime toTimestamp; - final int? maxItems; - - @override - int? get limit => maxItems; -} - -/// Contract-specific pagination (e.g., for ERC20 token transfers) -class ContractEventPagination extends TransactionPagination { - const ContractEventPagination({ - required this.contractAddress, - required this.fromBlock, - this.toBlock, - this.maxItems, - }); - - final String contractAddress; - final int fromBlock; - final int? toBlock; - final int? maxItems; - - @override - int? get limit => maxItems; -} diff --git a/lib/bloc/version_info/version_info_bloc.dart b/lib/bloc/version_info/version_info_bloc.dart index 62abe28808..51c287ecfe 100644 --- a/lib/bloc/version_info/version_info_bloc.dart +++ b/lib/bloc/version_info/version_info_bloc.dart @@ -42,6 +42,7 @@ class VersionInfoBloc extends Bloc { final commitHash = packageInformation.commitHash != null ? _tryParseCommitHash(packageInformation.commitHash!) : null; + final buildDate = packageInformation.buildDate; _logger.info( 'Basic app info retrieved - Version: $appVersion, ' @@ -51,6 +52,7 @@ class VersionInfoBloc extends Bloc { var currentInfo = VersionInfoLoaded( appVersion: appVersion, commitHash: commitHash, + buildDate: buildDate, ); emit(currentInfo); diff --git a/lib/bloc/version_info/version_info_state.dart b/lib/bloc/version_info/version_info_state.dart index adfe964e7c..a8643bd26c 100644 --- a/lib/bloc/version_info/version_info_state.dart +++ b/lib/bloc/version_info/version_info_state.dart @@ -22,6 +22,7 @@ class VersionInfoLoaded extends VersionInfoState { const VersionInfoLoaded({ required this.appVersion, required this.commitHash, + this.buildDate, this.apiCommitHash, this.currentCoinsCommit, this.latestCoinsCommit, @@ -29,6 +30,7 @@ class VersionInfoLoaded extends VersionInfoState { final String? appVersion; final String? commitHash; + final String? buildDate; final String? apiCommitHash; final String? currentCoinsCommit; final String? latestCoinsCommit; @@ -36,6 +38,7 @@ class VersionInfoLoaded extends VersionInfoState { VersionInfoLoaded copyWith({ ValueGetter? appVersion, ValueGetter? commitHash, + ValueGetter? buildDate, ValueGetter? apiCommitHash, ValueGetter? currentCoinsCommit, ValueGetter? latestCoinsCommit, @@ -43,6 +46,7 @@ class VersionInfoLoaded extends VersionInfoState { return VersionInfoLoaded( appVersion: appVersion?.call() ?? this.appVersion, commitHash: commitHash?.call() ?? this.commitHash, + buildDate: buildDate?.call() ?? this.buildDate, apiCommitHash: apiCommitHash?.call() ?? this.apiCommitHash, currentCoinsCommit: currentCoinsCommit?.call() ?? this.currentCoinsCommit, latestCoinsCommit: latestCoinsCommit?.call() ?? this.latestCoinsCommit, @@ -53,6 +57,7 @@ class VersionInfoLoaded extends VersionInfoState { List get props => [ appVersion, commitHash, + buildDate, apiCommitHash, currentCoinsCommit, latestCoinsCommit, diff --git a/lib/bloc/withdraw_form/withdraw_form_bloc.dart b/lib/bloc/withdraw_form/withdraw_form_bloc.dart index a9d1877fdd..d8928edd9a 100644 --- a/lib/bloc/withdraw_form/withdraw_form_bloc.dart +++ b/lib/bloc/withdraw_form/withdraw_form_bloc.dart @@ -1,7 +1,11 @@ +import 'dart:async'; + +import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:logging/logging.dart'; import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; @@ -10,6 +14,7 @@ import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/services/fd_monitor_service.dart'; import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; import 'package:web_dex/shared/utils/platform_tuner.dart'; import 'package:collection/collection.dart'; @@ -20,8 +25,13 @@ export 'package:web_dex/bloc/withdraw_form/withdraw_form_step.dart'; import 'package:decimal/decimal.dart'; class WithdrawFormBloc extends Bloc { + static final Logger _logger = Logger('WithdrawFormBloc'); + static const _unsupportedSiaHardwareWalletMessage = + 'SIA is not supported for hardware wallets in this release.'; + final KomodoDefiSdk _sdk; final WalletType? _walletType; + Timer? _tronPreviewTimer; WithdrawFormBloc({ required Asset asset, @@ -38,23 +48,234 @@ class WithdrawFormBloc extends Bloc { amount: '0', ), ) { - on(_onRecipientChanged); + on( + _onRecipientChanged, + transformer: restartable(), + ); on(_onAmountChanged); on(_onSourceChanged); on(_onMaxAmountEnabled); on(_onCustomFeeEnabled); on(_onFeeChanged); + on(_onFeePriorityChanged); on(_onMemoChanged); on(_onIbcTransferEnabled); on(_onIbcChannelChanged); - on(_onPreviewSubmitted); - on(_onSubmitted); + on( + _onPreviewSubmitted, + transformer: droppable(), + ); + on(_onSubmitted, transformer: droppable()); + on(_onTronPreviewTicked); + on( + _onTronPreviewRefreshRequested, + transformer: droppable(), + ); on(_onCancelled); on(_onReset); + on(_onStepReverted); on(_onSourcesLoadRequested); + on(_onFeeOptionsRequested); on(_onConvertAddress); add(const WithdrawFormSourcesLoadRequested()); + add(const WithdrawFormFeeOptionsRequested()); + } + + bool _isTronAsset(Asset asset) => + asset.protocol is TrxProtocol || asset.protocol is Trc20Protocol; + + void _cancelTronPreviewTimer() { + _tronPreviewTimer?.cancel(); + _tronPreviewTimer = null; + } + + DateTime? _buildPreviewExpiryAt( + WithdrawFormState state, + WithdrawalPreview preview, + ) { + if (!_isTronAsset(state.asset)) { + return null; + } + + return DateTime.fromMillisecondsSinceEpoch( + preview.timestamp * 1000, + isUtc: true, + ).add( + const Duration(seconds: WithdrawFormState.tronPreviewExpirationSeconds), + ); + } + + int _calculatePreviewSecondsRemaining(DateTime expiryAt) { + final remainingMs = expiryAt + .difference(DateTime.now().toUtc()) + .inMilliseconds; + if (remainingMs <= 0) { + return 0; + } + + return (remainingMs / 1000).ceil(); + } + + void _startTronPreviewTimer(WithdrawFormState state) { + _cancelTronPreviewTimer(); + + if (!_isTronAsset(state.asset) || + state.step != WithdrawFormStep.confirm || + state.preview == null || + state.previewExpiresAt == null || + state.isPreviewExpired) { + return; + } + + _tronPreviewTimer = Timer.periodic(const Duration(seconds: 1), (_) { + add(const WithdrawFormTronPreviewTicked()); + }); + } + + TextError? _previewGuardError() { + if (_isUnsupportedSiaHardwareWalletFlow) { + return TextError(error: _unsupportedSiaHardwareWalletMessage); + } + + if (_isSelfTransfer) { + return TextError(error: LocaleKeys.cannotSendToSelf.tr()); + } + + return null; + } + + Future _generatePreview( + WithdrawFormState requestState, + ) async { + final params = requestState.toWithdrawParameters(); + return _sdk.withdrawals.previewWithdrawal(params); + } + + bool _matchesPreviewRequest( + WithdrawFormState requestState, + WithdrawFormState currentState, + ) { + final requestParams = requestState.toWithdrawParameters(); + final currentParams = currentState.toWithdrawParameters(); + if (requestParams == currentParams) { + return true; + } + + if (_isBackgroundFeePriorityDefault(requestState, currentState)) { + final requestWithDefaultFeePriority = requestState.copyWith( + selectedFeePriority: () => currentState.selectedFeePriority, + ); + return requestWithDefaultFeePriority.toWithdrawParameters() == + currentParams; + } + + return false; + } + + bool _isBackgroundFeePriorityDefault( + WithdrawFormState requestState, + WithdrawFormState currentState, + ) { + return !requestState.isCustomFee && + !currentState.isCustomFee && + requestState.selectedFeePriority == null && + currentState.selectedFeePriority == WithdrawalFeeLevel.medium && + currentState.feeOptions != null; + } + + void _emitPreviewState( + Emitter emit, + WithdrawFormState requestState, + WithdrawalPreview preview, { + required bool moveToConfirm, + }) { + final currentState = state; + if (!_matchesPreviewRequest(requestState, currentState)) { + emit( + currentState.copyWith( + isSending: false, + isPreviewRefreshing: false, + isAwaitingTrezorConfirmation: false, + ), + ); + _cancelTronPreviewTimer(); + return; + } + + final expiryAt = _buildPreviewExpiryAt(currentState, preview); + final secondsRemaining = expiryAt == null + ? null + : _calculatePreviewSecondsRemaining(expiryAt); + final isExpired = secondsRemaining != null && secondsRemaining <= 0; + final nextState = currentState.copyWith( + preview: () => preview, + step: moveToConfirm ? WithdrawFormStep.confirm : currentState.step, + previewError: () => null, + transactionError: () => null, + confirmStepError: () => isExpired + ? TextError(error: LocaleKeys.withdrawTronPreviewExpired.tr()) + : null, + isSending: false, + isPreviewRefreshing: false, + isPreviewExpired: isExpired, + previewExpiresAt: () => expiryAt, + previewSecondsRemaining: () => secondsRemaining, + isAwaitingTrezorConfirmation: false, + ); + + emit(nextState); + + if (isExpired) { + _cancelTronPreviewTimer(); + return; + } + + _startTronPreviewTimer(nextState); + } + + String _formatErrorMessage(Object error) { + final resolved = formatKdfUserFacingError(error); + return _normalizeCommonErrors(resolved); + } + + TextError _buildTextError(Object error) { + return TextError( + error: _formatErrorMessage(error), + technicalDetails: extractKdfTechnicalDetails(error), + ); + } + + String _normalizeCommonErrors(String message) { + final normalized = message.toLowerCase(); + + if (normalized.contains('cannot transfer') && + normalized.contains('to yourself')) { + return LocaleKeys.cannotSendToSelf.tr(); + } + + if (normalized.contains('insufficient') && + (normalized.contains('gas') || normalized.contains('fee'))) { + return LocaleKeys.notEnoughBalanceForGasError.tr(); + } + + if (normalized.contains('insufficient funds') || + normalized.contains('not sufficient balance')) { + return 'kdfErrorNotSufficientBalance'.tr(); + } + + if (normalized.contains('failed to fetch') || + normalized.contains('network error') || + normalized.contains('timed out') || + normalized.contains('timeout')) { + return 'kdfErrorTransport'.tr(); + } + + if (message.trim().isEmpty) { + return LocaleKeys.somethingWrong.tr(); + } + + return message; } Future _onSourcesLoadRequested( @@ -102,7 +323,10 @@ class WithdrawFormBloc extends Bloc { } catch (e) { emit( state.copyWith( - networkError: () => TextError(error: 'Failed to load addresses: $e'), + networkError: () => TextError( + error: _formatErrorMessage(e), + technicalDetails: extractKdfTechnicalDetails(e), + ), ), ); } @@ -111,15 +335,33 @@ class WithdrawFormBloc extends Bloc { FeeInfo? _getDefaultFee() { final protocol = state.asset.protocol; if (protocol is Erc20Protocol) { - return FeeInfo.ethGas( + return FeeInfo.ethGasEip1559( coin: state.asset.id.id, - gasPrice: Decimal.one, + maxFeePerGas: Decimal.parse('0.00000002'), + maxPriorityFeePerGas: Decimal.parse('0.000000001'), gas: 21000, ); - } else if (protocol is UtxoProtocol) { + } + if (protocol is QtumProtocol) { + return FeeInfo.qrc20Gas( + coin: state.asset.id.id, + gasPrice: Decimal.parse('0.00000040'), + gasLimit: 250000, + ); + } + if (protocol is TendermintProtocol) { + return FeeInfo.cosmosGas( + coin: state.asset.id.id, + gasPrice: Decimal.parse('0.025'), + gasLimit: 200000, + ); + } + if (protocol is UtxoProtocol) { + final decimals = state.asset.id.chainId.decimals ?? 8; + final feeAtomic = protocol.txFee ?? 10000; return FeeInfo.utxoFixed( coin: state.asset.id.id, - amount: Decimal.fromInt(protocol.txFee ?? 10000), + amount: _atomicToDecimal(feeAtomic, decimals), ); } return null; @@ -129,6 +371,8 @@ class WithdrawFormBloc extends Bloc { WithdrawFormRecipientChanged event, Emitter emit, ) async { + if (state.isSending || state.step != WithdrawFormStep.fill) return; + try { final trimmedAddress = event.address.trim(); @@ -159,6 +403,11 @@ class WithdrawFormBloc extends Bloc { asset: state.asset, address: result.convertedAddress, ); + if (state.isSending || + state.step != WithdrawFormStep.fill || + state.recipientAddress != trimmedAddress) { + return; + } final isMixedCaseAdddress = result.convertedAddress != trimmedAddress; if (validationResult.isValid) { @@ -181,6 +430,11 @@ class WithdrawFormBloc extends Bloc { asset: state.asset, address: trimmedAddress, ); + if (state.isSending || + state.step != WithdrawFormStep.fill || + state.recipientAddress != trimmedAddress) { + return; + } if (!validationResult.isValid) { emit( state.copyWith( @@ -205,8 +459,10 @@ class WithdrawFormBloc extends Bloc { emit( state.copyWith( recipientAddress: event.address.trim(), - recipientAddressError: () => - TextError(error: 'Address validation failed: $e'), + recipientAddressError: () => TextError( + error: _formatErrorMessage(e), + technicalDetails: extractKdfTechnicalDetails(e), + ), isMixedCaseAddress: false, ), ); @@ -222,6 +478,7 @@ class WithdrawFormBloc extends Bloc { WithdrawFormAmountChanged event, Emitter emit, ) { + if (state.isSending || state.step != WithdrawFormStep.fill) return; if (state.isMaxAmount) return; try { @@ -273,6 +530,7 @@ class WithdrawFormBloc extends Bloc { WithdrawFormSourceChanged event, Emitter emit, ) { + if (state.isSending || state.step != WithdrawFormStep.fill) return; final balance = event.address.balance; final updatedAmount = state.isMaxAmount ? balance.spendable.toString() @@ -294,10 +552,29 @@ class WithdrawFormBloc extends Bloc { } } - void _onMaxAmountEnabled( + Future _onMaxAmountEnabled( WithdrawFormMaxAmountEnabled event, Emitter emit, - ) { + ) async { + if (state.isSending || state.step != WithdrawFormStep.fill) return; + if (event.isEnabled && state.asset.id.parentId != null) { + final parentId = state.asset.id.parentId!; + final parentBalance = + _sdk.balances.lastKnown(parentId) ?? + await _sdk.balances.getBalance(parentId); + + if (parentBalance.spendable == Decimal.zero) { + emit( + state.copyWith( + isMaxAmount: false, + amountError: () => + TextError(error: LocaleKeys.notEnoughBalanceForGasError.tr()), + ), + ); + return; + } + } + final balance = state.selectedSourceAddress?.balance ?? state.pubkeys?.balance; final maxAmount = event.isEnabled @@ -318,12 +595,17 @@ class WithdrawFormBloc extends Bloc { WithdrawFormCustomFeeEnabled event, Emitter emit, ) { + if (state.isSending || state.step != WithdrawFormStep.fill) return; + final defaultPriority = + state.selectedFeePriority ?? + (state.feeOptions != null ? WithdrawalFeeLevel.medium : null); // If enabling custom fees, set a default fee or reuse from `_getDefaultFee()` emit( state.copyWith( isCustomFee: event.isEnabled, customFee: event.isEnabled ? () => _getDefaultFee() : () => null, customFeeError: () => null, + selectedFeePriority: () => event.isEnabled ? null : defaultPriority, ), ); } @@ -332,13 +614,9 @@ class WithdrawFormBloc extends Bloc { WithdrawFormCustomFeeChanged event, Emitter emit, ) { + if (state.isSending || state.step != WithdrawFormStep.fill) return; try { - // Validate the new fee, e.g. if it's EthGas => check gasPrice, gas > 0, etc. - if (event.fee is FeeInfoEthGas) { - _validateEvmFee(event.fee as FeeInfoEthGas); - } else if (event.fee is FeeInfoUtxoFixed) { - _validateUtxoFee(event.fee as FeeInfoUtxoFixed); - } + _validateFee(event.fee); emit( state.copyWith(customFee: () => event.fee, customFeeError: () => null), ); @@ -349,25 +627,115 @@ class WithdrawFormBloc extends Bloc { } } - void _validateEvmFee(FeeInfoEthGas fee) { - if (fee.gasPrice <= Decimal.zero) { - throw Exception('Gas price must be greater than 0'); - } - if (fee.gas <= 0) { - throw Exception('Gas limit must be greater than 0'); - } + void _onFeePriorityChanged( + WithdrawFormFeePriorityChanged event, + Emitter emit, + ) { + if (state.isSending || state.step != WithdrawFormStep.fill) return; + emit( + state.copyWith( + selectedFeePriority: () => event.priority, + isCustomFee: false, + customFee: () => null, + customFeeError: () => null, + ), + ); } - void _validateUtxoFee(FeeInfoUtxoFixed fee) { - if (fee.amount <= Decimal.zero) { - throw Exception('Fee amount must be greater than 0'); + Future _onFeeOptionsRequested( + WithdrawFormFeeOptionsRequested event, + Emitter emit, + ) async { + try { + final feeOptions = await _sdk.withdrawals.getFeeOptions( + state.asset.id.id, + ); + final shouldSelectDefault = + !state.isCustomFee && + state.selectedFeePriority == null && + feeOptions != null; + emit( + state.copyWith( + feeOptions: () => feeOptions, + selectedFeePriority: () => shouldSelectDefault + ? WithdrawalFeeLevel.medium + : state.selectedFeePriority, + ), + ); + } catch (_) { + emit(state.copyWith(feeOptions: () => null)); } } + void _validateFee(FeeInfo fee) { + fee.map( + utxoFixed: (utxo) { + if (utxo.amount <= Decimal.zero) { + throw Exception('Fee amount must be greater than 0'); + } + }, + utxoPerKbyte: (utxo) { + if (utxo.amount <= Decimal.zero) { + throw Exception('Fee amount must be greater than 0'); + } + }, + ethGas: (eth) { + if (eth.gasPrice <= Decimal.zero) { + throw Exception('Gas price must be greater than 0'); + } + if (eth.gas <= 0) { + throw Exception('Gas limit must be greater than 0'); + } + }, + ethGasEip1559: (eth) { + if (eth.maxFeePerGas <= Decimal.zero || + eth.maxPriorityFeePerGas <= Decimal.zero) { + throw Exception('Gas fee values must be greater than 0'); + } + if (eth.gas <= 0) { + throw Exception('Gas limit must be greater than 0'); + } + }, + qrc20Gas: (qrc) { + if (qrc.gasPrice <= Decimal.zero) { + throw Exception('Gas price must be greater than 0'); + } + if (qrc.gasLimit <= 0) { + throw Exception('Gas limit must be greater than 0'); + } + }, + cosmosGas: (cosmos) { + if (cosmos.gasPrice <= Decimal.zero) { + throw Exception('Gas price must be greater than 0'); + } + if (cosmos.gasLimit <= 0) { + throw Exception('Gas limit must be greater than 0'); + } + }, + tendermint: (tendermint) { + if (tendermint.amount <= Decimal.zero) { + throw Exception('Fee amount must be greater than 0'); + } + if (tendermint.gasLimit <= 0) { + throw Exception('Gas limit must be greater than 0'); + } + }, + tron: (_) { + throw Exception('Custom TRON fees are not supported'); + }, + sia: (sia) { + if (sia.amount <= Decimal.zero) { + throw Exception('Fee amount must be greater than 0'); + } + }, + ); + } + void _onMemoChanged( WithdrawFormMemoChanged event, Emitter emit, ) { + if (state.isSending || state.step != WithdrawFormStep.fill) return; emit(state.copyWith(memo: () => event.memo)); } @@ -375,6 +743,7 @@ class WithdrawFormBloc extends Bloc { WithdrawFormIbcTransferEnabled event, Emitter emit, ) { + if (state.isSending || state.step != WithdrawFormStep.fill) return; emit( state.copyWith( isIbcTransfer: event.isEnabled, @@ -388,6 +757,7 @@ class WithdrawFormBloc extends Bloc { WithdrawFormIbcChannelChanged event, Emitter emit, ) { + if (state.isSending || state.step != WithdrawFormStep.fill) return; if (event.channel.isEmpty) { emit( state.copyWith( @@ -411,51 +781,178 @@ class WithdrawFormBloc extends Bloc { WithdrawFormPreviewSubmitted event, Emitter emit, ) async { - if (state.hasValidationErrors) return; + final requestState = state; + if (requestState.hasValidationErrors) return; + final guardError = _previewGuardError(); + if (guardError != null) { + emit( + requestState.copyWith( + previewError: () => guardError, + isSending: false, + isAwaitingTrezorConfirmation: false, + ), + ); + return; + } try { + _cancelTronPreviewTimer(); + emit( - state.copyWith( + requestState.copyWith( isSending: true, previewError: () => null, - isAwaitingTrezorConfirmation: false, + confirmStepError: () => null, + isPreviewRefreshing: false, + isPreviewExpired: false, + previewExpiresAt: () => null, + previewSecondsRemaining: () => null, + isAwaitingTrezorConfirmation: _walletType == WalletType.trezor, ), ); - // For Trezor wallets, the preview generation might require user interaction - if (_walletType == WalletType.trezor) { - emit(state.copyWith(isAwaitingTrezorConfirmation: true)); + final preview = await _generatePreview(requestState); + _emitPreviewState(emit, requestState, preview, moveToConfirm: true); + } catch (e) { + _cancelTronPreviewTimer(); + + // Capture FD snapshot when KDF withdrawal preview fails + if (PlatformTuner.isIOS) { + try { + await FdMonitorService().logDetailedStatus(); + final stats = await FdMonitorService().getCurrentCount(); + _logger.info( + 'FD stats at withdrawal preview failure for ${state.asset.id.id}: $stats', + ); + } catch (fdError, fdStackTrace) { + _logger.warning('Failed to capture FD stats', fdError, fdStackTrace); + } } - final preview = await _sdk.withdrawals.previewWithdrawal( - state.toWithdrawParameters(), - ); + if (!_matchesPreviewRequest(requestState, state)) { + emit( + state.copyWith( + isSending: false, + isPreviewRefreshing: false, + isAwaitingTrezorConfirmation: false, + ), + ); + return; + } emit( state.copyWith( - preview: () => preview, - step: WithdrawFormStep.confirm, + previewError: () => _buildTextError(e), isSending: false, + isPreviewRefreshing: false, isAwaitingTrezorConfirmation: false, ), ); + } + } + + void _onTronPreviewTicked( + WithdrawFormTronPreviewTicked event, + Emitter emit, + ) { + if (!_isTronAsset(state.asset) || + state.step != WithdrawFormStep.confirm || + state.preview == null) { + _cancelTronPreviewTimer(); + return; + } + + final expiryAt = state.previewExpiresAt; + if (expiryAt == null) { + _cancelTronPreviewTimer(); + return; + } + + final secondsRemaining = _calculatePreviewSecondsRemaining(expiryAt); + if (secondsRemaining > 0) { + if (secondsRemaining != state.previewSecondsRemaining) { + emit( + state.copyWith( + previewSecondsRemaining: () => secondsRemaining, + isPreviewExpired: false, + ), + ); + } + return; + } + + _cancelTronPreviewTimer(); + if (state.isPreviewRefreshing) { + return; + } + + emit( + state.copyWith(previewSecondsRemaining: () => 0, isPreviewExpired: true), + ); + add(const WithdrawFormTronPreviewRefreshRequested(isAutomatic: true)); + } + + Future _onTronPreviewRefreshRequested( + WithdrawFormTronPreviewRefreshRequested event, + Emitter emit, + ) async { + final requestState = state; + if (!_isTronAsset(requestState.asset) || + requestState.step != WithdrawFormStep.confirm || + requestState.preview == null || + requestState.isSending || + requestState.isPreviewRefreshing) { + return; + } + + final guardError = _previewGuardError(); + if (guardError != null) { + emit( + requestState.copyWith( + isPreviewRefreshing: false, + isPreviewExpired: true, + previewSecondsRemaining: () => 0, + confirmStepError: () => guardError, + isAwaitingTrezorConfirmation: false, + ), + ); + return; + } + + try { + _cancelTronPreviewTimer(); + + emit( + requestState.copyWith( + isPreviewRefreshing: true, + isPreviewExpired: true, + previewSecondsRemaining: () => 0, + confirmStepError: () => null, + transactionError: () => null, + isAwaitingTrezorConfirmation: _walletType == WalletType.trezor, + ), + ); + + final preview = await _generatePreview(requestState); + _emitPreviewState(emit, requestState, preview, moveToConfirm: false); } catch (e) { - // Capture FD snapshot when KDF withdrawal preview fails - if (PlatformTuner.isIOS) { - try { - await FdMonitorService().logDetailedStatus(); - final stats = await FdMonitorService().getCurrentCount(); - print('FD stats at withdrawal preview failure for ${state.asset.id.id}: $stats'); - } catch (fdError) { - print('Failed to capture FD stats: $fdError'); - } + if (!_matchesPreviewRequest(requestState, state)) { + emit( + state.copyWith( + isSending: false, + isPreviewRefreshing: false, + isAwaitingTrezorConfirmation: false, + ), + ); + return; } - + emit( state.copyWith( - previewError: () => - TextError(error: 'Failed to generate preview: $e'), - isSending: false, + isPreviewRefreshing: false, + isPreviewExpired: true, + previewSecondsRemaining: () => 0, + confirmStepError: () => _buildTextError(e), isAwaitingTrezorConfirmation: false, ), ); @@ -467,12 +964,42 @@ class WithdrawFormBloc extends Bloc { Emitter emit, ) async { if (state.hasValidationErrors) return; + if (_isUnsupportedSiaHardwareWalletFlow) { + emit( + state.copyWith( + transactionError: () => + TextError(error: _unsupportedSiaHardwareWalletMessage), + isSending: false, + isAwaitingTrezorConfirmation: false, + ), + ); + return; + } + + if (_isTronAsset(state.asset) && + (state.isPreviewRefreshing || + state.isPreviewExpired || + state.previewSecondsRemaining == null || + state.previewSecondsRemaining == 0 || + state.hasConfirmStepError)) { + emit( + state.copyWith( + confirmStepError: () => + TextError(error: LocaleKeys.withdrawTronPreviewExpired.tr()), + isSending: false, + ), + ); + return; + } try { + _cancelTronPreviewTimer(); + emit( state.copyWith( isSending: true, transactionError: () => null, + confirmStepError: () => null, // No second device interaction is needed on confirm isAwaitingTrezorConfirmation: false, ), @@ -494,6 +1021,9 @@ class WithdrawFormBloc extends Bloc { result = progress.withdrawalResult; break; } else if (progress.status == WithdrawalStatus.error) { + if (progress.sdkError != null) { + throw progress.sdkError!; + } throw Exception(progress.errorMessage ?? 'Broadcast failed'); } // Continue for in-progress states @@ -504,7 +1034,7 @@ class WithdrawFormBloc extends Bloc { state.copyWith( isSending: false, transactionError: () => TextError( - error: 'Withdrawal did not complete: no result received.' + error: 'Withdrawal did not complete: no result received.', ), isAwaitingTrezorConfirmation: false, ), @@ -519,33 +1049,52 @@ class WithdrawFormBloc extends Bloc { // Clear cached preview after successful broadcast preview: () => null, isSending: false, + previewExpiresAt: () => null, + previewSecondsRemaining: () => null, + isPreviewExpired: false, + isPreviewRefreshing: false, isAwaitingTrezorConfirmation: false, ), ); return; } catch (e) { + _cancelTronPreviewTimer(); + // Capture FD snapshot when KDF withdrawal submission fails if (PlatformTuner.isIOS) { try { await FdMonitorService().logDetailedStatus(); final stats = await FdMonitorService().getCurrentCount(); - print('FD stats at withdrawal submission failure for ${state.asset.id.id}: $stats'); - } catch (fdError) { - print('Failed to capture FD stats: $fdError'); + _logger.info( + 'FD stats at withdrawal submission failure for ${state.asset.id.id}: $stats', + ); + } catch (fdError, fdStackTrace) { + _logger.warning('Failed to capture FD stats', fdError, fdStackTrace); } } - + emit( state.copyWith( - transactionError: () => TextError(error: 'Transaction failed: $e'), + transactionError: () => _buildTextError(e), step: WithdrawFormStep.failed, isSending: false, + isPreviewRefreshing: false, isAwaitingTrezorConfirmation: false, ), ); } } + bool get _isUnsupportedSiaHardwareWalletFlow => + _walletType == WalletType.trezor && state.asset.protocol is SiaProtocol; + + bool get _isSelfTransfer { + final source = state.selectedSourceAddress?.address; + final recipient = state.recipientAddress.trim(); + if (source == null || recipient.isEmpty) return false; + return source == recipient; + } + void _onCancelled( WithdrawFormCancelled event, Emitter emit, @@ -556,6 +1105,7 @@ class WithdrawFormBloc extends Bloc { } void _onReset(WithdrawFormReset event, Emitter emit) { + _cancelTronPreviewTimer(); emit( WithdrawFormState( asset: state.asset, @@ -568,6 +1118,86 @@ class WithdrawFormBloc extends Bloc { ); } + void _onStepReverted( + WithdrawFormStepReverted event, + Emitter emit, + ) { + if (state.isSending || state.isPreviewRefreshing) { + return; + } + + if (state.step == WithdrawFormStep.confirm) { + _cancelTronPreviewTimer(); + emit( + state.copyWith( + step: WithdrawFormStep.fill, + preview: () => null, + previewError: () => null, + transactionError: () => null, + confirmStepError: () => null, + isSending: false, + previewExpiresAt: () => null, + previewSecondsRemaining: () => null, + isPreviewExpired: false, + isPreviewRefreshing: false, + isAwaitingTrezorConfirmation: false, + ), + ); + return; + } + + if (state.step != WithdrawFormStep.failed) return; + + final nextStep = state.preview != null + ? WithdrawFormStep.confirm + : WithdrawFormStep.fill; + + if (nextStep == WithdrawFormStep.confirm && + _isTronAsset(state.asset) && + state.preview != null) { + final expiryAt = _buildPreviewExpiryAt(state, state.preview!); + final secondsRemaining = expiryAt == null + ? null + : _calculatePreviewSecondsRemaining(expiryAt); + final isExpired = secondsRemaining != null && secondsRemaining <= 0; + + final nextState = state.copyWith( + step: nextStep, + transactionError: () => null, + confirmStepError: () => isExpired + ? TextError(error: LocaleKeys.withdrawTronPreviewExpired.tr()) + : null, + isSending: false, + previewExpiresAt: () => expiryAt, + previewSecondsRemaining: () => secondsRemaining, + isPreviewExpired: isExpired, + isPreviewRefreshing: false, + isAwaitingTrezorConfirmation: false, + ); + emit(nextState); + + if (!isExpired) { + _startTronPreviewTimer(nextState); + } + return; + } + + _cancelTronPreviewTimer(); + emit( + state.copyWith( + step: nextStep, + transactionError: () => null, + confirmStepError: () => null, + isSending: false, + previewExpiresAt: () => null, + previewSecondsRemaining: () => null, + isPreviewExpired: false, + isPreviewRefreshing: false, + isAwaitingTrezorConfirmation: false, + ), + ); + } + bool _hasEthAddressMixedCase(String address) { if (!address.startsWith('0x')) return false; final chars = address.substring(2).split(''); @@ -579,6 +1209,7 @@ class WithdrawFormBloc extends Bloc { WithdrawFormConvertAddressRequested event, Emitter emit, ) async { + if (state.isSending || state.step != WithdrawFormStep.fill) return; if (state.isMixedCaseAddress) return; try { @@ -602,13 +1233,27 @@ class WithdrawFormBloc extends Bloc { } catch (e) { emit( state.copyWith( - recipientAddressError: () => - TextError(error: 'Failed to convert address: $e'), + recipientAddressError: () => TextError( + error: _formatErrorMessage(e), + technicalDetails: extractKdfTechnicalDetails(e), + ), isSending: false, ), ); } } + + Decimal _atomicToDecimal(int amount, int decimals) { + if (decimals <= 0) return Decimal.fromInt(amount); + final scale = Decimal.parse('1${'0' * decimals}'); + return (Decimal.fromInt(amount) / scale).toDecimal(); + } + + @override + Future close() { + _cancelTronPreviewTimer(); + return super.close(); + } } class MixedCaseAddressError extends BaseError { diff --git a/lib/bloc/withdraw_form/withdraw_form_event.dart b/lib/bloc/withdraw_form/withdraw_form_event.dart index 54ed172596..f0a5ae4038 100644 --- a/lib/bloc/withdraw_form/withdraw_form_event.dart +++ b/lib/bloc/withdraw_form/withdraw_form_event.dart @@ -34,6 +34,11 @@ class WithdrawFormCustomFeeChanged extends WithdrawFormEvent { const WithdrawFormCustomFeeChanged(this.fee); } +class WithdrawFormFeePriorityChanged extends WithdrawFormEvent { + final WithdrawalFeeLevel? priority; + const WithdrawFormFeePriorityChanged(this.priority); +} + class WithdrawFormMemoChanged extends WithdrawFormEvent { final String? memo; const WithdrawFormMemoChanged(this.memo); @@ -47,6 +52,16 @@ class WithdrawFormSubmitted extends WithdrawFormEvent { const WithdrawFormSubmitted(); } +class WithdrawFormTronPreviewTicked extends WithdrawFormEvent { + const WithdrawFormTronPreviewTicked(); +} + +class WithdrawFormTronPreviewRefreshRequested extends WithdrawFormEvent { + final bool isAutomatic; + + const WithdrawFormTronPreviewRefreshRequested({this.isAutomatic = false}); +} + class WithdrawFormCancelled extends WithdrawFormEvent { const WithdrawFormCancelled(); } @@ -69,6 +84,10 @@ class WithdrawFormSourcesLoadRequested extends WithdrawFormEvent { const WithdrawFormSourcesLoadRequested(); } +class WithdrawFormFeeOptionsRequested extends WithdrawFormEvent { + const WithdrawFormFeeOptionsRequested(); +} + class WithdrawFormStepReverted extends WithdrawFormEvent { const WithdrawFormStepReverted(); } diff --git a/lib/bloc/withdraw_form/withdraw_form_state.dart b/lib/bloc/withdraw_form/withdraw_form_state.dart index 421dedfe0e..b40e393a2f 100644 --- a/lib/bloc/withdraw_form/withdraw_form_state.dart +++ b/lib/bloc/withdraw_form/withdraw_form_state.dart @@ -2,11 +2,14 @@ import 'package:decimal/decimal.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:komodo_ui/utils.dart'; import 'package:web_dex/bloc/withdraw_form/withdraw_form_step.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/shared/utils/formatters.dart'; class WithdrawFormState extends Equatable { + static const int tronPreviewExpirationSeconds = 60; + final Asset asset; final AssetPubkeys? pubkeys; final WithdrawFormStep step; @@ -21,6 +24,8 @@ class WithdrawFormState extends Equatable { final String? memo; final bool isIbcTransfer; final String? ibcChannel; + final WithdrawalFeeOptions? feeOptions; + final WithdrawalFeeLevel? selectedFeePriority; // Transaction state final WithdrawalPreview? preview; @@ -40,13 +45,33 @@ class WithdrawFormState extends Equatable { // Network/Transaction errors final TextError? previewError; // Errors during preview generation final TextError? transactionError; // Errors during transaction submission + final TextError? + confirmStepError; // Errors while refreshing an expired TRON preview final TextError? networkError; // Network connectivity errors + // TRON confirm preview lifetime + final DateTime? previewExpiresAt; + final int? previewSecondsRemaining; + final bool isPreviewExpired; + final bool isPreviewRefreshing; + bool get isCustomFeeSupported => - asset.protocol is UtxoProtocol || asset.protocol is Erc20Protocol; + asset.protocol is UtxoProtocol || + asset.protocol is Erc20Protocol || + asset.protocol is QtumProtocol || + asset.protocol is TendermintProtocol; + + bool get isPriorityFeeSupported => + asset.protocol is Erc20Protocol || + asset.protocol is QtumProtocol || + asset.protocol is TendermintProtocol; + + bool get isTronAsset => + asset.protocol is TrxProtocol || asset.protocol is Trc20Protocol; bool get hasPreviewError => previewError != null; bool get hasTransactionError => transactionError != null; + bool get hasConfirmStepError => confirmStepError != null; bool get hasAddressError => recipientAddressError != null; bool get hasValidationErrors => hasAddressError || @@ -107,6 +132,8 @@ class WithdrawFormState extends Equatable { this.memo, this.isIbcTransfer = false, this.ibcChannel, + this.feeOptions, + this.selectedFeePriority, this.preview, this.isSending = false, this.result, @@ -120,7 +147,12 @@ class WithdrawFormState extends Equatable { this.ibcChannelError, this.previewError, this.transactionError, + this.confirmStepError, this.networkError, + this.previewExpiresAt, + this.previewSecondsRemaining, + this.isPreviewExpired = false, + this.isPreviewRefreshing = false, }); WithdrawFormState copyWith({ @@ -136,6 +168,8 @@ class WithdrawFormState extends Equatable { ValueGetter? memo, bool? isIbcTransfer, ValueGetter? ibcChannel, + ValueGetter? feeOptions, + ValueGetter? selectedFeePriority, ValueGetter? preview, bool? isSending, ValueGetter? result, @@ -149,7 +183,12 @@ class WithdrawFormState extends Equatable { ValueGetter? ibcChannelError, ValueGetter? previewError, ValueGetter? transactionError, + ValueGetter? confirmStepError, ValueGetter? networkError, + ValueGetter? previewExpiresAt, + ValueGetter? previewSecondsRemaining, + bool? isPreviewExpired, + bool? isPreviewRefreshing, }) { return WithdrawFormState( asset: asset ?? this.asset, @@ -166,6 +205,10 @@ class WithdrawFormState extends Equatable { memo: memo != null ? memo() : this.memo, isIbcTransfer: isIbcTransfer ?? this.isIbcTransfer, ibcChannel: ibcChannel != null ? ibcChannel() : this.ibcChannel, + feeOptions: feeOptions != null ? feeOptions() : this.feeOptions, + selectedFeePriority: selectedFeePriority != null + ? selectedFeePriority() + : this.selectedFeePriority, preview: preview != null ? preview() : this.preview, isSending: isSending ?? this.isSending, result: result != null ? result() : this.result, @@ -188,11 +231,27 @@ class WithdrawFormState extends Equatable { transactionError: transactionError != null ? transactionError() : this.transactionError, + confirmStepError: confirmStepError != null + ? confirmStepError() + : this.confirmStepError, networkError: networkError != null ? networkError() : this.networkError, + previewExpiresAt: previewExpiresAt != null + ? previewExpiresAt() + : this.previewExpiresAt, + previewSecondsRemaining: previewSecondsRemaining != null + ? previewSecondsRemaining() + : this.previewSecondsRemaining, + isPreviewExpired: isPreviewExpired ?? this.isPreviewExpired, + isPreviewRefreshing: isPreviewRefreshing ?? this.isPreviewRefreshing, ); } WithdrawParameters toWithdrawParameters() { + final derivationPath = selectedSourceAddress?.derivationPath; + final supportsHdSourceSelection = + asset.protocol.supportsMultipleAddresses && + asset.protocol is! SiaProtocol; + return WithdrawParameters( asset: asset.id.id, toAddress: recipientAddress, @@ -200,28 +259,27 @@ class WithdrawFormState extends Equatable { ? null : Decimal.parse(normalizeDecimalString(amount)), fee: isCustomFee ? customFee : null, - from: selectedSourceAddress?.derivationPath != null - ? WithdrawalSource.hdDerivationPath( - selectedSourceAddress!.derivationPath!, - ) + feePriority: isCustomFee ? null : selectedFeePriority, + from: supportsHdSourceSelection && derivationPath != null + ? WithdrawalSource.hdDerivationPath(derivationPath) : null, memo: memo, ibcTransfer: isIbcTransfer ? true : null, ibcSourceChannel: ibcChannel?.isNotEmpty == true ? int.tryParse(ibcChannel!.trim()) : null, + expirationSeconds: isTronAsset ? tronPreviewExpirationSeconds : null, isMax: isMaxAmount, ); } //TODO! - double? get usdFeePrice => 0.0; + double? get usdFeePrice => null; //TODO! - double? get usdAmountPrice => 0.0; + double? get usdAmountPrice => null; - //TODO! - bool get isFeePriceExpensive => false; + bool get isFeePriceExpensive => preview?.fee.isHighFee ?? false; @override List get props => [ @@ -237,6 +295,8 @@ class WithdrawFormState extends Equatable { memo, isIbcTransfer, ibcChannel, + feeOptions, + selectedFeePriority, preview, isSending, result, @@ -248,6 +308,11 @@ class WithdrawFormState extends Equatable { ibcChannelError, previewError, transactionError, + confirmStepError, networkError, + previewExpiresAt, + previewSecondsRemaining, + isPreviewExpired, + isPreviewRefreshing, ]; } diff --git a/lib/blocs/orderbook_bloc.dart b/lib/blocs/orderbook_bloc.dart index d06174ed88..fc5955db06 100644 --- a/lib/blocs/orderbook_bloc.dart +++ b/lib/blocs/orderbook_bloc.dart @@ -1,23 +1,25 @@ import 'dart:async'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' - show OrderbookResponse; -import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; + show NumericValue, OrderInfo, OrderbookResponse; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart' + show KomodoDefiSdk, OrderbookEvent; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/blocs/bloc_base.dart'; import 'package:web_dex/shared/utils/utils.dart'; class OrderbookBloc implements BlocBase { - OrderbookBloc({required KomodoDefiSdk sdk}) { - _sdk = sdk; - + OrderbookBloc({required KomodoDefiSdk sdk}) : _sdk = sdk { _timer = Timer.periodic( - const Duration(seconds: 3), + _fallbackPollingInterval, (_) async => await _updateOrderbooks(), ); } - late KomodoDefiSdk _sdk; + static const Duration _fallbackPollingInterval = Duration(seconds: 15); + static const Duration _streamStaleTimeout = Duration(seconds: 20); + + final KomodoDefiSdk _sdk; Timer? _timer; // keys are 'base/rel' Strings @@ -26,7 +28,11 @@ class OrderbookBloc implements BlocBase { @override void dispose() { _timer?.cancel(); - _subscriptions.forEach((pair, subs) => subs.controller.close()); + + final pairs = _subscriptions.keys.toList(); + for (final pair in pairs) { + _removeSubscription(pair).ignore(); + } } OrderbookResult? getInitialData(String base, String rel) { @@ -54,13 +60,15 @@ class OrderbookBloc implements BlocBase { stream: stream, ); - _fetchOrderbook(pair); + _ensureStreamSubscription(pair); + _fetchOrderbook(pair).ignore(); return _subscriptions[pair]!.stream; } } Future _updateOrderbooks() async { final List pairs = List.of(_subscriptions.keys); + final List pairsWithoutListeners = []; for (String pair in pairs) { final OrderbookSubscription? subscription = _subscriptions[pair]; @@ -68,11 +76,104 @@ class OrderbookBloc implements BlocBase { continue; } if (!subscription.controller.hasListener) { + pairsWithoutListeners.add(pair); + continue; + } + + final lastUpdateAt = subscription.lastUpdateAt; + final hasFreshStreamUpdate = + lastUpdateAt != null && + DateTime.now().difference(lastUpdateAt) < _streamStaleTimeout; + if (hasFreshStreamUpdate) { continue; } await _fetchOrderbook(pair); } + + for (final pair in pairsWithoutListeners) { + await _removeSubscription(pair); + } + } + + Future _removeSubscription(String pair) async { + final subscription = _subscriptions.remove(pair); + if (subscription == null) return; + + await subscription.streamSubscription?.cancel(); + await subscription.controller.close(); + } + + void _ensureStreamSubscription(String pair) { + final subscription = _subscriptions[pair]; + if (subscription == null) return; + if (subscription.streamSubscription != null || + subscription.streamInitializing) { + return; + } + + final coins = pair.split('/'); + subscription.streamInitializing = true; + + () async { + try { + final streamSubscription = await _sdk.subscribeToOrderbook( + base: coins[0], + rel: coins[1], + ); + + streamSubscription + ..onData((event) => _onOrderbookEvent(pair, event)) + ..onError((Object error, StackTrace trace) { + log( + 'Orderbook stream error for pair $pair', + path: 'OrderbookBloc._ensureStreamSubscription', + trace: trace, + isError: true, + ).ignore(); + }); + + final activeSubscription = _subscriptions[pair]; + if (activeSubscription == null) { + await streamSubscription.cancel(); + return; + } + activeSubscription.streamSubscription = streamSubscription; + } catch (e, s) { + log( + 'Failed to subscribe orderbook stream for pair $pair', + path: 'OrderbookBloc._ensureStreamSubscription', + trace: s, + isError: true, + ).ignore(); + } finally { + final activeSubscription = _subscriptions[pair]; + if (activeSubscription != null) { + activeSubscription.streamInitializing = false; + } + } + }().ignore(); + } + + void _onOrderbookEvent(String pair, OrderbookEvent event) { + final subscription = _subscriptions[pair]; + if (subscription == null) return; + + try { + final response = _mapStreamEventToResponse(event); + final result = OrderbookResult(response: response); + subscription.initialData = result; + subscription.lastUpdateAt = DateTime.now(); + subscription.sink.add(result); + } catch (e, s) { + log( + 'Failed to map orderbook stream event for pair $pair', + path: 'OrderbookBloc._onOrderbookEvent', + trace: s, + isError: true, + ).ignore(); + _fetchOrderbook(pair).ignore(); + } } Future _fetchOrderbook(String pair) async { @@ -87,6 +188,7 @@ class OrderbookBloc implements BlocBase { final result = OrderbookResult(response: response); subscription.initialData = result; + subscription.lastUpdateAt = DateTime.now(); subscription.sink.add(result); } catch (e, s) { log( @@ -101,6 +203,46 @@ class OrderbookBloc implements BlocBase { subscription.sink.add(result); } } + + OrderbookResponse _mapStreamEventToResponse(OrderbookEvent event) { + final asks = event.asks.map(_mapOrderInfo).toList(); + final bids = event.bids.map(_mapOrderInfo).toList(); + + return OrderbookResponse( + mmrpc: '2.0', + base: event.base, + rel: event.rel, + bids: bids, + asks: asks, + numBids: bids.length, + numAsks: asks.length, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + + OrderInfo _mapOrderInfo(Map orderData) { + final price = orderData['price']?.toString(); + final maxVolume = orderData['max_volume']?.toString(); + + if (price == null || maxVolume == null) { + throw ArgumentError('Orderbook stream entry is missing price/max_volume'); + } + + final minVolume = orderData['min_volume']?.toString(); + final priceValue = NumericValue(decimal: price); + final maxVolumeValue = NumericValue(decimal: maxVolume); + + return OrderInfo( + uuid: orderData['uuid']?.toString(), + pubkey: orderData['pubkey']?.toString(), + price: priceValue, + baseMaxVolume: maxVolumeValue, + baseMaxVolumeAggregated: maxVolumeValue, + baseMinVolume: minVolume == null + ? null + : NumericValue(decimal: minVolume), + ); + } } class OrderbookSubscription { @@ -115,6 +257,9 @@ class OrderbookSubscription { final StreamController controller; final Sink sink; final Stream stream; + StreamSubscription? streamSubscription; + DateTime? lastUpdateAt; + bool streamInitializing = false; } class OrderbookResult { diff --git a/lib/blocs/trading_entities_bloc.dart b/lib/blocs/trading_entities_bloc.dart index 5b98b2ec78..2ac83c32e9 100644 --- a/lib/blocs/trading_entities_bloc.dart +++ b/lib/blocs/trading_entities_bloc.dart @@ -15,8 +15,10 @@ import 'package:web_dex/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_request. import 'package:web_dex/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_response.dart'; import 'package:web_dex/mm2/mm2_api/rpc/recover_funds_of_swap/recover_funds_of_swap_request.dart'; import 'package:web_dex/mm2/mm2_api/rpc/recover_funds_of_swap/recover_funds_of_swap_response.dart'; +import 'package:web_dex/model/main_menu_value.dart'; import 'package:web_dex/model/my_orders/my_order.dart'; import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/router/state/routing_state.dart'; import 'package:web_dex/services/orders_service/my_orders_service.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -25,9 +27,9 @@ class TradingEntitiesBloc implements BlocBase { KomodoDefiSdk kdfSdk, Mm2Api mm2Api, MyOrdersService myOrdersService, - ) : _mm2Api = mm2Api, - _myOrdersService = myOrdersService, - _kdfSdk = kdfSdk; + ) : _mm2Api = mm2Api, + _myOrdersService = myOrdersService, + _kdfSdk = kdfSdk; final KomodoDefiSdk _kdfSdk; final MyOrdersService _myOrdersService; @@ -37,6 +39,13 @@ class TradingEntitiesBloc implements BlocBase { List _swaps = []; Timer? timer; bool _closed = false; + DateTime? _lastFetchAt; + bool _hasLoadedInitialSwaps = false; + + static const Duration _pollingInterval = Duration(seconds: 10); + static const Duration _backgroundFetchInterval = Duration(seconds: 45); + static const int _initialSwapsLimit = 1000; + static const int _refreshSwapsLimit = 250; final StreamController> _myOrdersController = StreamController>.broadcast(); @@ -55,18 +64,37 @@ class TradingEntitiesBloc implements BlocBase { Stream> get outSwaps => _swapsController.stream; List get swaps => _swaps; set swaps(List swapList) { - swapList.sort((first, second) => - (second.myInfo?.startedAt ?? 0) - (first.myInfo?.startedAt ?? 0)); + swapList.sort( + (first, second) => + (second.myInfo?.startedAt ?? 0) - (first.myInfo?.startedAt ?? 0), + ); _swaps = swapList; _inSwaps.add(_swaps); } Future fetch() async { if (_closed) return; - if (!await _kdfSdk.auth.isSignedIn()) return; + if (!await _kdfSdk.auth.isSignedIn()) { + _hasLoadedInitialSwaps = false; + _lastFetchAt = null; + if (_myOrders.isNotEmpty) myOrders = []; + if (_swaps.isNotEmpty) swaps = []; + return; + } myOrders = await _myOrdersService.getOrders() ?? []; - swaps = await getRecentSwaps(MyRecentSwapsRequest()) ?? []; + final recentSwaps = + await getRecentSwaps( + MyRecentSwapsRequest( + limit: _hasLoadedInitialSwaps + ? _refreshSwapsLimit + : _initialSwapsLimit, + ), + ) ?? + []; + _hasLoadedInitialSwaps = true; + swaps = _mergeSwaps(_swaps, recentSwaps); + _lastFetchAt = DateTime.now(); } @override @@ -81,9 +109,10 @@ class TradingEntitiesBloc implements BlocBase { void runUpdate() { bool updateInProgress = false; - timer = Timer.periodic(const Duration(seconds: 10), (_) async { + timer = Timer.periodic(_pollingInterval, (_) async { if (_closed) return; if (updateInProgress) return; + if (!_shouldRunBackgroundFetch()) return; // TODO!: do not run for hidden login or HW updateInProgress = true; @@ -93,10 +122,7 @@ class TradingEntitiesBloc implements BlocBase { if (e is StateError && e.message.contains('disposed')) { _closed = true; } else { - await log( - 'fetch error: $e', - path: 'TradingEntitiesBloc.fetch', - ); + await log('fetch error: $e', path: 'TradingEntitiesBloc.fetch'); } } finally { updateInProgress = false; @@ -104,9 +130,33 @@ class TradingEntitiesBloc implements BlocBase { }); } + bool _shouldRunBackgroundFetch() { + if (_isTradingMenuActive) return true; + if (_lastFetchAt == null) return true; + return DateTime.now().difference(_lastFetchAt!) >= _backgroundFetchInterval; + } + + bool get _isTradingMenuActive { + final currentMenu = routingState.selectedMenu; + return currentMenu == MainMenuValue.dex || + currentMenu == MainMenuValue.bridge; + } + + List _mergeSwaps(List existing, List incoming) { + if (existing.isEmpty) return incoming; + if (incoming.isEmpty) return existing; + + final merged = {for (final swap in existing) swap.uuid: swap}; + for (final swap in incoming) { + merged[swap.uuid] = swap; + } + return merged.values.toList(); + } + Future cancelOrder(String uuid) async { - final Map response = - await _mm2Api.cancelOrder(CancelOrderRequest(uuid: uuid)); + final Map response = await _mm2Api.cancelOrder( + CancelOrderRequest(uuid: uuid), + ); return response['error']; } @@ -165,8 +215,10 @@ class TradingEntitiesBloc implements BlocBase { .map((id) => getSwap(id)) .whereType() .toList(); - final double swapFill = swaps.fold(0, - (previousValue, swap) => previousValue + (swap.myInfo?.myAmount ?? 0)); + final double swapFill = swaps.fold( + 0, + (previousValue, swap) => previousValue + (swap.myInfo?.myAmount ?? 0), + ); return swapFill / order.baseAmount.toDouble(); } @@ -176,8 +228,9 @@ class TradingEntitiesBloc implements BlocBase { } Future?> getRecentSwaps(MyRecentSwapsRequest request) async { - final MyRecentSwapsResponse? response = - await _mm2Api.getMyRecentSwaps(request); + final MyRecentSwapsResponse? response = await _mm2Api.getMyRecentSwaps( + request, + ); if (response == null) { return null; } @@ -186,10 +239,11 @@ class TradingEntitiesBloc implements BlocBase { } Future recoverFundsOfSwap(String uuid) async { - final RecoverFundsOfSwapRequest request = - RecoverFundsOfSwapRequest(uuid: uuid); - final RecoverFundsOfSwapResponse? response = - await _mm2Api.recoverFundsOfSwap(request); + final RecoverFundsOfSwapRequest request = RecoverFundsOfSwapRequest( + uuid: uuid, + ); + final RecoverFundsOfSwapResponse? response = await _mm2Api + .recoverFundsOfSwap(request); if (response != null) { log( response.toJson().toString(), @@ -200,8 +254,9 @@ class TradingEntitiesBloc implements BlocBase { } Future getMaxTakerVolume(String coinAbbr) async { - final MaxTakerVolResponse? response = - await _mm2Api.getMaxTakerVolume(MaxTakerVolRequest(coin: coinAbbr)); + final MaxTakerVolResponse? response = await _mm2Api.getMaxTakerVolume( + MaxTakerVolRequest(coin: coinAbbr), + ); if (response == null) { return null; } diff --git a/lib/dispatchers/popup_dispatcher.dart b/lib/dispatchers/popup_dispatcher.dart index 90f1100263..10314d41f4 100644 --- a/lib/dispatchers/popup_dispatcher.dart +++ b/lib/dispatchers/popup_dispatcher.dart @@ -5,8 +5,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/common/screen.dart'; -import 'package:universal_html/html.dart' as html; import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/services/platform_web_api/platform_web_api.dart'; /// **DEPRECATED**: Use `AppDialog` from `package:web_dex/shared/widgets/app_dialog.dart` instead. /// @@ -46,7 +46,7 @@ class PopupDispatcher { this.maxWidth = 640, this.barrierDismissible = true, this.onDismiss, - }); + }) : _platformWebApi = PlatformWebApi(); final BuildContext? context; final Widget? popupContent; @@ -58,11 +58,12 @@ class PopupDispatcher { final Color? barrierColor; final Color? borderColor; final VoidCallback? onDismiss; + final PlatformWebApi _platformWebApi; bool _isShown = false; bool get isShown => _isShown; - StreamSubscription? _popStreamSubscription; + StreamSubscription? _popStreamSubscription; Future show() async { if (_currentContext == null) return; @@ -131,7 +132,7 @@ class PopupDispatcher { } void _onPopStateSubscriptionWeb() { - _popStreamSubscription = html.window.onPopState.listen((_) { + _popStreamSubscription = _platformWebApi.onPopState(() { final navigator = Navigator.of(_currentContext!, rootNavigator: true); if (navigator.canPop()) { _resetBrowserNavigationToDefault(); diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index d1c3957866..367f2ea6a1 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -86,6 +86,8 @@ abstract class LocaleKeys { static const portfolio = 'portfolio'; static const editList = 'editList'; static const withBalance = 'withBalance'; + static const hideBalancesTitle = 'hideBalancesTitle'; + static const hideBalancesSubtitle = 'hideBalancesSubtitle'; static const balance = 'balance'; static const transactions = 'transactions'; static const send = 'send'; @@ -152,6 +154,8 @@ abstract class LocaleKeys { static const settingsMenuGeneral = 'settingsMenuGeneral'; static const settingsMenuLanguage = 'settingsMenuLanguage'; static const settingsMenuSecurity = 'settingsMenuSecurity'; + static const settingsMenuPrivacy = 'settingsMenuPrivacy'; + static const settingsMenuKycPolicy = 'settingsMenuKycPolicy'; static const settingsMenuAbout = 'settingsMenuAbout'; static const seedPhraseSettingControlsViewSeed = 'seedPhraseSettingControlsViewSeed'; @@ -394,6 +398,7 @@ abstract class LocaleKeys { static const seedSettings = 'seedSettings'; static const errorDescription = 'errorDescription'; static const tryAgain = 'tryAgain'; + static const errorTryAgainSupportHint = 'errorTryAgainSupportHint'; static const customFeesWarning = 'customFeesWarning'; static const fiatExchange = 'fiatExchange'; static const bridgeExchange = 'bridgeExchange'; @@ -408,6 +413,8 @@ abstract class LocaleKeys { static const trezorErrorInvalidPin = 'trezorErrorInvalidPin'; static const trezorSelectTitle = 'trezorSelectTitle'; static const trezorSelectSubTitle = 'trezorSelectSubTitle'; + static const trezorWalletOnlyTooltip = 'trezorWalletOnlyTooltip'; + static const trezorWalletOnlyNotice = 'trezorWalletOnlyNotice'; static const trezorBrowserUnsupported = 'trezorBrowserUnsupported'; static const trezorTransactionInProgressMessage = 'trezorTransactionInProgressMessage'; @@ -445,6 +452,7 @@ abstract class LocaleKeys { static const setMin = 'setMin'; static const timeout = 'timeout'; static const notEnoughBalanceForGasError = 'notEnoughBalanceForGasError'; + static const cannotSendToSelf = 'cannotSendToSelf'; static const notEnoughFundsError = 'notEnoughFundsError'; static const dexErrorMessage = 'dexErrorMessage'; static const dexUnableToStartSwap = 'dexUnableToStartSwap'; @@ -463,6 +471,7 @@ abstract class LocaleKeys { static const passwordContainsTheWordPassword = 'passwordContainsTheWordPassword'; static const passwordTooShort = 'passwordTooShort'; + static const passwordTooLong = 'passwordTooLong'; static const passwordMissingDigit = 'passwordMissingDigit'; static const passwordMissingLowercase = 'passwordMissingLowercase'; static const passwordMissingUppercase = 'passwordMissingUppercase'; @@ -512,6 +521,31 @@ abstract class LocaleKeys { static const withdrawPreview = 'withdrawPreview'; static const withdrawPreviewZhtlcNote = 'withdrawPreviewZhtlcNote'; static const withdrawPreviewError = 'withdrawPreviewError'; + static const withdrawDestination = 'withdrawDestination'; + static const withdrawNetworkDetails = 'withdrawNetworkDetails'; + static const withdrawHighFee = 'withdrawHighFee'; + static const withdrawPreviewExpiresIn = 'withdrawPreviewExpiresIn'; + static const withdrawPreviewRefreshing = 'withdrawPreviewRefreshing'; + static const withdrawTronBandwidthUsed = 'withdrawTronBandwidthUsed'; + static const withdrawTronBandwidthFee = 'withdrawTronBandwidthFee'; + static const withdrawTronBandwidthSource = 'withdrawTronBandwidthSource'; + static const withdrawTronEnergyUsed = 'withdrawTronEnergyUsed'; + static const withdrawTronEnergyFee = 'withdrawTronEnergyFee'; + static const withdrawTronEnergySource = 'withdrawTronEnergySource'; + static const withdrawTronAccountActivationFee = + 'withdrawTronAccountActivationFee'; + static const withdrawTronFeeSummary = 'withdrawTronFeeSummary'; + static const withdrawTronFeePaidIn = 'withdrawTronFeePaidIn'; + static const withdrawTronBandwidthCovered = 'withdrawTronBandwidthCovered'; + static const withdrawTronEnergyCovered = 'withdrawTronEnergyCovered'; + static const withdrawTronResourceNotUsed = 'withdrawTronResourceNotUsed'; + static const withdrawTronFeeSummaryCharged = 'withdrawTronFeeSummaryCharged'; + static const withdrawTronFeeSummaryCovered = 'withdrawTronFeeSummaryCovered'; + static const withdrawTronPreviewExpired = 'withdrawTronPreviewExpired'; + static const withdrawTronPreviewRefreshFailed = + 'withdrawTronPreviewRefreshFailed'; + static const withdrawTronPreviewRegenerate = 'withdrawTronPreviewRegenerate'; + static const withdrawAwaitingConfirmations = 'withdrawAwaitingConfirmations'; static const txHistoryFetchError = 'txHistoryFetchError'; static const txHistoryNoTransactions = 'txHistoryNoTransactions'; static const maxGapLimitReached = 'maxGapLimitReached'; @@ -522,6 +556,9 @@ abstract class LocaleKeys { static const hdWalletModeSwitchTitle = 'hdWalletModeSwitchTitle'; static const hdWalletModeSwitchSubtitle = 'hdWalletModeSwitchSubtitle'; static const hdWalletModeSwitchTooltip = 'hdWalletModeSwitchTooltip'; + static const multiAddressWalletNoticeTitle = 'multiAddressWalletNoticeTitle'; + static const multiAddressWalletNoticeDescription = + 'multiAddressWalletNoticeDescription'; static const noActiveWallet = 'noActiveWallet'; static const memo = 'memo'; static const gasPriceGwei = 'gasPriceGwei'; @@ -594,8 +631,11 @@ abstract class LocaleKeys { static const useCustomSeedOrWif = 'useCustomSeedOrWif'; static const cancelOrder = 'cancelOrder'; static const version = 'version'; + static const buildDate = 'buildDate'; static const copyAddressToClipboard = 'copyAddressToClipboard'; static const copiedAddressToClipboard = 'copiedAddressToClipboard'; + static const copyUuid = 'copyUuid'; + static const copiedUuidToClipboard = 'copiedUuidToClipboard'; static const createdAt = 'createdAt'; static const coin = 'coin'; static const token = 'token'; @@ -635,10 +675,19 @@ abstract class LocaleKeys { static const accessHiddenWallet = 'accessHiddenWallet'; static const passphraseIsEmpty = 'passphraseIsEmpty'; static const selectWalletType = 'selectWalletType'; + static const walletImportTypeHdLabel = 'walletImportTypeHdLabel'; + static const walletImportTypeHdDescription = 'walletImportTypeHdDescription'; + static const walletImportTypeLegacyLabel = 'walletImportTypeLegacyLabel'; + static const walletImportTypeLegacyDescription = + 'walletImportTypeLegacyDescription'; + static const walletImportTypeHdDisabledHint = + 'walletImportTypeHdDisabledHint'; static const trezorNoAddresses = 'trezorNoAddresses'; static const trezorImportFailed = 'trezorImportFailed'; static const faucetFailureTitle = 'faucetFailureTitle'; static const faucetLoadingTitle = 'faucetLoadingTitle'; + static const faucetLoadingMessage = 'faucetLoadingMessage'; + static const faucetLoadingSubtitle = 'faucetLoadingSubtitle'; static const faucetInitialTitle = 'faucetInitialTitle'; static const faucetUnknownErrorMessage = 'faucetUnknownErrorMessage'; static const faucetLinkToTransaction = 'faucetLinkToTransaction'; @@ -654,6 +703,7 @@ abstract class LocaleKeys { static const transactionsEmptyDescription = 'transactionsEmptyDescription'; static const transactionsNoLoginCAT = 'transactionsNoLoginCAT'; static const loadingError = 'loadingError'; + static const legalDocumentLoadError = 'legalDocumentLoadError'; static const tryAgainButton = 'tryAgainButton'; static const contractAddress = 'contractAddress'; static const tokenID = 'tokenID'; @@ -744,7 +794,18 @@ abstract class LocaleKeys { static const expertMode = 'expertMode'; static const testCoins = 'testCoins'; static const enableTradingBot = 'enableTradingBot'; + static const saveOrders = 'saveOrders'; + static const saveOrdersRestartHint = 'saveOrdersRestartHint'; + static const exportMakerOrders = 'exportMakerOrders'; + static const importMakerOrders = 'importMakerOrders'; + static const noMakerOrdersToExport = 'noMakerOrdersToExport'; + static const makerOrdersExportSuccess = 'makerOrdersExportSuccess'; + static const makerOrdersExportFailed = 'makerOrdersExportFailed'; + static const makerOrdersImportSuccess = 'makerOrdersImportSuccess'; + static const makerOrdersImportFailed = 'makerOrdersImportFailed'; static const enableTestCoins = 'enableTestCoins'; + static const diagnosticLogging = 'diagnosticLogging'; + static const enableDiagnosticLogging = 'enableDiagnosticLogging'; static const makeMarket = 'makeMarket'; static const custom = 'custom'; static const edit = 'edit'; @@ -778,6 +839,8 @@ abstract class LocaleKeys { static const creating = 'creating'; static const createAddress = 'createAddress'; static const hideZeroBalanceAddresses = 'hideZeroBalanceAddresses'; + static const showAllAddresses = 'showAllAddresses'; + static const showLessAddresses = 'showLessAddresses'; static const important = 'important'; static const trend = 'trend'; static const growth = 'growth'; @@ -855,4 +918,126 @@ abstract class LocaleKeys { static const zhtlcAdvancedConfigurationHint = 'zhtlcAdvancedConfigurationHint'; static const zhtlcConfigButton = 'zhtlcConfigButton'; + static const kdfErrorGeneric = 'kdfErrorGeneric'; + static const kdfErrorNotSufficientBalance = 'kdfErrorNotSufficientBalance'; + static const kdfErrorNotSufficientPlatformBalanceForFee = + 'kdfErrorNotSufficientPlatformBalanceForFee'; + static const kdfErrorZeroBalanceToWithdrawMax = + 'kdfErrorZeroBalanceToWithdrawMax'; + static const kdfErrorAmountTooLow = 'kdfErrorAmountTooLow'; + static const kdfErrorNotEnoughNftsAmount = 'kdfErrorNotEnoughNftsAmount'; + static const kdfErrorInvalidAddress = 'kdfErrorInvalidAddress'; + static const kdfErrorFromAddressNotFound = 'kdfErrorFromAddressNotFound'; + static const kdfErrorUnexpectedFromAddress = 'kdfErrorUnexpectedFromAddress'; + static const kdfErrorMyAddressNotNftOwner = 'kdfErrorMyAddressNotNftOwner'; + static const kdfErrorNoSuchCoin = 'kdfErrorNoSuchCoin'; + static const kdfErrorCoinNotFound = 'kdfErrorCoinNotFound'; + static const kdfErrorCoinNotSupported = 'kdfErrorCoinNotSupported'; + static const kdfErrorCoinIsNotActive = 'kdfErrorCoinIsNotActive'; + static const kdfErrorCoinDoesntSupportWithdraw = + 'kdfErrorCoinDoesntSupportWithdraw'; + static const kdfErrorCoinDoesntSupportNftWithdraw = + 'kdfErrorCoinDoesntSupportNftWithdraw'; + static const kdfErrorNftProtocolNotSupported = + 'kdfErrorNftProtocolNotSupported'; + static const kdfErrorContractTypeDoesntSupportNft = + 'kdfErrorContractTypeDoesntSupportNft'; + static const kdfErrorTransport = 'kdfErrorTransport'; + static const kdfErrorTimeout = 'kdfErrorTimeout'; + static const kdfErrorTaskTimedOut = 'kdfErrorTaskTimedOut'; + static const kdfErrorInvalidResponse = 'kdfErrorInvalidResponse'; + static const kdfErrorUnreachableNodes = 'kdfErrorUnreachableNodes'; + static const kdfErrorAtLeastOneNodeRequired = + 'kdfErrorAtLeastOneNodeRequired'; + static const kdfErrorClientConnectionFailed = + 'kdfErrorClientConnectionFailed'; + static const kdfErrorConnectToNodeError = 'kdfErrorConnectToNodeError'; + static const kdfErrorActivationFailed = 'kdfErrorActivationFailed'; + static const kdfErrorCouldNotFetchBalance = 'kdfErrorCouldNotFetchBalance'; + static const kdfErrorUnsupportedChain = 'kdfErrorUnsupportedChain'; + static const kdfErrorChainIdNotSet = 'kdfErrorChainIdNotSet'; + static const kdfErrorNoChainIdSet = 'kdfErrorNoChainIdSet'; + static const kdfErrorInvalidFeePolicy = 'kdfErrorInvalidFeePolicy'; + static const kdfErrorInvalidFee = 'kdfErrorInvalidFee'; + static const kdfErrorInvalidGasApiConfig = 'kdfErrorInvalidGasApiConfig'; + static const kdfErrorNameTooLong = 'kdfErrorNameTooLong'; + static const kdfErrorDescriptionTooLong = 'kdfErrorDescriptionTooLong'; + static const kdfErrorNoSuchAccount = 'kdfErrorNoSuchAccount'; + static const kdfErrorNoEnabledAccount = 'kdfErrorNoEnabledAccount'; + static const kdfErrorAccountExistsAlready = 'kdfErrorAccountExistsAlready'; + static const kdfErrorUnknownAccount = 'kdfErrorUnknownAccount'; + static const kdfErrorLoadingAccount = 'kdfErrorLoadingAccount'; + static const kdfErrorSavingAccount = 'kdfErrorSavingAccount'; + static const kdfErrorHwError = 'kdfErrorHwError'; + static const kdfErrorHwContextNotInitialized = + 'kdfErrorHwContextNotInitialized'; + static const kdfErrorCoinDoesntSupportTrezor = + 'kdfErrorCoinDoesntSupportTrezor'; + static const kdfErrorInvalidHardwareWalletCall = + 'kdfErrorInvalidHardwareWalletCall'; + static const kdfErrorNotSupported = 'kdfErrorNotSupported'; + static const kdfErrorVolumeTooLow = 'kdfErrorVolumeTooLow'; + static const kdfErrorMyRecentSwapsError = 'kdfErrorMyRecentSwapsError'; + static const kdfErrorSwapInfoNotAvailable = 'kdfErrorSwapInfoNotAvailable'; + static const kdfErrorInvalidRequest = 'kdfErrorInvalidRequest'; + static const kdfErrorInvalidPayload = 'kdfErrorInvalidPayload'; + static const kdfErrorInvalidMemo = 'kdfErrorInvalidMemo'; + static const kdfErrorInvalidConfiguration = 'kdfErrorInvalidConfiguration'; + static const kdfErrorPrivKeyPolicyNotAllowed = + 'kdfErrorPrivKeyPolicyNotAllowed'; + static const kdfErrorUnexpectedDerivationMethod = + 'kdfErrorUnexpectedDerivationMethod'; + static const kdfErrorActionNotAllowed = 'kdfErrorActionNotAllowed'; + static const kdfErrorUnexpectedUserAction = 'kdfErrorUnexpectedUserAction'; + static const kdfErrorBroadcastExpected = 'kdfErrorBroadcastExpected'; + static const kdfErrorDbError = 'kdfErrorDbError'; + static const kdfErrorWalletStorageError = 'kdfErrorWalletStorageError'; + static const kdfErrorHDWalletStorageError = 'kdfErrorHDWalletStorageError'; + static const kdfErrorInternal = 'kdfErrorInternal'; + static const kdfErrorInternalError = 'kdfErrorInternalError'; + static const kdfErrorUnsupportedError = 'kdfErrorUnsupportedError'; + static const kdfErrorSigningError = 'kdfErrorSigningError'; + static const kdfErrorSystemTimeError = 'kdfErrorSystemTimeError'; + static const kdfErrorNumConversError = 'kdfErrorNumConversError'; + static const kdfErrorIOError = 'kdfErrorIOError'; + static const kdfErrorRpcError = 'kdfErrorRpcError'; + static const kdfErrorRpcTaskError = 'kdfErrorRpcTaskError'; + static const kdfErrorInvalidBip44Chain = 'kdfErrorInvalidBip44Chain'; + static const kdfErrorBip32Error = 'kdfErrorBip32Error'; + static const kdfErrorInvalidPath = 'kdfErrorInvalidPath'; + static const kdfErrorInvalidPathToAddress = 'kdfErrorInvalidPathToAddress'; + static const kdfErrorDeserializingDerivationPath = + 'kdfErrorDeserializingDerivationPath'; + static const kdfErrorInvalidSwapContractAddr = + 'kdfErrorInvalidSwapContractAddr'; + static const kdfErrorInvalidFallbackSwapContract = + 'kdfErrorInvalidFallbackSwapContract'; + static const kdfErrorCustomTokenError = 'kdfErrorCustomTokenError'; + static const kdfErrorGetNftInfoError = 'kdfErrorGetNftInfoError'; + static const kdfErrorMetamaskError = 'kdfErrorMetamaskError'; + static const kdfErrorWalletConnectError = 'kdfErrorWalletConnectError'; + static const sdk_errors_network_unavailable = + 'sdk_errors.network_unavailable'; + static const sdk_errors_timeout = 'sdk_errors.timeout'; + static const sdk_errors_invalid_response = 'sdk_errors.invalid_response'; + static const sdk_errors_insufficient_funds = 'sdk_errors.insufficient_funds'; + static const sdk_errors_insufficient_gas = 'sdk_errors.insufficient_gas'; + static const sdk_errors_zero_balance = 'sdk_errors.zero_balance'; + static const sdk_errors_amount_too_low = 'sdk_errors.amount_too_low'; + static const sdk_errors_invalid_address = 'sdk_errors.invalid_address'; + static const sdk_errors_invalid_fee = 'sdk_errors.invalid_fee'; + static const sdk_errors_invalid_memo = 'sdk_errors.invalid_memo'; + static const sdk_errors_asset_not_activated = + 'sdk_errors.asset_not_activated'; + static const sdk_errors_activation_failed = 'sdk_errors.activation_failed'; + static const sdk_errors_user_cancelled = 'sdk_errors.user_cancelled'; + static const sdk_errors_hardware_failure = 'sdk_errors.hardware_failure'; + static const sdk_errors_not_supported = 'sdk_errors.not_supported'; + static const sdk_errors_auth_invalid_credentials = + 'sdk_errors.auth_invalid_credentials'; + static const sdk_errors_auth_unauthorized = 'sdk_errors.auth_unauthorized'; + static const sdk_errors_auth_wallet_not_found = + 'sdk_errors.auth_wallet_not_found'; + static const sdk_errors_general = 'sdk_errors.general'; + static const sdk_errors = 'sdk_errors'; } diff --git a/lib/main.dart b/lib/main.dart index d60098a28f..69a9910aef 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,7 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; import 'package:get_it/get_it.dart'; -import 'package:hive_flutter/hive_flutter.dart'; +import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:path/path.dart' as p; @@ -21,7 +21,6 @@ import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; import 'package:web_dex/bloc/cex_market_data/cex_market_data.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; -import 'package:komodo_defi_framework/komodo_defi_framework.dart'; import 'package:web_dex/bloc/settings/settings_repository.dart'; import 'package:web_dex/bloc/trading_status/trading_status_repository.dart'; import 'package:web_dex/bloc/trading_status/trading_status_service.dart'; @@ -34,6 +33,7 @@ import 'package:web_dex/sdk/widgets/window_close_handler.dart'; import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; import 'package:web_dex/services/fd_monitor_service.dart'; import 'package:web_dex/services/feedback/app_feedback_wrapper.dart'; +import 'package:web_dex/services/legal_documents/legal_documents_repository.dart'; import 'package:web_dex/services/logger/get_logger.dart'; import 'package:web_dex/services/storage/get_storage.dart'; import 'package:web_dex/shared/constants.dart'; @@ -127,6 +127,10 @@ Future main() async { RepositoryProvider.value(value: sparklineRepository), RepositoryProvider.value(value: tradingStatusRepository), RepositoryProvider.value(value: tradingStatusService), + RepositoryProvider( + create: (_) => LegalDocumentsRepository(), + dispose: (repository) => repository.dispose(), + ), ], child: const MyApp(), ), diff --git a/lib/mm2/mm2_api/rpc/market_maker_bot/market_maker_bot_parameters.dart b/lib/mm2/mm2_api/rpc/market_maker_bot/market_maker_bot_parameters.dart index f4f3b57e13..f73eb3160b 100644 --- a/lib/mm2/mm2_api/rpc/market_maker_bot/market_maker_bot_parameters.dart +++ b/lib/mm2/mm2_api/rpc/market_maker_bot/market_maker_bot_parameters.dart @@ -27,7 +27,7 @@ class MarketMakerBotParameters extends Equatable { factory MarketMakerBotParameters.fromJson(Map json) => MarketMakerBotParameters( priceUrl: json['price_url'] as String?, - botRefreshRate: json['bot_refresh_rate'] as int?, + botRefreshRate: (json['bot_refresh_rate'] as num?)?.toInt(), tradeCoinPairs: json['cfg'] == null ? null : (json['cfg'] as Map).map( @@ -36,10 +36,10 @@ class MarketMakerBotParameters extends Equatable { ); Map toJson() => { - 'price_url': priceUrl, - 'bot_refresh_rate': botRefreshRate, - 'cfg': tradeCoinPairs?.map((k, e) => MapEntry(k, e.toJson())) ?? {}, - }..removeWhere((_, value) => value == null); + 'price_url': priceUrl, + 'bot_refresh_rate': botRefreshRate, + 'cfg': tradeCoinPairs?.map((k, e) => MapEntry(k, e.toJson())) ?? {}, + }..removeWhere((_, value) => value == null); MarketMakerBotParameters copyWith({ String? priceUrl, diff --git a/lib/mm2/mm2_api/rpc/market_maker_bot/trade_coin_pair_config.dart b/lib/mm2/mm2_api/rpc/market_maker_bot/trade_coin_pair_config.dart index cfcc4b1c25..856c6ea3c9 100644 --- a/lib/mm2/mm2_api/rpc/market_maker_bot/trade_coin_pair_config.dart +++ b/lib/mm2/mm2_api/rpc/market_maker_bot/trade_coin_pair_config.dart @@ -147,16 +147,16 @@ class TradeCoinPairConfig extends Equatable { maxVolume: json['max_volume'] != null ? TradeVolume.fromJson(json['max_volume'] as Map) : null, - minBasePriceUsd: json['min_base_price'] as double?, - minRelPriceUsd: json['min_rel_price'] as double?, - minPairPrice: json['min_pair_price'] as double?, + minBasePriceUsd: (json['min_base_price'] as num?)?.toDouble(), + minRelPriceUsd: (json['min_rel_price'] as num?)?.toDouble(), + minPairPrice: (json['min_pair_price'] as num?)?.toDouble(), spread: json['spread'] as String, - baseConfs: json['base_confs'] as int?, + baseConfs: (json['base_confs'] as num?)?.toInt(), baseNota: json['base_nota'] as bool?, - relConfs: json['rel_confs'] as int?, + relConfs: (json['rel_confs'] as num?)?.toInt(), relNota: json['rel_nota'] as bool?, enable: json['enable'] as bool, - priceElapsedValidity: json['price_elapsed_validity'] as int?, + priceElapsedValidity: (json['price_elapsed_validity'] as num?)?.toInt(), checkLastBidirectionalTradeThreshHold: json['check_last_bidirectional_trade_thresh_hold'] as bool?, ); @@ -224,28 +224,28 @@ class TradeCoinPairConfig extends Equatable { priceElapsedValidity: priceElapsedValidity ?? this.priceElapsedValidity, checkLastBidirectionalTradeThreshHold: checkLastBidirectionalTradeThreshHold ?? - this.checkLastBidirectionalTradeThreshHold, + this.checkLastBidirectionalTradeThreshHold, ); } @override List get props => [ - name, - baseCoinId, - relCoinId, - maxBalancePerTrade, - minVolume, - maxVolume, - minBasePriceUsd, - minRelPriceUsd, - minPairPrice, - spread, - baseConfs, - baseNota, - relConfs, - relNota, - enable, - priceElapsedValidity, - checkLastBidirectionalTradeThreshHold, - ]; + name, + baseCoinId, + relCoinId, + maxBalancePerTrade, + minVolume, + maxVolume, + minBasePriceUsd, + minRelPriceUsd, + minPairPrice, + spread, + baseConfs, + baseNota, + relConfs, + relNota, + enable, + priceElapsedValidity, + checkLastBidirectionalTradeThreshHold, + ]; } diff --git a/lib/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_response.dart b/lib/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_response.dart index 444cbd1452..7e206b040f 100644 --- a/lib/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_response.dart +++ b/lib/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_response.dart @@ -1,9 +1,8 @@ import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/shared/utils/utils.dart'; class MyRecentSwapsResponse { - MyRecentSwapsResponse({ - required this.result, - }); + MyRecentSwapsResponse({required this.result}); factory MyRecentSwapsResponse.fromJson(Map json) => MyRecentSwapsResponse( @@ -14,9 +13,7 @@ class MyRecentSwapsResponse { MyRecentSwapsResponseResult result; - Map get toJson => { - 'result': result.toJson, - }; + Map get toJson => {'result': result.toJson}; } class MyRecentSwapsResponseResult { @@ -34,8 +31,8 @@ class MyRecentSwapsResponseResult { factory MyRecentSwapsResponseResult.fromJson(Map json) => MyRecentSwapsResponseResult( fromUuid: json['from_uuid'] as String?, - limit: json['limit'] as int? ?? 0, - skipped: json['skipped'] as int? ?? 0, + limit: assertInt(json['limit']) ?? 0, + skipped: assertInt(json['skipped']) ?? 0, swaps: List.from( (json['swaps'] as List? ?? []) .where((dynamic x) => x != null) @@ -43,10 +40,10 @@ class MyRecentSwapsResponseResult { (dynamic x) => Swap.fromJson(x as Map? ?? {}), ), ), - total: json['total'] as int? ?? 0, - foundRecords: json['found_records'] as int? ?? 0, - pageNumber: json['page_number'] as int? ?? 0, - totalPages: json['total_pages'] as int? ?? 0, + total: assertInt(json['total']) ?? 0, + foundRecords: assertInt(json['found_records']) ?? 0, + pageNumber: assertInt(json['page_number']) ?? 0, + totalPages: assertInt(json['total_pages']) ?? 0, ); String? fromUuid; @@ -59,12 +56,12 @@ class MyRecentSwapsResponseResult { int foundRecords; Map get toJson => { - 'from_uuid': fromUuid, - 'limit': limit, - 'skipped': skipped, - 'swaps': List.from( - swaps.map>((Swap x) => x.toJson()), - ), - 'total': total, - }; + 'from_uuid': fromUuid, + 'limit': limit, + 'skipped': skipped, + 'swaps': List.from( + swaps.map>((Swap x) => x.toJson()), + ), + 'total': total, + }; } diff --git a/lib/mm2/mm2_api/rpc/rpc_error.dart b/lib/mm2/mm2_api/rpc/rpc_error.dart index bc9600678d..59efc3fd45 100644 --- a/lib/mm2/mm2_api/rpc/rpc_error.dart +++ b/lib/mm2/mm2_api/rpc/rpc_error.dart @@ -3,6 +3,36 @@ import 'dart:convert'; import 'package:equatable/equatable.dart'; import 'package:web_dex/mm2/mm2_api/rpc/rpc_error_type.dart'; +/// Legacy RPC exception class. +/// +/// This class is deprecated. Use [MmRpcException] and its subclasses from +/// `package:komodo_defi_rpc_methods` instead. The [KdfErrorRegistry] provides +/// automatic parsing of error responses into typed exceptions. +/// +/// Example migration: +/// ```dart +/// // Old code: +/// try { +/// await someRpcCall(); +/// } on RpcException catch (e) { +/// print(e.error.errorType); +/// } +/// +/// // New code: +/// try { +/// await someRpcCall(); +/// } on MmRpcException catch (e) { +/// switch (e) { +/// case AccountRpcErrorNameTooLongException(): +/// // Handle specific error +/// // ... other cases +/// } +/// } +/// ``` +@Deprecated( + 'Use MmRpcException and its subclasses from package:komodo_defi_rpc_methods ' + 'instead. See KdfErrorRegistry for automatic error parsing.', +) class RpcException implements Exception { const RpcException(this.error); @@ -14,6 +44,15 @@ class RpcException implements Exception { } } +/// Legacy RPC error data class. +/// +/// This class is deprecated. Use [GeneralErrorResponse] from +/// `package:komodo_defi_rpc_methods` for error data, or catch typed +/// [MmRpcException] subclasses which contain the error data as fields. +@Deprecated( + 'Use GeneralErrorResponse or typed MmRpcException subclasses from ' + 'package:komodo_defi_rpc_methods instead.', +) class RpcError extends Equatable { const RpcError({ this.mmrpc, @@ -53,7 +92,7 @@ class RpcError extends Equatable { ? RpcErrorType.fromString(json['error_type'] as String) : null, errorData: json['error_data'] as String?, - id: json['id'] as int?, + id: (json['id'] as num?)?.toInt(), ); final String? mmrpc; @@ -98,12 +137,12 @@ class RpcError extends Equatable { String toString() { return ''' RpcError: { - mmrpc: $mmrpc, - error: $error, - errorPath: $errorPath, - errorTrace: $errorTrace, + mmrpc: $mmrpc, + error: $error, + errorPath: $errorPath, + errorTrace: $errorTrace, errorType: $errorType, - errorData: $errorData, + errorData: $errorData, id: $id }'''; } diff --git a/lib/mm2/mm2_api/rpc/rpc_error_type.dart b/lib/mm2/mm2_api/rpc/rpc_error_type.dart index 4f92376e40..a5a14eaaa8 100644 --- a/lib/mm2/mm2_api/rpc/rpc_error_type.dart +++ b/lib/mm2/mm2_api/rpc/rpc_error_type.dart @@ -1,3 +1,19 @@ +/// Legacy RPC error type enum. +/// +/// This enum is deprecated. Use the typed error type enums from +/// `package:komodo_defi_rpc_methods` instead. These are auto-generated +/// from the KDF source and provide comprehensive coverage of all error types. +/// +/// For example: +/// - [AccountRpcErrorType] for account-related errors +/// - [WithdrawErrorType] for withdrawal errors +/// - [SwapStartErrorType] for swap errors +/// +/// See [KdfErrorRegistry] for automatic error parsing into typed exceptions. +@Deprecated( + 'Use typed error type enums from package:komodo_defi_rpc_methods instead. ' + 'See mm2_rpc_exceptions.dart for available error types.', +) enum RpcErrorType { alreadyStarted, alreadyStopped, diff --git a/lib/mm2/rpc_web.dart b/lib/mm2/rpc_web.dart index 39113924a2..8804b8f106 100644 --- a/lib/mm2/rpc_web.dart +++ b/lib/mm2/rpc_web.dart @@ -1,13 +1,57 @@ -import 'package:universal_html/js_util.dart'; +import 'dart:convert'; +import 'dart:js_interop'; + +import 'package:logging/logging.dart'; import 'package:web_dex/mm2/rpc.dart'; import 'package:web_dex/platform/platform.dart'; class RPCWeb extends RPC { const RPCWeb(); + static final _log = Logger('RPCWeb'); + @override Future call(String reqStr) async { - final dynamic response = await promiseToFuture(wasmRpc(reqStr)); - return response; + try { + final JSAny? jsResponse = await wasmRpc(reqStr).toDart; + final dynamic response = jsResponse?.dartify(); + + if (response == null) { + throw Exception('Empty RPC response'); + } + + if (response is String) { + final payload = response.trim(); + if (payload.isEmpty) { + throw Exception('Empty RPC response'); + } + + try { + return jsonDecode(payload); + } catch (_) { + return payload; + } + } + + return response; + } catch (e, s) { + _log.warning('Web RPC call failed', e, s); + throw Exception(_userFacingMessage(e)); + } + } + + String _userFacingMessage(Object error) { + final normalized = error.toString().toLowerCase(); + if (normalized.contains('failed to fetch') || + normalized.contains('network') || + normalized.contains('timeout')) { + return 'Network request failed. Please check your connection and retry.'; + } + if (normalized.contains('wasm') || + normalized.contains('runtimeerror') || + normalized.contains('bindgen')) { + return 'Wallet engine is still initializing. Please retry in a moment.'; + } + return 'Unexpected wallet engine error. Please retry.'; } } diff --git a/lib/model/coin.dart b/lib/model/coin.dart index 19323f5d2e..af463870a9 100644 --- a/lib/model/coin.dart +++ b/lib/model/coin.dart @@ -121,9 +121,11 @@ class Coin extends Equatable { bool get isTxMemoSupported => type == CoinType.tendermint || type == CoinType.tendermintToken; - bool get isCustomFeeSupported { - return type != CoinType.tendermintToken && type != CoinType.tendermint; - } + bool get isCustomFeeSupported => + type != CoinType.tendermintToken && + type != CoinType.tendermint && + type != CoinType.trx && + type != CoinType.trc20; static bool checkSegwitByAbbr(String abbr) => abbr.contains('-segwit'); static String normalizeAbbr(String abbr) => abbr.replaceAll('-segwit', ''); diff --git a/lib/model/coin_type.dart b/lib/model/coin_type.dart index 4c94bd4c8c..e005ab9332 100644 --- a/lib/model/coin_type.dart +++ b/lib/model/coin_type.dart @@ -7,6 +7,8 @@ // anchor: protocols support enum CoinType { utxo, + trx, + trc20, smartChain, etc, erc20, @@ -23,6 +25,7 @@ enum CoinType { sbch, ubiq, krc20, + grc20, tendermintToken, tendermint, slp, diff --git a/lib/model/coin_utils.dart b/lib/model/coin_utils.dart index c98b00fcde..b2ee5398c1 100644 --- a/lib/model/coin_utils.dart +++ b/lib/model/coin_utils.dart @@ -13,6 +13,10 @@ import 'package:web_dex/shared/utils/utils.dart'; List sortByPriorityAndBalance(List coins, KomodoDefiSdk sdk) { final List list = List.of(coins); list.sort((a, b) { + final bool aIsParent = a.parentCoin == null; + final bool bIsParent = b.parentCoin == null; + if (aIsParent != bIsParent) return aIsParent ? -1 : 1; + final double usdBalanceA = a.lastKnownUsdBalance(sdk) ?? 0.00; final double usdBalanceB = b.lastKnownUsdBalance(sdk) ?? 0.00; @@ -38,6 +42,10 @@ List sortByPriorityAndBalance(List coins, KomodoDefiSdk sdk) { List sortFiatBalance(List coins, KomodoDefiSdk sdk) { final List list = List.of(coins); list.sort((a, b) { + final bool aIsParent = a.parentCoin == null; + final bool bIsParent = b.parentCoin == null; + if (aIsParent != bIsParent) return aIsParent ? -1 : 1; + final double usdBalanceA = a.lastKnownUsdBalance(sdk) ?? 0.00; final double usdBalanceB = b.lastKnownUsdBalance(sdk) ?? 0.00; if (usdBalanceA > usdBalanceB) return -1; @@ -133,8 +141,14 @@ String getCoinTypeName(CoinType type, [String? symbol]) { return 'Native'; } switch (type) { + case CoinType.trx: + return 'TRON'; + case CoinType.trc20: + return 'TRC-20'; case CoinType.erc20: return 'ERC-20'; + case CoinType.grc20: + return 'GRC-20'; case CoinType.bep20: return 'BEP-20'; case CoinType.qrc20: @@ -182,11 +196,17 @@ String getCoinTypeName(CoinType type, [String? symbol]) { bool isParentCoin(CoinType type, String symbol) { switch (type) { + case CoinType.trx: + return symbol == 'TRX'; + case CoinType.trc20: + return false; case CoinType.utxo: case CoinType.tendermint: return true; case CoinType.erc20: return symbol == 'ETH'; + case CoinType.grc20: + return symbol == 'GLEECT'; case CoinType.bep20: return symbol == 'BNB'; case CoinType.avx20: diff --git a/lib/model/dex_form_error.dart b/lib/model/dex_form_error.dart index f45c088efc..d988722f2c 100644 --- a/lib/model/dex_form_error.dart +++ b/lib/model/dex_form_error.dart @@ -8,6 +8,7 @@ class DexFormError implements TextError { this.type = DexFormErrorType.simple, this.isWarning = false, this.action, + this.technicalDetails, }) : id = const Uuid().v4(); final DexFormErrorType type; @@ -18,6 +19,9 @@ class DexFormError implements TextError { @override final String error; + @override + final String? technicalDetails; + @override String get message => error; } diff --git a/lib/model/kdf_auth_metadata_extension.dart b/lib/model/kdf_auth_metadata_extension.dart index 27dfa875ad..3e2b4c220f 100644 --- a/lib/model/kdf_auth_metadata_extension.dart +++ b/lib/model/kdf_auth_metadata_extension.dart @@ -31,7 +31,8 @@ extension KdfAuthMetadataExtension on KomodoDefiSdk { /// If no user is signed in, returns an empty list. Future> getWalletCoinIds() async { final user = await auth.currentUser; - return user?.metadata.valueOrNull>('activated_coins') ?? []; + if (user == null) return []; + return user.metadata.valueOrNull>('activated_coins') ?? []; } /// Returns the stored list of wallet assets resolved from configuration IDs. @@ -97,40 +98,35 @@ extension KdfAuthMetadataExtension on KomodoDefiSdk { /// Adds new coin/asset IDs to the current user's activated coins list. /// - /// This method merges the provided [coins] with the existing activated coins, - /// ensuring no duplicates. The merged list is then stored in user metadata. + /// This method atomically merges the provided [coins] with the existing + /// activated coins, ensuring no duplicates and no lost writes under + /// concurrent calls. /// - /// If no user is currently signed in, the operation will complete but have no effect. + /// If no user is currently signed in, the operation will throw. /// /// [coins] - An iterable of coin/asset configuration IDs to add. Future addActivatedCoins(Iterable coins) async { - final existingCoins = - (await auth.currentUser)?.metadata.valueOrNull>( - 'activated_coins', - ) ?? - []; - - final mergedCoins = {...existingCoins, ...coins}.toList(); - await auth.setOrRemoveActiveUserKeyValue('activated_coins', mergedCoins); + await auth.updateActiveUserKeyValue('activated_coins', (current) { + final existing = (current as List?)?.cast() ?? []; + return {...existing, ...coins}.toList(); + }); } /// Removes specified coin/asset IDs from the current user's activated coins list. /// - /// This method removes all occurrences of the provided [coins] from the user's - /// activated coins list and updates the stored metadata. + /// This method atomically removes all occurrences of the provided [coins] + /// from the user's activated coins list, ensuring no lost writes under + /// concurrent calls. /// - /// If no user is currently signed in, the operation will complete but have no effect. + /// If no user is currently signed in, the operation will throw. /// /// [coins] - A list of coin/asset configuration IDs to remove. Future removeActivatedCoins(List coins) async { - final existingCoins = - (await auth.currentUser)?.metadata.valueOrNull>( - 'activated_coins', - ) ?? - []; - - existingCoins.removeWhere((coin) => coins.contains(coin)); - await auth.setOrRemoveActiveUserKeyValue('activated_coins', existingCoins); + await auth.updateActiveUserKeyValue('activated_coins', (current) { + final existing = (current as List?)?.cast() ?? []; + final updated = existing.where((c) => !coins.contains(c)).toList(); + return updated.isEmpty ? null : updated; + }); } /// Sets the seed backup confirmation status for the current user. @@ -156,4 +152,25 @@ extension KdfAuthMetadataExtension on KomodoDefiSdk { Future setWalletType(WalletType type) async { await auth.setOrRemoveActiveUserKeyValue('type', type.name); } + + /// Sets the wallet provenance for the current user. + /// + /// Stored values are used by wallet selection UIs to show quick metadata + /// tags (generated/imported). + Future setWalletProvenance(WalletProvenance provenance) async { + await auth.setOrRemoveActiveUserKeyValue( + 'wallet_provenance', + provenance.name, + ); + } + + /// Sets the wallet creation timestamp for the current user. + /// + /// Stored as milliseconds since epoch. + Future setWalletCreatedAt(DateTime createdAt) async { + await auth.setOrRemoveActiveUserKeyValue( + 'wallet_created_at', + createdAt.millisecondsSinceEpoch, + ); + } } diff --git a/lib/model/main_menu_value.dart b/lib/model/main_menu_value.dart index 19295c9834..b4362f8d7a 100644 --- a/lib/model/main_menu_value.dart +++ b/lib/model/main_menu_value.dart @@ -3,8 +3,8 @@ import 'package:web_dex/generated/codegen_loader.g.dart'; enum MainMenuValue { wallet, - fiat, dex, + fiat, bridge, marketMakerBot, nft, @@ -80,9 +80,9 @@ enum MainMenuValue { switch (this) { case MainMenuValue.wallet: return 0; - case MainMenuValue.fiat: - return 1; case MainMenuValue.dex: + return 1; + case MainMenuValue.fiat: return 2; case MainMenuValue.bridge: return 3; diff --git a/lib/model/settings/market_maker_bot_settings.dart b/lib/model/settings/market_maker_bot_settings.dart index 8df41fa385..2b7bcef1ac 100644 --- a/lib/model/settings/market_maker_bot_settings.dart +++ b/lib/model/settings/market_maker_bot_settings.dart @@ -9,6 +9,7 @@ class MarketMakerBotSettings extends Equatable { const MarketMakerBotSettings({ required this.isMMBotEnabled, + required this.saveOrdersBetweenLaunches, required this.botRefreshRate, required this.tradeCoinPairConfigs, this.messageServiceConfig, @@ -21,6 +22,7 @@ class MarketMakerBotSettings extends Equatable { factory MarketMakerBotSettings.initial() { return MarketMakerBotSettings( isMMBotEnabled: false, + saveOrdersBetweenLaunches: true, botRefreshRate: 60, tradeCoinPairConfigs: const [], messageServiceConfig: null, @@ -34,9 +36,10 @@ class MarketMakerBotSettings extends Equatable { if (json == null) return MarketMakerBotSettings.initial(); final bool? enabled = json['is_market_maker_bot_enabled'] as bool?; - final int refresh = (json['bot_refresh_rate'] is int) - ? json['bot_refresh_rate'] as int - : int.tryParse('${json['bot_refresh_rate']}') ?? 60; + final dynamic refreshRaw = json['bot_refresh_rate']; + final int refresh = (refreshRaw is num) + ? refreshRaw.toInt() + : int.tryParse('$refreshRaw') ?? 60; final dynamic configsRaw = json['trade_coin_pair_configs']; final List configs = (configsRaw is List) @@ -79,6 +82,8 @@ class MarketMakerBotSettings extends Equatable { return MarketMakerBotSettings( isMMBotEnabled: enabled ?? false, + saveOrdersBetweenLaunches: + json['save_orders_between_launches'] as bool? ?? true, botRefreshRate: refresh, tradeCoinPairConfigs: configs, messageServiceConfig: messageCfg, @@ -88,6 +93,9 @@ class MarketMakerBotSettings extends Equatable { /// Whether the Market Maker Bot is enabled (menu item is shown or not). final bool isMMBotEnabled; + /// Whether maker order configs should be retained between app launches. + final bool saveOrdersBetweenLaunches; + /// The refresh rate of the bot in seconds. final int botRefreshRate; @@ -102,6 +110,7 @@ class MarketMakerBotSettings extends Equatable { Map toJson() { return { 'is_market_maker_bot_enabled': isMMBotEnabled, + 'save_orders_between_launches': saveOrdersBetweenLaunches, 'bot_refresh_rate': botRefreshRate, 'trade_coin_pair_configs': tradeCoinPairConfigs .map((e) => e.toJson()) @@ -115,6 +124,7 @@ class MarketMakerBotSettings extends Equatable { Map toLegacyJson() { return { 'is_market_maker_bot_enabled': isMMBotEnabled, + 'save_orders_between_launches': saveOrdersBetweenLaunches, // Old builds included a price_url; provide the previous default 'price_url': 'https://defistats.gleec.com/api/v3/prices/tickers_v2?expire_at=600', @@ -129,12 +139,15 @@ class MarketMakerBotSettings extends Equatable { MarketMakerBotSettings copyWith({ bool? isMMBotEnabled, + bool? saveOrdersBetweenLaunches, int? botRefreshRate, List? tradeCoinPairConfigs, MessageServiceConfig? messageServiceConfig, }) { return MarketMakerBotSettings( isMMBotEnabled: isMMBotEnabled ?? this.isMMBotEnabled, + saveOrdersBetweenLaunches: + saveOrdersBetweenLaunches ?? this.saveOrdersBetweenLaunches, botRefreshRate: botRefreshRate ?? this.botRefreshRate, tradeCoinPairConfigs: tradeCoinPairConfigs ?? this.tradeCoinPairConfigs, messageServiceConfig: messageServiceConfig ?? this.messageServiceConfig, @@ -144,6 +157,7 @@ class MarketMakerBotSettings extends Equatable { @override List get props => [ isMMBotEnabled, + saveOrdersBetweenLaunches, botRefreshRate, tradeCoinPairConfigs, messageServiceConfig, diff --git a/lib/model/settings_menu_value.dart b/lib/model/settings_menu_value.dart index 2ba25b8db8..0dc0916df8 100644 --- a/lib/model/settings_menu_value.dart +++ b/lib/model/settings_menu_value.dart @@ -4,6 +4,8 @@ import 'package:web_dex/generated/codegen_loader.g.dart'; enum SettingsMenuValue { general, security, + privacy, + kycPolicy, support, feedback, none; @@ -14,6 +16,10 @@ enum SettingsMenuValue { return LocaleKeys.settingsMenuGeneral.tr(); case SettingsMenuValue.security: return LocaleKeys.settingsMenuSecurity.tr(); + case SettingsMenuValue.privacy: + return LocaleKeys.settingsMenuPrivacy.tr(); + case SettingsMenuValue.kycPolicy: + return LocaleKeys.settingsMenuKycPolicy.tr(); case SettingsMenuValue.support: return LocaleKeys.support.tr(); case SettingsMenuValue.feedback: @@ -29,6 +35,10 @@ enum SettingsMenuValue { return 'general'; case SettingsMenuValue.security: return 'security'; + case SettingsMenuValue.privacy: + return 'privacy'; + case SettingsMenuValue.kycPolicy: + return 'kyc'; case SettingsMenuValue.support: return 'support'; case SettingsMenuValue.feedback: diff --git a/lib/model/stored_settings.dart b/lib/model/stored_settings.dart index c10085ebdd..c14c1f3ac8 100644 --- a/lib/model/stored_settings.dart +++ b/lib/model/stored_settings.dart @@ -12,6 +12,7 @@ class StoredSettings { required this.weakPasswordsAllowed, required this.hideZeroBalanceAssets, required this.diagnosticLoggingEnabled, + required this.hideBalances, }); final ThemeMode mode; @@ -21,6 +22,7 @@ class StoredSettings { final bool weakPasswordsAllowed; final bool hideZeroBalanceAssets; final bool diagnosticLoggingEnabled; + final bool hideBalances; static StoredSettings initial() { return StoredSettings( @@ -31,6 +33,7 @@ class StoredSettings { weakPasswordsAllowed: false, hideZeroBalanceAssets: false, diagnosticLoggingEnabled: false, + hideBalances: false, ); } @@ -47,6 +50,7 @@ class StoredSettings { weakPasswordsAllowed: json['weakPasswordsAllowed'] ?? false, hideZeroBalanceAssets: json['hideZeroBalanceAssets'] ?? false, diagnosticLoggingEnabled: json['diagnosticLoggingEnabled'] ?? false, + hideBalances: json['hideBalances'] ?? false, ); } @@ -59,6 +63,7 @@ class StoredSettings { 'weakPasswordsAllowed': weakPasswordsAllowed, 'hideZeroBalanceAssets': hideZeroBalanceAssets, 'diagnosticLoggingEnabled': diagnosticLoggingEnabled, + 'hideBalances': hideBalances, }; } @@ -72,6 +77,7 @@ class StoredSettings { 'testCoinsEnabled': testCoinsEnabled, 'weakPasswordsAllowed': weakPasswordsAllowed, 'hideZeroBalanceAssets': hideZeroBalanceAssets, + 'hideBalances': hideBalances, }; } @@ -83,6 +89,7 @@ class StoredSettings { bool? weakPasswordsAllowed, bool? hideZeroBalanceAssets, bool? diagnosticLoggingEnabled, + bool? hideBalances, }) { return StoredSettings( mode: mode ?? this.mode, @@ -95,6 +102,7 @@ class StoredSettings { hideZeroBalanceAssets ?? this.hideZeroBalanceAssets, diagnosticLoggingEnabled: diagnosticLoggingEnabled ?? this.diagnosticLoggingEnabled, + hideBalances: hideBalances ?? this.hideBalances, ); } } diff --git a/lib/model/swap.dart b/lib/model/swap.dart index 4b40e2e211..97f9184b01 100644 --- a/lib/model/swap.dart +++ b/lib/model/swap.dart @@ -25,19 +25,22 @@ class Swap extends Equatable { }); factory Swap.fromJson(Map json) { - final Rational makerAmount = fract2rat(json['maker_amount_fraction']) ?? + final Rational makerAmount = + fract2rat(json['maker_amount_fraction']) ?? Rational.parse(json['maker_amount'] ?? '0'); - final Rational takerAmount = fract2rat(json['taker_amount_fraction']) ?? + final Rational takerAmount = + fract2rat(json['taker_amount_fraction']) ?? Rational.parse(json['taker_amount'] ?? '0'); - final TradeSide type = - json['type'] == 'Taker' ? TradeSide.taker : TradeSide.maker; + final TradeSide type = json['type'] == 'Taker' + ? TradeSide.taker + : TradeSide.maker; return Swap( type: type, uuid: json['uuid'], myOrderUuid: json['my_order_uuid'] ?? '', - events: List>.from(json['events']) - .map((e) => SwapEventItem.fromJson(e)) - .toList(), + events: List>.from( + json['events'], + ).map((e) => SwapEventItem.fromJson(e)).toList(), makerAmount: makerAmount, makerCoin: json['maker_coin'] ?? '', takerAmount: takerAmount, @@ -92,10 +95,10 @@ class Swap extends Equatable { } bool get isCompleted => events.any( - (e) => - e.event.type == successEvents.last || - errorEvents.contains(e.event.type), - ); + (e) => + e.event.type == successEvents.last || + errorEvents.contains(e.event.type), + ); bool get isFailed => events.firstWhereOrNull( @@ -165,37 +168,35 @@ class Swap extends Equatable { @override List get props => [ - type, - uuid, - myOrderUuid, - events, - makerAmount, - makerCoin, - takerAmount, - takerCoin, - gui, - mmVersion, - successEvents, - errorEvents, - myInfo, - recoverable, - ]; + type, + uuid, + myOrderUuid, + events, + makerAmount, + makerCoin, + takerAmount, + takerCoin, + gui, + mmVersion, + successEvents, + errorEvents, + myInfo, + recoverable, + ]; } class SwapEventItem extends Equatable { - const SwapEventItem({ - required this.timestamp, - required this.event, - }); + const SwapEventItem({required this.timestamp, required this.event}); factory SwapEventItem.fromJson(Map json) => SwapEventItem( - timestamp: json['timestamp'], - event: SwapEvent.fromJson(json['event']), - ); + timestamp: assertInt(json['timestamp']) ?? 0, + event: SwapEvent.fromJson(json['event']), + ); final int timestamp; final SwapEvent event; - String get eventDateTime => DateFormat('d MMMM y, H:m') - .format(DateTime.fromMillisecondsSinceEpoch(timestamp)); + String get eventDateTime => DateFormat( + 'd MMMM y, H:m', + ).format(DateTime.fromMillisecondsSinceEpoch(timestamp)); Map toJson() { final data = {}; @@ -209,10 +210,7 @@ class SwapEventItem extends Equatable { } class SwapEvent extends Equatable { - const SwapEvent({ - required this.type, - required this.data, - }); + const SwapEvent({required this.type, required this.data}); factory SwapEvent.fromJson(Map json) { return SwapEvent( @@ -263,34 +261,38 @@ class SwapEventData extends Equatable { }); factory SwapEventData.fromJson(Map json) => SwapEventData( - takerCoin: json['taker_coin'], - makerCoin: json['maker_coin'], - maker: json['maker'], - myPersistentPub: json['my_persistent_pub'], - lockDuration: json['lock_duration'], - makerAmount: double.tryParse(json['maker_amount'] ?? ''), - takerAmount: double.tryParse(json['taker_amount'] ?? ''), - makerPaymentConfirmations: json['maker_payment_confirmations'], - makerPaymentRequiresNota: json['maker_payment_requires_nota'], - takerPaymentConfirmations: json['taker_payment_confirmations'], - takerPaymentRequiresNota: json['taker_payment_requires_nota'], - takerPaymentLock: json['taker_payment_lock'], - uuid: json['uuid'], - startedAt: json['started_at'], - makerPaymentWait: json['maker_payment_wait'], - makerCoinStartBlock: json['maker_coin_start_block'], - takerCoinStartBlock: json['taker_coin_start_block'], - feeToSendTakerFee: json['fee_to_send_taker_fee'] != null - ? TradeFee.fromJson(json['fee_to_send_taker_fee']) - : null, - takerPaymentTradeFee: json['taker_payment_trade_fee'] != null - ? TradeFee.fromJson(json['taker_payment_trade_fee']) - : null, - makerPaymentSpendTradeFee: json['maker_payment_spend_trade_fee'] != null - ? TradeFee.fromJson(json['maker_payment_spend_trade_fee']) - : null, - txHash: json['tx_hash'] ?? json['transaction']?['tx_hash'], - ); + takerCoin: json['taker_coin'], + makerCoin: json['maker_coin'], + maker: json['maker'], + myPersistentPub: json['my_persistent_pub'], + lockDuration: assertInt(json['lock_duration']), + makerAmount: json['maker_amount'] == null + ? null + : double.tryParse(json['maker_amount'].toString()), + takerAmount: json['taker_amount'] == null + ? null + : double.tryParse(json['taker_amount'].toString()), + makerPaymentConfirmations: assertInt(json['maker_payment_confirmations']), + makerPaymentRequiresNota: json['maker_payment_requires_nota'], + takerPaymentConfirmations: assertInt(json['taker_payment_confirmations']), + takerPaymentRequiresNota: json['taker_payment_requires_nota'], + takerPaymentLock: assertInt(json['taker_payment_lock']), + uuid: json['uuid'], + startedAt: assertInt(json['started_at']), + makerPaymentWait: assertInt(json['maker_payment_wait']), + makerCoinStartBlock: assertInt(json['maker_coin_start_block']), + takerCoinStartBlock: assertInt(json['taker_coin_start_block']), + feeToSendTakerFee: json['fee_to_send_taker_fee'] != null + ? TradeFee.fromJson(json['fee_to_send_taker_fee']) + : null, + takerPaymentTradeFee: json['taker_payment_trade_fee'] != null + ? TradeFee.fromJson(json['taker_payment_trade_fee']) + : null, + makerPaymentSpendTradeFee: json['maker_payment_spend_trade_fee'] != null + ? TradeFee.fromJson(json['maker_payment_spend_trade_fee']) + : null, + txHash: json['tx_hash'] ?? json['transaction']?['tx_hash'], + ); final String? takerCoin; final String? makerCoin; @@ -341,38 +343,31 @@ class SwapEventData extends Equatable { @override List get props => [ - takerCoin, - makerCoin, - maker, - myPersistentPub, - lockDuration, - makerAmount, - takerAmount, - makerPaymentConfirmations, - makerPaymentRequiresNota, - takerPaymentConfirmations, - takerPaymentRequiresNota, - takerPaymentLock, - uuid, - startedAt, - makerPaymentWait, - makerCoinStartBlock, - takerCoinStartBlock, - feeToSendTakerFee, - takerPaymentTradeFee, - makerPaymentSpendTradeFee, - txHash, - ]; + takerCoin, + makerCoin, + maker, + myPersistentPub, + lockDuration, + makerAmount, + takerAmount, + makerPaymentConfirmations, + makerPaymentRequiresNota, + takerPaymentConfirmations, + takerPaymentRequiresNota, + takerPaymentLock, + uuid, + startedAt, + makerPaymentWait, + makerCoinStartBlock, + takerCoinStartBlock, + feeToSendTakerFee, + takerPaymentTradeFee, + makerPaymentSpendTradeFee, + txHash, + ]; } -enum SwapStatus { - successful, - negotiated, - ongoing, - matched, - matching, - failed, -} +enum SwapStatus { successful, negotiated, ongoing, matched, matching, failed } class TradeFee extends Equatable { const TradeFee({ @@ -384,7 +379,9 @@ class TradeFee extends Equatable { factory TradeFee.fromJson(Map json) { return TradeFee( coin: json['coin'], - amount: double.tryParse(json['amount'] ?? ''), + amount: json['amount'] == null + ? null + : double.tryParse(json['amount'].toString()), paidFromTradingVol: json['paid_from_trading_vol'], ); } @@ -418,9 +415,9 @@ class SwapMyInfo extends Equatable { return SwapMyInfo( myCoin: json['my_coin'], otherCoin: json['other_coin'], - myAmount: double.parse(json['my_amount']), - otherAmount: double.parse(json['other_amount']), - startedAt: json['started_at'], + myAmount: double.parse(json['my_amount'].toString()), + otherAmount: double.parse(json['other_amount'].toString()), + startedAt: assertInt(json['started_at']) ?? 0, ); } @@ -442,10 +439,10 @@ class SwapMyInfo extends Equatable { @override List get props => [ - myCoin, - otherCoin, - myAmount, - otherAmount, - startedAt, - ]; + myCoin, + otherCoin, + myAmount, + otherAmount, + startedAt, + ]; } diff --git a/lib/model/text_error.dart b/lib/model/text_error.dart index a75adfedad..b360f430cb 100644 --- a/lib/model/text_error.dart +++ b/lib/model/text_error.dart @@ -1,7 +1,7 @@ import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; class TextError implements BaseError { - TextError({required this.error}); + TextError({required this.error, this.technicalDetails}); static TextError empty() { return TextError(error: ''); } @@ -13,8 +13,14 @@ class TextError implements BaseError { } static const String type = 'TextError'; + + /// User-friendly error message. final String error; + /// Raw technical details for debugging. When non-null, the UI should show + /// this in an expandable "Technical Details" section instead of [error]. + final String? technicalDetails; + @override String get message => error; } diff --git a/lib/model/wallet.dart b/lib/model/wallet.dart index df3ad20551..051bfe494c 100644 --- a/lib/model/wallet.dart +++ b/lib/model/wallet.dart @@ -36,6 +36,8 @@ class Wallet { hasBackup: hasBackup, type: walletType, seedPhrase: '', + provenance: WalletProvenance.generated, + createdAt: DateTime.now(), ), ); } @@ -85,6 +87,8 @@ class WalletConfig { this.pubKey, this.type = WalletType.iguana, this.isLegacyWallet = false, + this.provenance = WalletProvenance.unknown, + this.createdAt, }); factory WalletConfig.fromJson(Map json) { @@ -98,6 +102,12 @@ class WalletConfig { json['activated_coins'] as List? ?? [], ).toList(), hasBackup: json['has_backup'] as bool? ?? false, + provenance: WalletProvenance.fromJson( + json['wallet_provenance'] as String? ?? json['provenance'] as String?, + ), + createdAt: _parseCreatedAt( + json['wallet_created_at'] ?? json['created_at'], + ), ); } @@ -107,6 +117,8 @@ class WalletConfig { bool hasBackup; WalletType type; bool isLegacyWallet; + WalletProvenance provenance; + DateTime? createdAt; Map toJson() { return { @@ -115,6 +127,8 @@ class WalletConfig { 'pub_key': pubKey, 'activated_coins': activatedCoins, 'has_backup': hasBackup, + 'provenance': provenance.name, + 'created_at': createdAt?.millisecondsSinceEpoch, }; } @@ -128,6 +142,8 @@ class WalletConfig { // Preserve legacy flag when copying config; losing this flag breaks // legacy login flow and can hide the wallet from lists. isLegacyWallet: isLegacyWallet, + provenance: provenance, + createdAt: createdAt, ); } @@ -138,6 +154,8 @@ class WalletConfig { bool? hasBackup, WalletType? type, bool? isLegacyWallet, + WalletProvenance? provenance, + DateTime? createdAt, }) { return WalletConfig( seedPhrase: seedPhrase ?? this.seedPhrase, @@ -146,8 +164,23 @@ class WalletConfig { hasBackup: hasBackup ?? this.hasBackup, type: type ?? this.type, isLegacyWallet: isLegacyWallet ?? this.isLegacyWallet, + provenance: provenance ?? this.provenance, + createdAt: createdAt ?? this.createdAt, ); } + + static DateTime? _parseCreatedAt(dynamic value) { + if (value is int) { + return DateTime.fromMillisecondsSinceEpoch(value); + } + if (value is String) { + final asInt = int.tryParse(value); + if (asInt != null) { + return DateTime.fromMillisecondsSinceEpoch(asInt); + } + } + return null; + } } enum WalletType { @@ -173,11 +206,30 @@ enum WalletType { } } +enum WalletProvenance { + generated, + imported, + unknown; + + factory WalletProvenance.fromJson(String? value) { + switch (value) { + case 'generated': + return WalletProvenance.generated; + case 'imported': + case 'restored': + case 'migrated': + return WalletProvenance.imported; + default: + return WalletProvenance.unknown; + } + } +} + extension KdfUserWalletExtension on KdfUser { Wallet get wallet { - final walletType = WalletType.fromJson( - metadata['type'] as String? ?? 'iguana', - ); + final walletType = _walletTypeFromMetadataOrAuth(this); + final provenance = _walletProvenanceFromMetadata(this); + final createdAt = _walletCreatedAtFromMetadata(this); return Wallet( id: walletId.name, name: walletId.name, @@ -188,6 +240,8 @@ extension KdfUserWalletExtension on KdfUser { metadata.valueOrNull>('activated_coins') ?? [], hasBackup: metadata['has_backup'] as bool? ?? false, type: walletType, + provenance: provenance, + createdAt: createdAt, ), ); } @@ -197,3 +251,40 @@ extension KdfSdkWalletExtension on KomodoDefiSdk { Future> get wallets async => (await auth.getUsers()).map((user) => user.wallet); } + +WalletType _walletTypeFromMetadataOrAuth(KdfUser user) { + final metadataType = user.metadata['type']; + if (metadataType is String && metadataType.isNotEmpty) { + return WalletType.fromJson(metadataType); + } + + return user.walletId.isHd ? WalletType.hdwallet : WalletType.iguana; +} + +WalletProvenance _walletProvenanceFromMetadata(KdfUser user) { + final metadataProvenance = user.metadata['wallet_provenance']; + if (metadataProvenance is String && metadataProvenance.isNotEmpty) { + return WalletProvenance.fromJson(metadataProvenance); + } + + final isImported = user.metadata['isImported']; + if (isImported is bool) { + return isImported ? WalletProvenance.imported : WalletProvenance.generated; + } + + return WalletProvenance.unknown; +} + +DateTime? _walletCreatedAtFromMetadata(KdfUser user) { + final createdAtRaw = user.metadata['wallet_created_at']; + if (createdAtRaw is int) { + return DateTime.fromMillisecondsSinceEpoch(createdAtRaw); + } + if (createdAtRaw is String) { + final createdAtMs = int.tryParse(createdAtRaw); + if (createdAtMs != null) { + return DateTime.fromMillisecondsSinceEpoch(createdAtMs); + } + } + return null; +} diff --git a/lib/platform/platform.dart b/lib/platform/platform.dart index 8ae69d4300..a1b7bceca8 100644 --- a/lib/platform/platform.dart +++ b/lib/platform/platform.dart @@ -1,2 +1,2 @@ export 'package:web_dex/platform/platform_native.dart' - if (dart.library.html) 'package:web_dex/platform/platform_web.dart'; + if (dart.library.js_interop) 'package:web_dex/platform/platform_web.dart'; diff --git a/lib/platform/platform_native.dart b/lib/platform/platform_native.dart index 85d80df98c..3307855433 100644 --- a/lib/platform/platform_native.dart +++ b/lib/platform/platform_native.dart @@ -2,7 +2,7 @@ void reloadPage() {} bool canLogin(String? _) => true; -dynamic initWasm() async {} +Future initWasm() async {} Future wasmRunMm2( String params, diff --git a/lib/platform/platform_web.dart b/lib/platform/platform_web.dart index 6cfdf22c79..169909ca8a 100644 --- a/lib/platform/platform_web.dart +++ b/lib/platform/platform_web.dart @@ -1,28 +1,50 @@ +import 'dart:js_interop'; + +@JS('kdf') +external _KdfBindings? get _kdfBindings; + @JS() -library wasmlib; +extension type _KdfBindings._(JSObject _) implements JSObject { + @JS('init_wasm') + external JSPromise initWasm(); + + @JS('run_mm2') + external JSPromise runMm2(String params, JSFunction handleLog); + + @JS('mm2_status') + external JSAny? mm2Status(); + + @JS('mm2_version') + external String mm2Version(); + + @JS('rpc_request') + external JSPromise rpcRequest(String request); + + @JS('reload_page') + external void reloadPage(); +} -import 'package:js/js.dart'; +_KdfBindings _requireKdfBindings() { + final bindings = _kdfBindings; + if (bindings == null) { + throw StateError('KDF bootstrap is not loaded'); + } + return bindings; +} -@JS('init_wasm') -external dynamic initWasm(); +JSPromise initWasm() => _requireKdfBindings().initWasm(); -@JS('run_mm2') -external Future wasmRunMm2( - String params, - void Function(int, String) handleLog, -); +JSPromise wasmRunMm2(String params, JSFunction handleLog) => + _requireKdfBindings().runMm2(params, handleLog); -@JS('mm2_status') -external dynamic wasmMm2Status(); +JSAny? wasmMm2Status() => _requireKdfBindings().mm2Status(); -@JS('mm2_version') -external String wasmVersion(); +String wasmVersion() => _requireKdfBindings().mm2Version(); -@JS('rpc_request') -external dynamic wasmRpc(String request); +JSPromise wasmRpc(String request) => + _requireKdfBindings().rpcRequest(request); -@JS('reload_page') -external void reloadPage(); +void reloadPage() => _requireKdfBindings().reloadPage(); @JS('changeTheme') external void changeHtmlTheme(int themeIndex); diff --git a/lib/router/navigators/app_router_delegate.dart b/lib/router/navigators/app_router_delegate.dart index 90f70b0f4d..7acd98c5ed 100644 --- a/lib/router/navigators/app_router_delegate.dart +++ b/lib/router/navigators/app_router_delegate.dart @@ -37,9 +37,7 @@ class AppRouterDelegate extends RouterDelegate materialPageContext = context; return GestureDetector( onTap: () => runDropdownDismiss(context), - child: MainLayout( - key: ValueKey('${routingState.selectedMenu}'), - ), + child: MainLayout(key: ValueKey('${routingState.selectedMenu}')), ); }, ), @@ -247,6 +245,10 @@ class AppRouterDelegate extends RouterDelegate return SettingsRoutePath.general(); case SettingsMenuValue.security: return SettingsRoutePath.security(); + case SettingsMenuValue.privacy: + return SettingsRoutePath.privacy(); + case SettingsMenuValue.kycPolicy: + return SettingsRoutePath.kyc(); case SettingsMenuValue.support: return SettingsRoutePath.support(); case SettingsMenuValue.feedback: diff --git a/lib/router/navigators/main_layout/main_layout_router_delegate.dart b/lib/router/navigators/main_layout/main_layout_router_delegate.dart index 337fbedc63..893a91cf26 100644 --- a/lib/router/navigators/main_layout/main_layout_router_delegate.dart +++ b/lib/router/navigators/main_layout/main_layout_router_delegate.dart @@ -15,32 +15,41 @@ class MainLayoutRouterDelegate extends RouterDelegate @override Widget build(BuildContext context) { - return Builder(builder: (context) { - switch (screenType) { - case ScreenType.mobile: - return Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: maxScreenWidth, + return LayoutBuilder( + builder: (context, constraints) { + final double width = constraints.maxWidth; + final bool isCompact = width < 1100; + final double leftPadding = isCompact ? 16 : 24; + final double rightPadding = isCompact ? 16 : mainLayoutPadding; + final EdgeInsets contentPadding = EdgeInsets.fromLTRB( + leftPadding, + 0, + rightPadding, + 0, + ); + + switch (screenType) { + case ScreenType.mobile: + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: maxScreenWidth), + child: _MobileLayout(), ), - child: _MobileLayout(), - ), - ); - case ScreenType.tablet: - return Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: maxScreenWidth, + ); + case ScreenType.tablet: + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: maxScreenWidth), + child: _TabletLayout(contentPadding: contentPadding), ), - child: _TabletLayout(), - ), - ); - case ScreenType.desktop: - return _DesktopLayout(); - } - }); + ); + case ScreenType.desktop: + return _DesktopLayout(contentPadding: contentPadding); + } + }, + ); } @override @@ -48,6 +57,10 @@ class MainLayoutRouterDelegate extends RouterDelegate } class _DesktopLayout extends StatelessWidget { + const _DesktopLayout({required this.contentPadding}); + + final EdgeInsets contentPadding; + @override Widget build(BuildContext context) { return Row( @@ -75,8 +88,7 @@ class _DesktopLayout extends StatelessWidget { // Main content Expanded( child: Container( - padding: - const EdgeInsets.fromLTRB(24, 0, mainLayoutPadding, 0), + padding: contentPadding, child: PageContentRouter(), ), ), @@ -89,16 +101,17 @@ class _DesktopLayout extends StatelessWidget { } class _TabletLayout extends StatelessWidget { + const _TabletLayout({required this.contentPadding}); + + final EdgeInsets contentPadding; + @override Widget build(BuildContext context) { return Column( children: [ const MainLayoutTopBar(), Expanded( - child: Container( - padding: const EdgeInsets.fromLTRB(24, 0, mainLayoutPadding, 0), - child: PageContentRouter(), - ), + child: Container(padding: contentPadding, child: PageContentRouter()), ), ], ); diff --git a/lib/router/parsers/settings_route_parser.dart b/lib/router/parsers/settings_route_parser.dart index c230633949..9adc652614 100644 --- a/lib/router/parsers/settings_route_parser.dart +++ b/lib/router/parsers/settings_route_parser.dart @@ -18,6 +18,14 @@ class _SettingsRouteParser implements BaseRouteParser { return SettingsRoutePath.security(); } + if (uri.pathSegments[1] == 'privacy') { + return SettingsRoutePath.privacy(); + } + + if (uri.pathSegments[1] == 'kyc') { + return SettingsRoutePath.kyc(); + } + // TODO: Remove since the feedback is now handled by `BetterFeedback` if (uri.pathSegments[1] == 'feedback') { return SettingsRoutePath.feedback(); diff --git a/lib/router/routes.dart b/lib/router/routes.dart index fbc295a5c3..4f35a5be3a 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -14,9 +14,9 @@ class WalletRoutePath implements AppRoutePath { WalletRoutePath.wallet() : location = '/${firstUriSegment.wallet}'; WalletRoutePath.coinDetails(this.abbr) - : location = '/${firstUriSegment.wallet}/${abbr.toLowerCase()}'; + : location = '/${firstUriSegment.wallet}/${abbr.toLowerCase()}'; WalletRoutePath.action(this.action) - : location = '/${firstUriSegment.wallet}/$action'; + : location = '/${firstUriSegment.wallet}/$action'; String abbr = ''; String action = ''; @@ -26,11 +26,9 @@ class WalletRoutePath implements AppRoutePath { } class FiatRoutePath implements AppRoutePath { - FiatRoutePath.fiat() - : location = '/${firstUriSegment.fiat}', - uuid = ''; + FiatRoutePath.fiat() : location = '/${firstUriSegment.fiat}', uuid = ''; FiatRoutePath.swapDetails(this.action, this.uuid) - : location = '/${firstUriSegment.fiat}/trading_details/$uuid'; + : location = '/${firstUriSegment.fiat}/trading_details/$uuid'; @override final String location; @@ -61,17 +59,18 @@ class DexRoutePath implements AppRoutePath { if (toAmount.isNotEmpty) queryParams.add('to_amount=$toAmount'); if (orderType.isNotEmpty) queryParams.add('order_type=$orderType'); - final String queryString = - queryParams.isNotEmpty ? '?${queryParams.join('&')}' : ''; + final String queryString = queryParams.isNotEmpty + ? '?${queryParams.join('&')}' + : ''; return '/${firstUriSegment.dex}$queryString'; } DexRoutePath.swapDetails(this.action, this.uuid) - : fromCurrency = '', - fromAmount = '', - toCurrency = '', - toAmount = '', - orderType = ''; + : fromCurrency = '', + fromAmount = '', + toCurrency = '', + toAmount = '', + orderType = ''; final String uuid; DexAction action = DexAction.none; @@ -84,11 +83,9 @@ class DexRoutePath implements AppRoutePath { } class BridgeRoutePath implements AppRoutePath { - BridgeRoutePath.bridge() - : location = '/${firstUriSegment.bridge}', - uuid = ''; + BridgeRoutePath.bridge() : location = '/${firstUriSegment.bridge}', uuid = ''; BridgeRoutePath.swapDetails(this.action, this.uuid) - : location = '/${firstUriSegment.bridge}/trading_details/$uuid'; + : location = '/${firstUriSegment.bridge}/trading_details/$uuid'; @override final String location; @@ -98,20 +95,20 @@ class BridgeRoutePath implements AppRoutePath { class NftRoutePath implements AppRoutePath { NftRoutePath.nfts() - : location = '/${firstUriSegment.nfts}', - uuid = '', - pageState = NFTSelectedState.none; + : location = '/${firstUriSegment.nfts}', + uuid = '', + pageState = NFTSelectedState.none; NftRoutePath.nftDetails(this.uuid, bool isSend) - : location = '/${firstUriSegment.nfts}/$uuid', - pageState = isSend ? NFTSelectedState.send : NFTSelectedState.details; + : location = '/${firstUriSegment.nfts}/$uuid', + pageState = isSend ? NFTSelectedState.send : NFTSelectedState.details; NftRoutePath.nftReceive() - : location = '/${firstUriSegment.nfts}/receive', - uuid = '', - pageState = NFTSelectedState.receive; + : location = '/${firstUriSegment.nfts}/receive', + uuid = '', + pageState = NFTSelectedState.receive; NftRoutePath.nftTransactions() - : location = '/${firstUriSegment.nfts}/transactions', - pageState = NFTSelectedState.transactions, - uuid = ''; + : location = '/${firstUriSegment.nfts}/transactions', + pageState = NFTSelectedState.transactions, + uuid = ''; @override final String location; @@ -121,10 +118,10 @@ class NftRoutePath implements AppRoutePath { class MarketMakerBotRoutePath implements AppRoutePath { MarketMakerBotRoutePath.marketMakerBot() - : location = '/${firstUriSegment.marketMakerBot}', - uuid = ''; + : location = '/${firstUriSegment.marketMakerBot}', + uuid = ''; MarketMakerBotRoutePath.swapDetails(this.action, this.uuid) - : location = '/${firstUriSegment.marketMakerBot}/trading_details/$uuid'; + : location = '/${firstUriSegment.marketMakerBot}/trading_details/$uuid'; @override final String location; @@ -134,23 +131,29 @@ class MarketMakerBotRoutePath implements AppRoutePath { class SettingsRoutePath implements AppRoutePath { SettingsRoutePath.root() - : location = '/${firstUriSegment.settings}', - selectedMenu = SettingsMenuValue.none; + : location = '/${firstUriSegment.settings}', + selectedMenu = SettingsMenuValue.none; SettingsRoutePath.general() - : location = '/${firstUriSegment.settings}/general', - selectedMenu = SettingsMenuValue.general; + : location = '/${firstUriSegment.settings}/general', + selectedMenu = SettingsMenuValue.general; SettingsRoutePath.security() - : location = '/${firstUriSegment.settings}/security', - selectedMenu = SettingsMenuValue.security; + : location = '/${firstUriSegment.settings}/security', + selectedMenu = SettingsMenuValue.security; + SettingsRoutePath.privacy() + : location = '/${firstUriSegment.settings}/privacy', + selectedMenu = SettingsMenuValue.privacy; + SettingsRoutePath.kyc() + : location = '/${firstUriSegment.settings}/kyc', + selectedMenu = SettingsMenuValue.kycPolicy; SettingsRoutePath.passwordUpdate() - : location = '/${firstUriSegment.settings}/security/passwordUpdate', - selectedMenu = SettingsMenuValue.security; + : location = '/${firstUriSegment.settings}/security/passwordUpdate', + selectedMenu = SettingsMenuValue.security; SettingsRoutePath.support() - : location = '/${firstUriSegment.settings}/support', - selectedMenu = SettingsMenuValue.support; + : location = '/${firstUriSegment.settings}/support', + selectedMenu = SettingsMenuValue.support; SettingsRoutePath.feedback() - : location = '/${firstUriSegment.settings}/feedback', - selectedMenu = SettingsMenuValue.feedback; + : location = '/${firstUriSegment.settings}/feedback', + selectedMenu = SettingsMenuValue.feedback; @override final String location; diff --git a/lib/sdk/widgets/window_close_handler.dart b/lib/sdk/widgets/window_close_handler.dart index 006620a149..5e65f6ed2f 100644 --- a/lib/sdk/widgets/window_close_handler.dart +++ b/lib/sdk/widgets/window_close_handler.dart @@ -1,5 +1,5 @@ -import 'dart:io' show exit - if (dart.library.html) 'window_close_handler_exit_stub.dart' show exit; +import 'dart:io' + if (dart.library.js_interop) 'window_close_handler_exit_stub.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -56,10 +56,12 @@ class _WindowCloseHandlerState extends State // standard window closing, then manually trigger exit via SystemNavigator FlutterWindowClose.setWindowShouldCloseHandler(() async { final shouldClose = await _handleWindowClose(); - + // On Linux, if user confirmed, we need to manually exit instead of letting // flutter_window_close handle it, to avoid GTK cleanup issues - if (!kIsWeb && defaultTargetPlatform == TargetPlatform.linux && shouldClose) { + if (!kIsWeb && + defaultTargetPlatform == TargetPlatform.linux && + shouldClose) { // Hide window immediately // Then exit after a short delay to allow any final cleanup Future.delayed(const Duration(milliseconds: 200), () { @@ -68,7 +70,7 @@ class _WindowCloseHandlerState extends State // Return false to prevent flutter_window_close from closing the window return false; } - + return shouldClose; }); } else if (kIsWeb) { diff --git a/lib/services/arrr_activation/arrr_activation_service.dart b/lib/services/arrr_activation/arrr_activation_service.dart index 80be34869e..f6abd44b5f 100644 --- a/lib/services/arrr_activation/arrr_activation_service.dart +++ b/lib/services/arrr_activation/arrr_activation_service.dart @@ -7,6 +7,7 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; import 'package:mutex/mutex.dart'; import 'package:web_dex/mm2/mm2.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; import 'package:web_dex/mm2/mm2_api/rpc/disable_coin/disable_coin_req.dart'; import 'arrr_config.dart'; @@ -32,6 +33,7 @@ class ArrrActivationService { /// Track ongoing activation flows per asset to prevent duplicate runs final Map> _ongoingActivations = {}; + final Set _cancelledActivations = {}; /// Subscription to auth state changes StreamSubscription? _authSubscription; @@ -66,6 +68,7 @@ class ArrrActivationService { _activateArrrInternal(asset, initialConfig: initialConfig).whenComplete( () { _ongoingActivations.remove(asset.id); + _cancelledActivations.remove(asset.id); }, ); _ongoingActivations[asset.id] = activationFuture; @@ -76,6 +79,8 @@ class ArrrActivationService { Asset asset, { ZhtlcUserConfig? initialConfig, }) async { + _cancelledActivations.remove(asset.id); + var config = initialConfig ?? await _getOrRequestConfiguration(asset.id); if (config == null) { @@ -114,7 +119,7 @@ class ArrrActivationService { e, stackTrace, ); - return ArrrActivationResultError('Failed to request configuration: $e'); + return ArrrActivationResultError(formatKdfUserFacingError(e)); } try { @@ -154,6 +159,10 @@ class ArrrActivationService { try { final result = await retry( () async { + if (_isActivationCancelled(asset.id)) { + throw _ActivationCancelledException(); + } + attempt += 1; _log.info( 'Starting ARRR activation attempt $attempt for ${asset.id.id}', @@ -165,6 +174,9 @@ class ArrrActivationService { await for (final activationProgress in _sdk.assets.activateAsset( asset, )) { + if (_isActivationCancelled(asset.id)) { + throw _ActivationCancelledException(); + } await _cacheActivationProgress(asset.id, activationProgress); lastActivationProgress = activationProgress; } @@ -187,6 +199,7 @@ class ArrrActivationService { } final errorMessage = + lastActivationProgress?.sdkError?.fallbackMessage ?? lastActivationProgress?.errorMessage ?? 'Unknown activation error'; throw _RetryableZhtlcActivationException(errorMessage); @@ -196,6 +209,7 @@ class ArrrActivationService { initialDelay: const Duration(seconds: 5), maxDelay: const Duration(seconds: 30), ), + shouldRetry: (error) => error is _RetryableZhtlcActivationException, onRetry: (currentAttempt, error, delay) { _log.warning( 'ARRR activation attempt $currentAttempt for ${asset.id.id} failed. ' @@ -205,6 +219,10 @@ class ArrrActivationService { ); return result; + } on _ActivationCancelledException { + _log.info('ARRR activation cancelled by user for ${asset.id.id}'); + await _cacheActivationError(asset.id, 'Activation cancelled by user'); + return const ArrrActivationResultError('Activation cancelled by user'); } catch (e, stackTrace) { _log.severe( 'ARRR activation failed after $maxAttempts attempts for ${asset.id.id}', @@ -246,6 +264,9 @@ class ArrrActivationService { AssetId assetId, ActivationProgress progress, ) async { + if (_isActivationCancelled(assetId)) { + return; + } await _activationCacheMutex.protectWrite(() async { final current = _activationCache[assetId]; if (current is ArrrActivationStatusInProgress) { @@ -259,6 +280,9 @@ class ArrrActivationService { } Future _cacheActivationComplete(AssetId assetId) async { + if (_isActivationCancelled(assetId)) { + return; + } await _activationCacheMutex.protectWrite(() async { _activationCache[assetId] = ArrrActivationStatusCompleted( assetId: assetId, @@ -269,8 +293,12 @@ class ArrrActivationService { Future _cacheActivationError( AssetId assetId, - String errorMessage, - ) async { + String errorMessage, { + bool allowCancelledWrite = false, + }) async { + if (!allowCancelledWrite && _isActivationCancelled(assetId)) { + return; + } await _activationCacheMutex.protectWrite(() async { _activationCache[assetId] = ArrrActivationStatusError( assetId: assetId, @@ -302,6 +330,21 @@ class ArrrActivationService { ); } + Future cancelActivation(AssetId assetId) async { + _log.info('Cancelling activation for ${assetId.id}'); + _cancelledActivations.add(assetId); + _sdk.assets.cancelActivation( + assetId, + reason: 'Activation cancelled by user', + ); + cancelConfiguration(assetId); + await _cacheActivationError( + assetId, + 'Activation cancelled by user', + allowCancelledWrite: true, + ); + } + /// Submit configuration for a pending request /// Called by UI when user provides configuration Future submitConfiguration( @@ -424,6 +467,8 @@ class ArrrActivationService { /// Clean up all user-specific state when user signs out Future _cleanupOnSignOut() async { _log.info('User signed out - cleaning up active ZHTLC activations'); + final cancelledAssetIds = await _markActiveAssetsAsCancelled(); + _cancelSdkActivations(cancelledAssetIds); // Cancel all pending configuration requests final pendingAssets = _configCompleters.keys.toList(); @@ -447,7 +492,8 @@ class ArrrActivationService { }); _log.info( - 'Cleanup completed - cancelled ${pendingAssets.length} pending configs and cleared ${activeAssets.length} activation statuses', + 'Cleanup completed - marked ${cancelledAssetIds.length} assets as cancelled, ' + 'cancelled ${pendingAssets.length} pending configs and cleared ${activeAssets.length} activation statuses', ); } @@ -456,10 +502,7 @@ class ArrrActivationService { /// 1. Cancel any ongoing activation tasks for the asset /// 2. Disable the coin if it's currently active /// 3. Store the new configuration - Future updateZhtlcConfig( - Asset asset, - ZhtlcUserConfig newConfig, - ) async { + Future updateZhtlcConfig(Asset asset, ZhtlcUserConfig newConfig) async { if (_isDisposing || _configRequestController.isClosed) { throw StateError('ArrrActivationService has been disposed'); } @@ -517,6 +560,14 @@ class ArrrActivationService { // Mark as disposing to prevent new operations _isDisposing = true; + final cancelledAssetIds = { + ..._ongoingActivations.keys, + ..._configCompleters.keys, + ..._activationCache.keys, + }; + _cancelledActivations.addAll(cancelledAssetIds); + _cancelSdkActivations(cancelledAssetIds); + // Cancel auth subscription first _authSubscription?.cancel(); @@ -533,6 +584,34 @@ class ArrrActivationService { _configRequestController.close(); } } + + Future> _markActiveAssetsAsCancelled() async { + final cancelledAssetIds = { + ..._ongoingActivations.keys, + ..._configCompleters.keys, + }; + + final cachedAssets = await _activationCacheMutex.protectRead( + () async => _activationCache.keys.toList(), + ); + cancelledAssetIds.addAll(cachedAssets); + _cancelledActivations.addAll(cancelledAssetIds); + + return cancelledAssetIds; + } + + bool _isActivationCancelled(AssetId assetId) { + return _cancelledActivations.contains(assetId); + } + + void _cancelSdkActivations(Set assetIds) { + for (final assetId in assetIds) { + _sdk.assets.cancelActivation( + assetId, + reason: 'Activation cancelled due to auth/session cleanup', + ); + } + } } class _RetryableZhtlcActivationException implements Exception { @@ -544,6 +623,13 @@ class _RetryableZhtlcActivationException implements Exception { String toString() => 'RetryableZhtlcActivationException: $message'; } +class _ActivationCancelledException implements Exception { + const _ActivationCancelledException(); + + @override + String toString() => 'Activation cancelled by user'; +} + /// Configuration request model for UI handling class ZhtlcConfigurationRequest { const ZhtlcConfigurationRequest({ diff --git a/lib/services/fd_monitor_service.dart b/lib/services/fd_monitor_service.dart index df9cf56f3b..67781e5bbf 100644 --- a/lib/services/fd_monitor_service.dart +++ b/lib/services/fd_monitor_service.dart @@ -3,8 +3,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; class FdMonitorService { - static const MethodChannel _channel = - MethodChannel('com.komodo.wallet/fd_monitor'); + static const MethodChannel _channel = MethodChannel( + 'com.komodo.wallet/fd_monitor', + ); static FdMonitorService? _instance; @@ -44,17 +45,13 @@ class FdMonitorService { 'message': 'FD monitoring not available on this platform', }; } catch (e) { - return { - 'success': false, - 'message': 'Unexpected error: $e', - }; + return {'success': false, 'message': 'Unexpected error: $e'}; } } Future> stop() async { try { - final result = - await _channel.invokeMethod>('stop'); + final result = await _channel.invokeMethod>('stop'); if (result != null) { _isMonitoring = false; @@ -74,17 +71,15 @@ class FdMonitorService { 'message': 'FD monitoring not available on this platform', }; } catch (e) { - return { - 'success': false, - 'message': 'Unexpected error: $e', - }; + return {'success': false, 'message': 'Unexpected error: $e'}; } } Future getCurrentCount() async { try { - final result = - await _channel.invokeMethod>('getCurrentCount'); + final result = await _channel.invokeMethod>( + 'getCurrentCount', + ); if (result != null) { return FdMonitorStats.fromMap(Map.from(result)); @@ -104,8 +99,9 @@ class FdMonitorService { Future> logDetailedStatus() async { try { - final result = - await _channel.invokeMethod>('logDetailedStatus'); + final result = await _channel.invokeMethod>( + 'logDetailedStatus', + ); if (result != null) { return Map.from(result); @@ -124,10 +120,7 @@ class FdMonitorService { 'message': 'FD monitoring not available on this platform', }; } catch (e) { - return { - 'success': false, - 'message': 'Unexpected error: $e', - }; + return {'success': false, 'message': 'Unexpected error: $e'}; } } @@ -157,10 +150,10 @@ class FdMonitorStats { factory FdMonitorStats.fromMap(Map map) { return FdMonitorStats( - openCount: map['openCount'] as int? ?? 0, - tableSize: map['tableSize'] as int? ?? 0, - softLimit: map['softLimit'] as int? ?? 0, - hardLimit: map['hardLimit'] as int? ?? 0, + openCount: (map['openCount'] as num?)?.toInt() ?? 0, + tableSize: (map['tableSize'] as num?)?.toInt() ?? 0, + softLimit: (map['softLimit'] as num?)?.toInt() ?? 0, + hardLimit: (map['hardLimit'] as num?)?.toInt() ?? 0, percentUsed: (map['percentUsed'] as num?)?.toDouble() ?? 0.0, timestamp: map['timestamp'] as String? ?? '', ); diff --git a/lib/services/file_loader/file_loader.dart b/lib/services/file_loader/file_loader.dart index 6e3fb0fa23..b8236df0fc 100644 --- a/lib/services/file_loader/file_loader.dart +++ b/lib/services/file_loader/file_loader.dart @@ -1,7 +1,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:web_dex/services/file_loader/file_loader_stub.dart' if (dart.library.io) 'package:web_dex/services/file_loader/file_loader_native.dart' - if (dart.library.html) 'package:web_dex/services/file_loader/file_loader_web.dart'; + if (dart.library.js_interop) 'package:web_dex/services/file_loader/file_loader_web.dart'; abstract class FileLoader { const FileLoader(); diff --git a/lib/services/file_loader/file_loader_web.dart b/lib/services/file_loader/file_loader_web.dart index 361e215438..1318593e98 100644 --- a/lib/services/file_loader/file_loader_web.dart +++ b/lib/services/file_loader/file_loader_web.dart @@ -33,8 +33,10 @@ class FileLoaderWeb implements FileLoader { required String data, }) async { final dataArray = web.TextEncoder().encode(data); - final blob = - web.Blob([dataArray].toJS, web.BlobPropertyBag(type: 'text/plain')); + final blob = web.Blob( + [dataArray].toJS, + web.BlobPropertyBag(type: 'text/plain'), + ); final url = web.URL.createObjectURL(blob); @@ -99,13 +101,19 @@ class FileLoaderWeb implements FileLoader { final encoder = web.TextEncoder(); final dataArray = encoder.encode(data); - final blob = - web.Blob([dataArray].toJS, web.BlobPropertyBag(type: 'text/plain')); + final blob = web.Blob( + [dataArray].toJS, + web.BlobPropertyBag(type: 'text/plain'), + ); final response = web.Response(blob); + final compressionStream = web.CompressionStream('gzip'); final compressedResponse = web.Response( response.body!.pipeThrough( - web.CompressionStream('gzip') as web.ReadableWritablePair, + web.ReadableWritablePair( + readable: compressionStream.readable, + writable: compressionStream.writable, + ), ), ); @@ -148,23 +156,34 @@ class FileLoaderWeb implements FileLoader { } if (files.length == 1) { - final web.File? file = files.item(0); + final file = files.item(0); + if (file == null) { + onError('No file was selected.'); + return; + } final reader = web.FileReader(); - reader.onLoadEnd.listen((event) { + reader.onLoadEnd.listen((_) { final result = reader.result; - if (result case final String content) { - onUpload(file!.name, content); + if (result == null) { + onError('Failed to read ${file.name}.'); + return; + } + + final dartResult = result.dartify(); + if (dartResult case final String content) { + onUpload(file.name, content); + return; } + + onError('Unsupported file content returned for ${file.name}.'); }); - reader - ..onerror = (JSAny event) { - if (event is web.ErrorEvent) { - onError(event.message); - } - }.toJS - ..readAsText(file! as web.Blob); + reader.onerror = ((JSAny _) { + onError(reader.error?.message ?? 'Failed to read ${file.name}.'); + return null; + }).toJS; + reader.readAsText(file); } }); } diff --git a/lib/services/initializer/app_bootstrapper.dart b/lib/services/initializer/app_bootstrapper.dart index a9813a080a..5c3486597d 100644 --- a/lib/services/initializer/app_bootstrapper.dart +++ b/lib/services/initializer/app_bootstrapper.dart @@ -68,7 +68,19 @@ final class AppBootstrapper { /// Initialize settings and register analytics Future _initializeSettings() async { - final stored = await SettingsRepository.loadStoredSettings(); + var stored = await SettingsRepository.loadStoredSettings(); + final mmSettings = stored.marketMakerBotSettings; + final shouldClearStoredMakerOrders = + !mmSettings.saveOrdersBetweenLaunches && + mmSettings.tradeCoinPairConfigs.isNotEmpty; + if (shouldClearStoredMakerOrders) { + final clearedMmSettings = mmSettings.copyWith( + tradeCoinPairConfigs: const [], + ); + stored = stored.copyWith(marketMakerBotSettings: clearedMmSettings); + await SettingsRepository().updateSettings(stored); + } + _storedSettings = stored; // Register the unified analytics repository with GetIt diff --git a/lib/services/legal_documents/legal_document.dart b/lib/services/legal_documents/legal_document.dart new file mode 100644 index 0000000000..20a1e27582 --- /dev/null +++ b/lib/services/legal_documents/legal_document.dart @@ -0,0 +1,74 @@ +enum LegalDocumentType { + eula, + termsOfService, + privacyNotice, + kycDueDiligencePolicy; + + String get title { + switch (this) { + case LegalDocumentType.eula: + return 'End User License Agreement (EULA)'; + case LegalDocumentType.termsOfService: + return 'Terms of Service'; + case LegalDocumentType.privacyNotice: + return 'Privacy Notice'; + case LegalDocumentType.kycDueDiligencePolicy: + return 'KYC and Due Diligence Policy'; + } + } + + String get assetPath { + switch (this) { + case LegalDocumentType.eula: + return 'assets/legal/eula.md'; + case LegalDocumentType.termsOfService: + return 'assets/legal/terms-of-service.md'; + case LegalDocumentType.privacyNotice: + return 'assets/legal/privacy-notice.md'; + case LegalDocumentType.kycDueDiligencePolicy: + return 'assets/legal/kyc-due-diligence-policy.md'; + } + } + + String get githubPath { + switch (this) { + case LegalDocumentType.eula: + return 'assets/legal/eula.md'; + case LegalDocumentType.termsOfService: + return 'assets/legal/terms-of-service.md'; + case LegalDocumentType.privacyNotice: + return 'assets/legal/privacy-notice.md'; + case LegalDocumentType.kycDueDiligencePolicy: + return 'assets/legal/kyc-due-diligence-policy.md'; + } + } + + String get cacheKey { + switch (this) { + case LegalDocumentType.eula: + return 'legal_document_eula'; + case LegalDocumentType.termsOfService: + return 'legal_document_terms_of_service'; + case LegalDocumentType.privacyNotice: + return 'legal_document_privacy_notice'; + case LegalDocumentType.kycDueDiligencePolicy: + return 'legal_document_kyc_due_diligence_policy'; + } + } +} + +enum LegalDocumentSource { bundledAsset, cachedRemote, remote } + +class LegalDocumentContent { + const LegalDocumentContent({ + required this.markdown, + required this.source, + this.sha, + this.fetchedAt, + }); + + final String markdown; + final LegalDocumentSource source; + final String? sha; + final DateTime? fetchedAt; +} diff --git a/lib/services/legal_documents/legal_documents_repository.dart b/lib/services/legal_documents/legal_documents_repository.dart new file mode 100644 index 0000000000..58c4ebe8ab --- /dev/null +++ b/lib/services/legal_documents/legal_documents_repository.dart @@ -0,0 +1,158 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:web_dex/services/legal_documents/legal_document.dart'; +import 'package:web_dex/services/storage/base_storage.dart'; +import 'package:web_dex/services/storage/get_storage.dart'; + +class LegalDocumentsRepository { + LegalDocumentsRepository({ + BaseStorage? storage, + AssetBundle? assetBundle, + http.Client? httpClient, + }) : _storage = storage ?? getStorage(), + _assetBundle = assetBundle ?? rootBundle, + _httpClient = httpClient ?? http.Client(); + + static const String _githubOwner = 'GLEECBTC'; + static const String _githubRepo = 'gleec-wallet'; + static const String _githubBranch = 'main'; + + final BaseStorage _storage; + final AssetBundle _assetBundle; + final http.Client _httpClient; + final Logger _log = Logger('LegalDocumentsRepository'); + + Future loadPreferredContent( + LegalDocumentType document, + ) async { + final cached = await _readCachedContent(document); + if (cached != null) { + return cached; + } + + final bundled = await _assetBundle.loadString(document.assetPath); + return LegalDocumentContent( + markdown: bundled, + source: LegalDocumentSource.bundledAsset, + ); + } + + Future refreshFromRemote( + LegalDocumentType document, + ) async { + final cached = await _readCachedContent(document); + final uri = Uri.https( + 'api.github.com', + '/repos/$_githubOwner/$_githubRepo/contents/${document.githubPath}', + {'ref': _githubBranch}, + ); + + try { + final response = await _httpClient + .get( + uri, + headers: const { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'GleecWallet', + 'X-GitHub-Api-Version': '2022-11-28', + }, + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + _log.warning( + 'Failed to refresh ${document.cacheKey}: ' + 'GitHub returned ${response.statusCode}', + ); + return null; + } + + final payload = jsonDecode(response.body) as Map; + final encodedContent = payload['content'] as String?; + final sha = payload['sha'] as String?; + if (encodedContent == null || encodedContent.trim().isEmpty) { + _log.warning( + 'GitHub response missing content for ${document.cacheKey}', + ); + return null; + } + + final markdown = utf8.decode( + base64Decode(encodedContent.replaceAll('\n', '')), + ); + if (markdown.trim().isEmpty) { + _log.warning('Decoded content empty for ${document.cacheKey}'); + return null; + } + + final fetchedAt = DateTime.now(); + final hasChanged = + cached?.markdown != markdown || (sha != null && cached?.sha != sha); + + if (hasChanged) { + await _storage.write(document.cacheKey, { + 'markdown': markdown, + 'sha': sha, + 'fetchedAt': fetchedAt.toIso8601String(), + }); + } + + if (!hasChanged) { + return null; + } + + return LegalDocumentContent( + markdown: markdown, + source: LegalDocumentSource.remote, + sha: sha, + fetchedAt: fetchedAt, + ); + } on TimeoutException catch (error) { + _log.warning('Timed out refreshing ${document.cacheKey}: $error'); + return null; + } on FormatException catch (error) { + _log.warning('Invalid GitHub payload for ${document.cacheKey}: $error'); + return null; + } catch (error, stackTrace) { + _log.warning( + 'Unexpected error refreshing ${document.cacheKey}', + error, + stackTrace, + ); + return null; + } + } + + Future _readCachedContent( + LegalDocumentType document, + ) async { + final rawValue = await _storage.read(document.cacheKey); + if (rawValue is! Map) { + return null; + } + + final markdown = rawValue['markdown']; + if (markdown is! String || markdown.trim().isEmpty) { + return null; + } + + final sha = rawValue['sha']; + final fetchedAtRaw = rawValue['fetchedAt']; + return LegalDocumentContent( + markdown: markdown, + source: LegalDocumentSource.cachedRemote, + sha: sha is String ? sha : null, + fetchedAt: fetchedAtRaw is String + ? DateTime.tryParse(fetchedAtRaw) + : null, + ); + } + + void dispose() { + _httpClient.close(); + } +} diff --git a/lib/services/logger/log_message.dart b/lib/services/logger/log_message.dart index e0e8e33fe3..f3a985d669 100644 --- a/lib/services/logger/log_message.dart +++ b/lib/services/logger/log_message.dart @@ -55,7 +55,7 @@ class LogMessage { platform: json['platform'] as String, osLanguage: json['os_language'] as String, screenSize: json['screen_size'] as String, - timestamp: json['timestamp'] as int, + timestamp: (json['timestamp'] as num).toInt(), message: json['message'] as String, path: json['path'] as String?, date: json['date'] as String, diff --git a/lib/services/platform_info/platform_info.dart b/lib/services/platform_info/platform_info.dart index ca50872dce..2d45179201 100644 --- a/lib/services/platform_info/platform_info.dart +++ b/lib/services/platform_info/platform_info.dart @@ -1,6 +1,6 @@ // ignore: always_use_package_imports import 'stub.dart' - if (dart.library.html) 'web_platform_info.dart' + if (dart.library.js_interop) 'web_platform_info.dart' if (dart.library.io) 'native_platform_info.dart'; enum PlatformType { diff --git a/lib/services/platform_info/web_platform_info.dart b/lib/services/platform_info/web_platform_info.dart index b80b6b1ee9..7249cd4464 100644 --- a/lib/services/platform_info/web_platform_info.dart +++ b/lib/services/platform_info/web_platform_info.dart @@ -1,4 +1,4 @@ -import 'package:web/web.dart' show window; +import 'package:web/web.dart' as web; import 'package:web_dex/services/platform_info/platform_info.dart'; import 'package:web_dex/shared/utils/browser_helpers.dart'; @@ -10,9 +10,7 @@ class WebPlatformInfo extends PlatformInfo with MemoizedPlatformInfoMixin { BrowserInfo get browserInfo => _browserInfo ??= BrowserInfoParser.get(); @override - // Exclude for mav compilation because it shows string is nullable - // ignore: unnecessary_non_null_assertion - String computeOsLanguage() => window.navigator.language!; + String computeOsLanguage() => web.window.navigator.language; @override String computePlatform() => diff --git a/lib/services/platform_web_api/platform_web_api.dart b/lib/services/platform_web_api/platform_web_api.dart new file mode 100644 index 0000000000..eeff26c262 --- /dev/null +++ b/lib/services/platform_web_api/platform_web_api.dart @@ -0,0 +1,18 @@ +import 'dart:async'; + +import 'platform_web_api_implementation.dart' + if (dart.library.js_interop) 'platform_web_api_web.dart' + if (dart.library.io) 'platform_web_api_stub.dart'; + +/// Abstract interface for browser-only APIs used by Flutter web runtime code. +abstract class PlatformWebApi { + void setElementDisplay(String elementId, String display); + + void addElementClass(String elementId, String className); + + void removeElement(String elementId); + + StreamSubscription onPopState(void Function() callback); + + factory PlatformWebApi() => createPlatformWebApi(); +} diff --git a/lib/services/platform_web_api/platform_web_api_implementation.dart b/lib/services/platform_web_api/platform_web_api_implementation.dart new file mode 100644 index 0000000000..2ca8d44fff --- /dev/null +++ b/lib/services/platform_web_api/platform_web_api_implementation.dart @@ -0,0 +1,4 @@ +import 'platform_web_api.dart'; +import 'platform_web_api_stub.dart'; + +PlatformWebApi createPlatformWebApi() => PlatformWebApiStub(); diff --git a/lib/services/platform_web_api/platform_web_api_stub.dart b/lib/services/platform_web_api/platform_web_api_stub.dart new file mode 100644 index 0000000000..914c2e1267 --- /dev/null +++ b/lib/services/platform_web_api/platform_web_api_stub.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +import 'platform_web_api.dart'; + +class PlatformWebApiStub implements PlatformWebApi { + @override + void addElementClass(String elementId, String className) {} + + @override + StreamSubscription onPopState(void Function() callback) { + return Stream.empty().listen((_) {}); + } + + @override + void removeElement(String elementId) {} + + @override + void setElementDisplay(String elementId, String display) {} +} + +PlatformWebApi createPlatformWebApi() => PlatformWebApiStub(); diff --git a/lib/services/platform_web_api/platform_web_api_web.dart b/lib/services/platform_web_api/platform_web_api_web.dart new file mode 100644 index 0000000000..25e288c547 --- /dev/null +++ b/lib/services/platform_web_api/platform_web_api_web.dart @@ -0,0 +1,40 @@ +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:web/web.dart' as web; + +import 'platform_web_api.dart'; + +@JS() +extension type _StyledElement._(web.Element _) implements web.Element { + external web.CSSStyleDeclaration get style; +} + +class PlatformWebApiWeb implements PlatformWebApi { + @override + void setElementDisplay(String elementId, String display) { + final element = web.document.getElementById(elementId); + if (element != null) { + _StyledElement._(element).style.display = display; + } + } + + @override + void addElementClass(String elementId, String className) { + final element = web.document.getElementById(elementId); + element?.classList.add(className); + } + + @override + void removeElement(String elementId) { + final element = web.document.getElementById(elementId); + element?.remove(); + } + + @override + StreamSubscription onPopState(void Function() callback) { + return web.window.onPopState.listen((_) => callback()); + } +} + +PlatformWebApi createPlatformWebApi() => PlatformWebApiWeb(); diff --git a/lib/shared/constants.dart b/lib/shared/constants.dart index 3250fea285..ef1d298c3b 100644 --- a/lib/shared/constants.dart +++ b/lib/shared/constants.dart @@ -19,6 +19,7 @@ const String storedSettingsKeyV2 = 'komodo_wallet_settings_v2'; const String storedAnalyticsSettingsKey = 'analytics_settings'; const String storedMarketMakerSettingsKey = 'market_maker_settings'; const String lastLoggedInWalletKey = 'last_logged_in_wallet'; +const String hdWalletModePreferenceKey = 'wallet_hd_mode_preference'; // anchor: protocols support const String ercTxHistoryUrl = 'https://etherscan.gleec.com/api'; @@ -32,6 +33,10 @@ const int contactDetailsMaxLength = 100; // Maximum allowed length for passwords across the app // TODO: Mirror this limit in the SDK validation and any backend API constraints const int passwordMaxLength = 128; +const String maskedBalanceText = '****'; + +/// Shown when balance or fiat value is unavailable (e.g. still loading). +const String kBalancePlaceholder = '--'; final RegExp discordUsernameRegex = RegExp(r'^[a-zA-Z0-9._]{2,32}$'); final RegExp telegramUsernameRegex = RegExp(r'^[a-zA-Z0-9_]{5,32}$'); final RegExp matrixIdRegex = RegExp( diff --git a/lib/shared/ui/borderless_search_field.dart b/lib/shared/ui/borderless_search_field.dart index 14a5ee5c30..d80464bf9a 100644 --- a/lib/shared/ui/borderless_search_field.dart +++ b/lib/shared/ui/borderless_search_field.dart @@ -3,11 +3,17 @@ import 'package:flutter/material.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; class BorderLessSearchField extends StatelessWidget { - const BorderLessSearchField( - {Key? key, required this.onChanged, this.height = 44}) - : super(key: key); - final Function(String) onChanged; + const BorderLessSearchField({ + super.key, + required this.onChanged, + this.height = 44, + this.controller, + this.focusNode, + }); + final ValueChanged onChanged; final double height; + final TextEditingController? controller; + final FocusNode? focusNode; @override Widget build(BuildContext context) { @@ -17,18 +23,22 @@ class BorderLessSearchField extends StatelessWidget { height: height, child: TextField( key: const Key('search-field'), + controller: controller, + focusNode: focusNode, onChanged: onChanged, autofocus: true, decoration: InputDecoration( - hintText: LocaleKeys.search.tr(), - border: OutlineInputBorder( - borderSide: BorderSide.none, - borderRadius: BorderRadius.circular(height * 0.5)), - prefixIcon: Icon( - Icons.search, - size: height * 0.6, - color: Theme.of(context).textTheme.bodyMedium?.color, - )), + hintText: LocaleKeys.search.tr(), + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(height * 0.5), + ), + prefixIcon: Icon( + Icons.search, + size: height * 0.6, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ), style: themeData.textTheme.bodyMedium?.copyWith(fontSize: 14), ), ); diff --git a/lib/shared/ui/ui_tab_bar/ui_tab_bar.dart b/lib/shared/ui/ui_tab_bar/ui_tab_bar.dart index ec67355792..7101242065 100644 --- a/lib/shared/ui/ui_tab_bar/ui_tab_bar.dart +++ b/lib/shared/ui/ui_tab_bar/ui_tab_bar.dart @@ -6,11 +6,8 @@ import 'package:web_dex/shared/ui/gradient_border.dart'; import 'package:web_dex/shared/ui/ui_tab_bar/ui_tab.dart'; class UiTabBar extends StatefulWidget { - const UiTabBar({ - Key? key, - required this.currentTabIndex, - required this.tabs, - }) : super(key: key); + const UiTabBar({Key? key, required this.currentTabIndex, required this.tabs}) + : super(key: key); final int currentTabIndex; final List tabs; @@ -25,6 +22,12 @@ class _UiTabBarState extends State { @override Widget build(BuildContext context) { + final bool isMobileLayout = isMobile; + final bool isScrollable = isMobileLayout; + final tabs = _buildTabs( + useFlexible: !isScrollable, + isMobileLayout: isMobileLayout, + ); return GradientBorder( borderRadius: const BorderRadius.all(Radius.circular(36)), innerColor: dexPageColors.frontPlate, @@ -33,27 +36,45 @@ class _UiTabBarState extends State { constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), padding: const EdgeInsets.all(2), child: SizedBox( - height: 36, - child: FocusTraversalGroup( - child: Row(children: _buildTabs()), - )), + height: 36, + child: FocusTraversalGroup( + child: isScrollable + ? SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(mainAxisSize: MainAxisSize.min, children: tabs), + ) + : Row(children: tabs), + ), + ), ), ); } - List _buildTabs() { + List _buildTabs({ + required bool useFlexible, + required bool isMobileLayout, + }) { final List children = []; for (int i = 0; i < widget.tabs.length; i++) { - children.add(Flexible(child: widget.tabs[i])); + Widget tab = widget.tabs[i]; + if (isMobileLayout) { + tab = Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: tab, + ); + } + children.add(useFlexible ? Flexible(child: tab) : tab); // We need a way to fit all tabs // in mobile screens with limited width if (_isLastNotHiddenTabMobile(i)) { - children.add(Padding( - padding: const EdgeInsets.only(left: 1.0), - child: _buildMobileDropdown(), - )); + children.add( + Padding( + padding: EdgeInsets.only(left: isMobile ? 6.0 : 1.0), + child: _buildMobileDropdown(), + ), + ); break; } @@ -69,7 +90,7 @@ class _UiTabBarState extends State { } Widget _buildMobileDropdown() { - final bool isSelected = [3].contains(widget.currentTabIndex); + final bool isSelected = widget.currentTabIndex >= _tabsOnMobile; return UiDropdown( borderRadius: BorderRadius.circular(50), switcher: Container( @@ -77,9 +98,10 @@ class _UiTabBarState extends State { width: 28, height: 28, decoration: BoxDecoration( - color: isSelected ? Theme.of(context).colorScheme.primary : null, - shape: BoxShape.circle, - border: Border.all(color: const Color.fromRGBO(158, 213, 244, 1))), + color: isSelected ? Theme.of(context).colorScheme.primary : null, + shape: BoxShape.circle, + border: Border.all(color: const Color.fromRGBO(158, 213, 244, 1)), + ), child: Center( child: Icon( Icons.more_horiz, @@ -94,9 +116,10 @@ class _UiTabBarState extends State { borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow( - offset: const Offset(0, 1), - blurRadius: 8, - color: theme.custom.tabBarShadowColor) + offset: const Offset(0, 1), + blurRadius: 8, + color: theme.custom.tabBarShadowColor, + ), ], ), child: DecoratedBox( @@ -133,10 +156,7 @@ class _UiTabBarState extends State { padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 9), child: Text( tab.text, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), ), ), ); @@ -147,14 +167,12 @@ class _UiTabBarState extends State { _switcherKey.currentContext?.findRenderObject() as RenderBox?; final Offset? position = box?.localToGlobal(Offset.zero); if (box != null && position != null) { - WidgetsBinding.instance.handlePointerEvent(PointerDownEvent( - pointer: 0, - position: position, - )); - WidgetsBinding.instance.handlePointerEvent(PointerUpEvent( - pointer: 0, - position: position, - )); + WidgetsBinding.instance.handlePointerEvent( + PointerDownEvent(pointer: 0, position: position), + ); + WidgetsBinding.instance.handlePointerEvent( + PointerUpEvent(pointer: 0, position: position), + ); } } } diff --git a/lib/shared/utils/balance_utils.dart b/lib/shared/utils/balance_utils.dart new file mode 100644 index 0000000000..93fc8b23e7 --- /dev/null +++ b/lib/shared/utils/balance_utils.dart @@ -0,0 +1,40 @@ +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; +import 'package:web_dex/model/coin.dart'; + +/// Aggregates USD wallet value for display in wallet chrome (top bar, overview). +/// +/// Uses [KomodoDefiSdk.balances.lastKnown] for spendable amounts and +/// [CoinsState.getPriceForAsset] for USD prices. Those prices come from the CEX +/// feed cached in [CoinsState.prices] (updated via [CoinsBloc] polling), not from +/// [KomodoDefiSdk.marketData.priceIfKnown]. Sorting, portfolio growth, and other +/// features may still use SDK pricing; sources can diverge by design until a +/// single SDK pricing path is adopted. +double? computeWalletTotalUsd({ + required Iterable coins, + required CoinsState coinsState, + required KomodoDefiSdk sdk, +}) { + var hasAnyUsdBalance = false; + var total = 0.0; + + for (final coin in coins) { + final balance = sdk.balances.lastKnown(coin.id)?.spendable.toDouble(); + final price = coinsState.getPriceForAsset(coin.id)?.price?.toDouble(); + if (balance == null || price == null) { + continue; + } + hasAnyUsdBalance = true; + total += balance * price; + } + + if (!hasAnyUsdBalance) { + return null; + } + + if (total > 0.01) { + return total; + } + + return total != 0 ? 0.01 : 0; +} diff --git a/lib/shared/utils/hd_wallet_mode_preference.dart b/lib/shared/utils/hd_wallet_mode_preference.dart new file mode 100644 index 0000000000..b472adea3f --- /dev/null +++ b/lib/shared/utils/hd_wallet_mode_preference.dart @@ -0,0 +1,38 @@ +import 'package:web_dex/services/storage/get_storage.dart'; +import 'package:web_dex/shared/constants.dart'; + +String _walletHdWalletModePreferenceKey(String walletId) { + return '$hdWalletModePreferenceKey:$walletId'; +} + +bool? _parseStoredPreference(dynamic value) { + if (value is bool) { + return value; + } + if (value is String) { + final normalized = value.toLowerCase(); + if (normalized == 'true') return true; + if (normalized == 'false') return false; + } + return null; +} + +Future readHdWalletModePreference(String walletId) async { + final storage = getStorage(); + final walletScopedValue = await storage.read( + _walletHdWalletModePreferenceKey(walletId), + ); + final parsedWalletScopedValue = _parseStoredPreference(walletScopedValue); + if (parsedWalletScopedValue != null) { + return parsedWalletScopedValue; + } + + // Fall back to the legacy global key so existing users keep their last + // selection until this wallet writes its own scoped preference. + final legacyValue = await storage.read(hdWalletModePreferenceKey); + return _parseStoredPreference(legacyValue); +} + +Future storeHdWalletModePreference(String walletId, bool value) async { + await getStorage().write(_walletHdWalletModePreferenceKey(walletId), value); +} diff --git a/lib/shared/utils/kdf_error_display.dart b/lib/shared/utils/kdf_error_display.dart new file mode 100644 index 0000000000..1325628ee2 --- /dev/null +++ b/lib/shared/utils/kdf_error_display.dart @@ -0,0 +1,133 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; +// ignore: implementation_imports -- not exported from komodo_defi_sdk public API +import 'package:komodo_defi_sdk/src/activation/activation_exceptions.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +/// Extension on [MmRpcException] providing localized user-friendly messages. +/// +/// This extension integrates the SDK's error message mapping with the app's +/// localization system (easy_localization). +/// +/// ## Example +/// +/// ```dart +/// try { +/// await withdraw(...); +/// } on MmRpcException catch (e) { +/// // Get localized message with automatic fallback +/// showErrorDialog(e.localizedMessage); +/// } +/// ``` +extension KdfErrorLocalizedMessage on MmRpcException { + /// Returns the localized user-friendly message for this exception. + /// + /// Resolution order: + /// 1. If a locale key mapping exists and has a translation, returns the + /// translated message + /// 2. If a locale key mapping exists but no translation, returns the + /// English fallback message + /// 3. If no mapping exists, returns the technical error message + /// 4. As a last resort, returns a generic error message + String get localizedMessage { + final msg = userMessage; + if (msg == null) { + // No mapping - use technical message or default + return message ?? KdfErrorMessages.defaultError.fallbackMessage; + } + + // Try to get translation + final translated = msg.localeKey.tr(); + + // If translation equals the key, it wasn't found - use fallback + if (translated == msg.localeKey) { + return msg.fallbackMessage; + } + + return translated; + } +} + +/// Extension on [GeneralErrorResponse] providing localized user-friendly +/// messages. +/// +/// This is useful when catching the fallback error type before it's converted +/// to a typed exception. +extension GeneralErrorLocalizedMessage on GeneralErrorResponse { + /// Returns the localized user-friendly message for this error. + /// + /// First attempts to look up a message based on [errorType], then falls + /// back to the raw error message. + String get localizedMessage { + final msg = KdfErrorMessages.forErrorType(errorType); + if (msg == null) { + return error ?? KdfErrorMessages.defaultError.fallbackMessage; + } + + final translated = msg.localeKey.tr(); + if (translated == msg.localeKey) { + return msg.fallbackMessage; + } + + return translated; + } +} + +/// Resolves [error] to a single user-facing string using the same mapping as +/// [KdfErrorLocalizedMessage] / [GeneralErrorLocalizedMessage] where applicable, +/// plus [SdkError] locale keys and a few common wrapper types. +String formatKdfUserFacingError(Object error) { + if (error is MmRpcException) { + return error.localizedMessage; + } + if (error is GeneralErrorResponse) { + return error.localizedMessage; + } + if (error is SdkError) { + final localized = error.messageKey.tr(args: error.messageArgs); + return localized == error.messageKey ? error.fallbackMessage : localized; + } + if (error is WithdrawalException) { + return error.message; + } + if (error is ActivationFailedException) { + final original = error.originalError; + if (original != null) { + return formatKdfUserFacingError(original); + } + return error.message; + } + + final raw = error.toString().trim(); + if (raw.isEmpty) { + return LocaleKeys.somethingWrong.tr(); + } + + const exceptionPrefix = 'Exception: '; + if (raw.startsWith(exceptionPrefix)) { + final message = raw.substring(exceptionPrefix.length).trim(); + if (message.isNotEmpty) { + return message; + } + } + + return raw; +} + +/// Technical detail string for expandable error UI (mirrors withdraw-form logic). +String extractKdfTechnicalDetails(Object error) { + if (error is SdkError) { + return error.fallbackMessage; + } + if (error is MmRpcException) { + return error.message ?? error.toString(); + } + if (error is GeneralErrorResponse) { + return error.error ?? error.toString(); + } + if (error is WithdrawalException) { + return error.message; + } + return error.toString(); +} diff --git a/lib/shared/utils/swap_export.dart b/lib/shared/utils/swap_export.dart new file mode 100644 index 0000000000..1deee92332 --- /dev/null +++ b/lib/shared/utils/swap_export.dart @@ -0,0 +1,18 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_swap_status/my_swap_status_req.dart'; +import 'package:web_dex/services/file_loader/file_loader.dart'; + +Future exportSwapData(BuildContext context, String uuid) async { + final mm2Api = RepositoryProvider.of(context); + final response = await mm2Api.getSwapStatus(MySwapStatusReq(uuid: uuid)); + final jsonStr = jsonEncode(response); + await FileLoader.fromPlatform().save( + fileName: 'swap_$uuid.json', + data: jsonStr, + type: LoadFileType.text, + ); +} diff --git a/lib/shared/utils/utils.dart b/lib/shared/utils/utils.dart index 0044f665ec..0edf13b274 100644 --- a/lib/shared/utils/utils.dart +++ b/lib/shared/utils/utils.dart @@ -197,15 +197,21 @@ String getTxExplorerUrl(Coin coin, String txHash) { ? txHash.toUpperCase() : txHash; - return coin.need0xPrefixForTxHash && !hash.startsWith('0x') - ? '$explorerUrl${explorerTxUrl}0x$hash' - : '$explorerUrl$explorerTxUrl$hash'; + final normalizedHash = coin.need0xPrefixForTxHash && !hash.startsWith('0x') + ? '0x$hash' + : hash; + + if (explorerTxUrl.isEmpty) { + return '$explorerUrl$normalizedHash'; + } + + return '$explorerUrl$explorerTxUrl$normalizedHash'; } String getAddressExplorerUrl(Coin coin, String address) { final String explorerUrl = coin.explorerUrl; final String explorerAddressUrl = coin.explorerAddressUrl; - if (explorerUrl.isEmpty) return ''; + if (explorerUrl.isEmpty || explorerAddressUrl.isEmpty) return ''; return '$explorerUrl$explorerAddressUrl$address'; } @@ -332,7 +338,9 @@ String abbr2Ticker(String abbr) { if (!abbr.contains('-') && !abbr.contains('_')) return abbr; const List filteredSuffixes = [ + 'TRC20', 'ERC20', + 'GRC20', 'BEP20', 'QRC20', 'FTM20', @@ -389,6 +397,9 @@ final Map _abbr2TickerCache = {}; Color getProtocolColor(CoinType type) { switch (type) { + case CoinType.trx: + case CoinType.trc20: + return const Color.fromRGBO(236, 4, 38, 1); case CoinType.zhtlc: case CoinType.utxo: return const Color.fromRGBO(233, 152, 60, 1); @@ -397,6 +408,8 @@ Color getProtocolColor(CoinType type) { return const Color(0xFF29F06F); case CoinType.erc20: return const Color.fromRGBO(108, 147, 237, 1); + case CoinType.grc20: + return const Color(0xFF8C41FF); case CoinType.smartChain: return const Color.fromRGBO(32, 22, 49, 1); case CoinType.bep20: @@ -442,12 +455,15 @@ bool hasTxHistorySupport(Coin coin) { case CoinType.ubiq: case CoinType.hrc20: return false; + case CoinType.trx: + case CoinType.trc20: case CoinType.krc20: case CoinType.tendermint: case CoinType.tendermintToken: case CoinType.utxo: case CoinType.sia: case CoinType.erc20: + case CoinType.grc20: case CoinType.smartChain: case CoinType.bep20: case CoinType.qrc20: @@ -469,6 +485,13 @@ String getNativeExplorerUrlByCoin(Coin coin, String? address) { final bool hasSupport = hasTxHistorySupport(coin); final coinAddress = address ?? coin.address; assert(!hasSupport); + if (coinAddress == null || coinAddress.isEmpty || coin.explorerUrl.isEmpty) { + return ''; + } + + if (coin.explorerAddressUrl.isNotEmpty) { + return getAddressExplorerUrl(coin, coinAddress); + } switch (coin.type) { case CoinType.sbch: @@ -483,6 +506,7 @@ String getNativeExplorerUrlByCoin(Coin coin, String? address) { case CoinType.utxo: case CoinType.smartChain: case CoinType.erc20: + case CoinType.grc20: case CoinType.bep20: case CoinType.qrc20: case CoinType.ftm20: @@ -498,6 +522,8 @@ String getNativeExplorerUrlByCoin(Coin coin, String? address) { case CoinType.krc20: case CoinType.slp: case CoinType.sia: + case CoinType.trx: + case CoinType.trc20: return '${coin.explorerUrl}address/$coinAddress'; } } @@ -530,12 +556,18 @@ String? assertString(dynamic value) { int? assertInt(dynamic value) { if (value == null) return null; - switch (value.runtimeType) { - case String: - return int.parse(value as String); - default: - return value as int?; + if (value is int) return value; + if (value is num) return value.toInt(); + + if (value is String) { + final intValue = int.tryParse(value); + if (intValue != null) return intValue; + + final numValue = num.tryParse(value); + if (numValue != null) return numValue.toInt(); } + + return null; } double assertDouble(dynamic value) { diff --git a/lib/shared/utils/validators.dart b/lib/shared/utils/validators.dart index 29357ffd98..660064cede 100644 --- a/lib/shared/utils/validators.dart +++ b/lib/shared/utils/validators.dart @@ -1,17 +1,19 @@ import 'package:characters/characters.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/constants.dart'; /// Enum representing different types of password validation errors enum PasswordValidationError { containsPassword, tooShort, + tooLong, missingDigit, missingLowercase, missingUppercase, missingSpecialCharacter, consecutiveCharacters, - none + none, } /// Converts a password validation error to a localized error message @@ -21,6 +23,8 @@ String? passwordErrorMessage(PasswordValidationError error) { return LocaleKeys.passwordContainsTheWordPassword.tr(); case PasswordValidationError.tooShort: return LocaleKeys.passwordTooShort.tr(); + case PasswordValidationError.tooLong: + return LocaleKeys.passwordTooLong.tr(); case PasswordValidationError.missingDigit: return LocaleKeys.passwordMissingDigit.tr(); case PasswordValidationError.missingLowercase: @@ -43,8 +47,9 @@ String? validateConfirmPassword(String password, String confirmPassword) { } String? validatePasswordLegacy(String password, String errorText) { - final RegExp exp = - RegExp(r'^(?:(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9\s])).{12,}$'); + final RegExp exp = RegExp( + r'^(?:(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9\s])).{12,}$', + ); return password.isEmpty || !password.contains(exp) ? errorText : null; } @@ -74,9 +79,13 @@ PasswordValidationError checkPasswordRequirements(String password) { return PasswordValidationError.tooShort; } - if (password - .toLowerCase() - .contains(RegExp('password', caseSensitive: false, unicode: true))) { + if (password.characters.length > passwordMaxLength) { + return PasswordValidationError.tooLong; + } + + if (password.toLowerCase().contains( + RegExp('password', caseSensitive: false, unicode: true), + )) { return PasswordValidationError.containsPassword; } diff --git a/lib/shared/utils/window/window.dart b/lib/shared/utils/window/window.dart index 8276f49603..cb4084a27e 100644 --- a/lib/shared/utils/window/window.dart +++ b/lib/shared/utils/window/window.dart @@ -1,3 +1,3 @@ export 'window_stub.dart' if (dart.library.io) './window_native.dart' - if (dart.library.html) './window_web.dart'; + if (dart.library.js_interop) './window_web.dart'; diff --git a/lib/shared/widgets/coin_balance.dart b/lib/shared/widgets/coin_balance.dart index baa0952374..282ab448ab 100644 --- a/lib/shared/widgets/coin_balance.dart +++ b/lib/shared/widgets/coin_balance.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/constants.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/widgets/coin_fiat_balance.dart'; @@ -16,41 +20,55 @@ class CoinBalance extends StatelessWidget { Widget build(BuildContext context) { final baseFont = Theme.of(context).textTheme.bodySmall; final balanceStyle = baseFont?.copyWith(fontWeight: FontWeight.w500); + final hideBalances = context.select( + (SettingsBloc bloc) => bloc.state.hideBalances, + ); - final balance = - context.sdk.balances.lastKnown(coin.id)?.spendable.toDouble() ?? 0.0; + final balanceStream = context.sdk.balances.watchBalance(coin.id); - final children = [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: AutoScrollText( - key: Key('coin-balance-asset-${coin.abbr.toLowerCase()}'), - text: doubleToString(balance), - style: balanceStyle, - textAlign: TextAlign.right, - ), - ), - Text(' ${Coin.normalizeAbbr(coin.abbr)}', style: balanceStyle), - ], - ), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 100), - child: CoinFiatBalance(coin, isAutoScrollEnabled: true), - ), - ]; + return StreamBuilder( + stream: balanceStream, + builder: (context, snapshot) { + final balance = snapshot.data?.spendable.toDouble(); + final balanceText = hideBalances + ? maskedBalanceText + : balance == null + ? '--' + : doubleToString(balance); - return isVertical - ? Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: children, - ) - : Row( + final children = [ + Row( mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - children: children, - ); + children: [ + Flexible( + child: AutoScrollText( + key: Key('coin-balance-asset-${coin.abbr.toLowerCase()}'), + text: balanceText, + style: balanceStyle, + textAlign: TextAlign.right, + ), + ), + Text(' ${Coin.normalizeAbbr(coin.abbr)}', style: balanceStyle), + ], + ), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 100), + child: CoinFiatBalance(coin, isAutoScrollEnabled: true), + ), + ]; + + return isVertical + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ) + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: children, + ); + }, + ); } } diff --git a/lib/shared/widgets/coin_fiat_balance.dart b/lib/shared/widgets/coin_fiat_balance.dart index 6a966fdedb..deb74033a0 100644 --- a/lib/shared/widgets/coin_fiat_balance.dart +++ b/lib/shared/widgets/coin_fiat_balance.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/constants.dart'; import 'package:web_dex/shared/utils/formatters.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -21,6 +25,9 @@ class CoinFiatBalance extends StatelessWidget { @override Widget build(BuildContext context) { + final hideBalances = context.select( + (SettingsBloc bloc) => bloc.state.hideBalances, + ); final balanceStream = context.sdk.balances.watchBalance(coin.id); final TextStyle mergedStyle = const TextStyle( @@ -28,32 +35,55 @@ class CoinFiatBalance extends StatelessWidget { fontWeight: FontWeight.w500, ).merge(style); - return StreamBuilder( - stream: balanceStream, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox(); - } - - final usdBalance = coin.lastKnownUsdBalance(context.sdk); - if (usdBalance == null) { - return const SizedBox(); - } - - final formattedBalance = formatUsdValue(usdBalance); - final balanceStr = ' ($formattedBalance)'; - - if (isAutoScrollEnabled) { - return AutoScrollText( - text: balanceStr, - style: mergedStyle, - isSelectable: isSelectable, - ); - } - - return isSelectable - ? SelectableText(balanceStr, style: mergedStyle) - : Text(balanceStr, style: mergedStyle); + if (hideBalances) { + final balanceStr = ' ($maskedBalanceText)'; + return isAutoScrollEnabled + ? AutoScrollText( + text: balanceStr, + style: mergedStyle, + isSelectable: isSelectable, + ) + : isSelectable + ? SelectableText(balanceStr, style: mergedStyle) + : Text(balanceStr, style: mergedStyle); + } + + return BlocSelector( + selector: (state) => state.getPriceForAsset(coin.id)?.price?.toDouble(), + builder: (context, price) { + return StreamBuilder( + stream: balanceStream, + builder: (context, snapshot) { + final balance = snapshot.data?.spendable.toDouble(); + if (balance == null || price == null) { + final balanceStr = ' ($kBalancePlaceholder)'; + return isAutoScrollEnabled + ? AutoScrollText( + text: balanceStr, + style: mergedStyle, + isSelectable: isSelectable, + ) + : isSelectable + ? SelectableText(balanceStr, style: mergedStyle) + : Text(balanceStr, style: mergedStyle); + } + + final formattedBalance = formatUsdValue(price * balance); + final balanceStr = ' ($formattedBalance)'; + + if (isAutoScrollEnabled) { + return AutoScrollText( + text: balanceStr, + style: mergedStyle, + isSelectable: isSelectable, + ); + } + + return isSelectable + ? SelectableText(balanceStr, style: mergedStyle) + : Text(balanceStr, style: mergedStyle); + }, + ); }, ); } diff --git a/lib/shared/widgets/coin_select_item_widget.dart b/lib/shared/widgets/coin_select_item_widget.dart index 1871c60f60..b94b6438c3 100644 --- a/lib/shared/widgets/coin_select_item_widget.dart +++ b/lib/shared/widgets/coin_select_item_widget.dart @@ -75,11 +75,7 @@ class CoinSelectItemWidget extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - // Use the text color from textTheme since onSurface is misused as a - // background color in this codebase. - final textColor = - theme.textTheme.bodyMedium?.color ?? - (theme.brightness == Brightness.dark ? Colors.white : Colors.black); + final textColor = theme.colorScheme.onSurface; final baseTextStyle = theme.textTheme.bodyMedium ?? @@ -87,24 +83,31 @@ class CoinSelectItemWidget extends StatelessWidget { return InkWell( onTap: onTap, - child: Row( - children: [ - if (leading != null) - Padding(padding: const EdgeInsets.only(right: 12), child: leading!) - else - Padding( - padding: const EdgeInsets.only(right: 12), - child: AssetLogo.ofTicker(coinId, size: 20), - ), - Expanded( - child: DefaultTextStyle( - style: baseTextStyle.copyWith(color: textColor), - child: title ?? Text(name), - ), + child: IconTheme( + data: theme.iconTheme.copyWith(color: textColor), + child: DefaultTextStyle( + style: baseTextStyle.copyWith(color: textColor), + child: Row( + children: [ + if (leading != null) + Padding( + padding: const EdgeInsets.only(right: 12), + child: leading!, + ) + else + Padding( + padding: const EdgeInsets.only(right: 12), + child: AssetLogo.ofTicker(coinId, size: 20), + ), + Expanded(child: title ?? Text(name)), + if (trailing != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: trailing!, + ), + ], ), - if (trailing != null) - Padding(padding: const EdgeInsets.only(left: 8), child: trailing!), - ], + ), ), ); } diff --git a/lib/shared/widgets/connect_wallet/connect_wallet_button.dart b/lib/shared/widgets/connect_wallet/connect_wallet_button.dart index 45860ae02d..8272269650 100644 --- a/lib/shared/widgets/connect_wallet/connect_wallet_button.dart +++ b/lib/shared/widgets/connect_wallet/connect_wallet_button.dart @@ -9,12 +9,12 @@ import 'package:web_dex/bloc/bridge_form/bridge_event.dart'; import 'package:web_dex/bloc/taker_form/taker_bloc.dart'; import 'package:web_dex/bloc/taker_form/taker_event.dart'; import 'package:web_dex/common/screen.dart'; -import 'package:web_dex/dispatchers/popup_dispatcher.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/views/dex/dex_helpers.dart'; import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; import 'package:web_dex/views/wallets_manager/wallets_manager_wrapper.dart'; +import 'package:web_dex/shared/widgets/app_dialog.dart'; class ConnectWalletButton extends StatefulWidget { const ConnectWalletButton({ @@ -23,8 +23,8 @@ class ConnectWalletButton extends StatefulWidget { this.withText = true, this.withIcon = false, Size? buttonSize, - }) : buttonSize = buttonSize ?? const Size(double.infinity, 40), - super(key: key); + }) : buttonSize = buttonSize ?? const Size(double.infinity, 40), + super(key: key); final Size buttonSize; final bool withIcon; final bool withText; @@ -38,15 +38,6 @@ class _ConnectWalletButtonState extends State { static const String walletIconPath = '$assetsPath/nav_icons/desktop/dark/wallet.svg'; - PopupDispatcher? _popupDispatcher; - - @override - void dispose() { - _popupDispatcher?.close(); - _popupDispatcher = null; - super.dispose(); - } - @override Widget build(BuildContext context) { return widget.withText @@ -60,15 +51,17 @@ class _ConnectWalletButtonState extends State { child: SvgPicture.asset( walletIconPath, colorFilter: ColorFilter.mode( - theme.custom.defaultGradientButtonTextColor, - BlendMode.srcIn), + theme.custom.defaultGradientButtonTextColor, + BlendMode.srcIn, + ), width: 15, height: 15, ), ) : null, - text: LocaleKeys.connectSomething - .tr(args: [LocaleKeys.wallet.tr().toLowerCase()]), + text: LocaleKeys.connectSomething.tr( + args: [LocaleKeys.wallet.tr().toLowerCase()], + ), onPressed: onButtonPressed, ) : ElevatedButton( @@ -85,33 +78,32 @@ class _ConnectWalletButtonState extends State { child: SvgPicture.asset( walletIconPath, colorFilter: ColorFilter.mode( - theme.custom.defaultGradientButtonTextColor, BlendMode.srcIn), + theme.custom.defaultGradientButtonTextColor, + BlendMode.srcIn, + ), width: 20, ), ); } - void onButtonPressed() { - _popupDispatcher = _createPopupDispatcher(); - _popupDispatcher?.show(); - } - - PopupDispatcher _createPopupDispatcher() { + Future onButtonPressed() async { final TakerBloc takerBloc = context.read(); final BridgeBloc bridgeBloc = context.read(); + final BuildContext dialogContext = scaffoldKey.currentContext ?? context; - return PopupDispatcher( - borderColor: theme.custom.specificButtonBorderColor, - barrierColor: isMobile ? Theme.of(context).colorScheme.onSurface : null, + await AppDialog.showWithCallback( + context: dialogContext, + barrierDismissible: false, width: 320, - context: scaffoldKey.currentContext ?? context, - popupContent: WalletsManagerWrapper( + useRootNavigator: true, + childBuilder: (closeDialog) => WalletsManagerWrapper( eventType: widget.eventType, + onCancel: closeDialog, onSuccess: (_) async { takerBloc.add(TakerReInit()); bridgeBloc.add(const BridgeReInit()); - await reInitTradingForms(context); - _popupDispatcher?.close(); + await reInitTradingForms(dialogContext); + closeDialog(); }, ), ); diff --git a/lib/shared/widgets/disclaimer/constants.dart b/lib/shared/widgets/disclaimer/constants.dart deleted file mode 100644 index 650dd14f7f..0000000000 --- a/lib/shared/widgets/disclaimer/constants.dart +++ /dev/null @@ -1,135 +0,0 @@ -const String disclaimerEulaTitle1 = - 'End User License Agreement (EULA) of GLEEC Wallet:\n\n'; - -const String disclaimerTocTitle2 = - 'TERMS and CONDITIONS: (APPLICATION USER AGREEMENT)\n\n'; -const String disclaimerTocTitle3 = - 'TERMS AND CONDITIONS OF USE AND DISCLAIMER\n\n'; -const String disclaimerTocTitle4 = 'GENERAL USE\n\n'; -const String disclaimerTocTitle5 = 'MODIFICATIONS\n\n'; -const String disclaimerTocTitle6 = 'LIMITATIONS ON USE\n\n'; -const String disclaimerTocTitle7 = 'Accounts and membership\n\n'; -const String disclaimerTocTitle8 = 'Backups\n\n'; -const String disclaimerTocTitle9 = 'GENERAL WARNING\n\n'; -const String disclaimerTocTitle10 = 'ACCESS AND SECURITY\n\n'; -const String disclaimerTocTitle11 = 'INTELLECTUAL PROPERTY RIGHTS\n\n'; -const String disclaimerTocTitle12 = 'DISCLAIMER\n\n'; -const String disclaimerTocTitle13 = - 'REPRESENTATIONS AND WARRANTIES, INDEMNIFICATION, AND LIMITATION OF LIABILITY\n\n'; -const String disclaimerTocTitle14 = 'GENERAL RISK FACTORS\n\n'; -const String disclaimerTocTitle15 = 'INDEMNIFICATION\n\n'; -const String disclaimerTocTitle16 = - 'RISK DISCLOSURES RELATING TO THE WALLET\n\n'; -const String disclaimerTocTitle17 = 'NO INVESTMENT ADVICE OR BROKERAGE\n\n'; -const String disclaimerTocTitle18 = 'TERMINATION\n\n'; -const String disclaimerTocTitle19 = 'THIRD PARTY RIGHTS\n\n'; -const String disclaimerTocTitle20 = 'OUR LEGAL OBLIGATIONS\n\n'; - -// EULA structured content -const String disclaimerEulaParagraph1 = - "This End User License Agreement ('EULA') is a legal agreement between you and GLEEC.\n\n"; - -const String disclaimerEulaParagraph2 = - "This EULA agreement governs your acquisition and use of our GLEEC Wallet software ('Software', 'Web Application', 'Application' or 'App') directly from GLEEC or indirectly through a GLEEC authorized entity, reseller or distributor (a 'Distributor').\n\n"; - -const String disclaimerEulaParagraph3 = - "Please read this EULA agreement carefully before completing the installation process and using the GLEEC Wallet software. It provides a license to use the GLEEC Wallet software and contains warranty information and liability disclaimers.\n\n"; - -const String disclaimerEulaParagraph4 = - "If you register for the beta program of the GLEEC Wallet software, this EULA agreement will also govern that trial. By clicking 'accept' or installing and/or using the GLEEC Wallet software, you are confirming your acceptance of the Software and agreeing to become bound by the terms of this EULA agreement.\n\n"; - -const String disclaimerEulaParagraph5 = - "If you are entering into this EULA agreement on behalf of a company or other legal entity, you represent that you have the authority to bind such entity and its affiliates to these terms and conditions. If you do not have such authority or if you do not agree with the terms and conditions of this EULA agreement, do not install or use the Software, and you must not accept this EULA agreement.\n\n"; - -const String disclaimerEulaParagraph6 = - "This EULA agreement shall apply only to the Software supplied by GLEEC herewith regardless of whether other software is referred to or described herein. The terms also apply to any GLEEC updates, supplements, internet based services, and support services for the Software, unless other terms accompany those items on delivery. If so, those terms apply.\n\n"; - -const String disclaimerEulaTitle2 = "License Grant\n\n"; - -const String disclaimerEulaParagraph7 = - "GLEEC hereby grants you a personal, non-transferable, non-exclusive license to use the GLEEC Wallet software on your devices in accordance with the terms of this EULA agreement.\n\n"; - -const String disclaimerEulaParagraph8 = - "You are permitted to load the GLEEC Wallet software (for example a PC, laptop, mobile or tablet) under your control. You are responsible for ensuring your device meets the minimum security and resource requirements of the GLEEC Wallet software.\n\n"; - -const String disclaimerEulaTitle3 = "Restrictions\n\n"; - -const String disclaimerEulaParagraph9 = "You are not permitted to:\n\n"; - -const String disclaimerEulaParagraph10 = - "• Edit, alter, modify, adapt, translate or otherwise change the whole or any part of the Software nor permit the whole or any part of the Software to be combined with or become incorporated in any other software, nor decompile, disassemble or reverse engineer the Software or attempt to do any such things\n\n"; - -const String disclaimerEulaParagraph11 = - "• Reproduce, copy, distribute, resell or otherwise use the Software for any commercial purpose\n\n"; - -const String disclaimerEulaParagraph12 = - "• Use the Software in any way which breaches any applicable local, national or international law\n\n"; - -const String disclaimerEulaParagraph13 = - "• Use the Software for any purpose that GLEEC considers is a breach of this EULA agreement\n\n"; - -const String disclaimerEulaTitle4 = "Intellectual Property and Ownership\n\n"; - -const String disclaimerEulaParagraph14 = - "GLEEC shall at all times retain ownership of the Software as originally downloaded by you and all subsequent downloads of the Software by you. The Software (and the copyright, and other intellectual property rights of whatever nature in the Software, including any modifications made thereto) are and shall remain the property of GLEEC.\n\n"; - -const String disclaimerEulaParagraph15 = - "GLEEC reserves the right to grant licences to use the Software to third parties.\n\n"; - -const String disclaimerEulaTitle5 = "Termination\n\n"; - -const String disclaimerEulaParagraph16 = - "This EULA agreement is effective from the date you first use the Software and shall continue until terminated. You may terminate it at any time upon written notice to GLEEC.\n\n"; - -const String disclaimerEulaParagraph17 = - "It will also terminate immediately if you fail to comply with any term of this EULA agreement. Upon such termination, the licenses granted by this EULA agreement will immediately terminate and you agree to stop all access and use of the Software. The provisions that by their nature continue and survive will survive any termination of this EULA agreement.\n\n"; - -const String disclaimerEulaTitle6 = "Governing Law\n\n"; - -const String disclaimerEulaParagraph18 = - "This EULA agreement, and any dispute arising out of or in connection with this EULA agreement, shall be governed by and construed in accordance with the laws of Vietnam.\n\n"; - -const String disclaimerEulaParagraph19 = - "This document was last updated on December 10th, 2025\n\n"; - -// Legacy EULA paragraph (keeping for backward compatibility if needed) -const String disclaimerEulaLegacyParagraph1 = - "This End User License Agreement ('EULA') is a legal agreement between you and GLEEC.\n\nThis EULA agreement governs your acquisition and use of our GLEEC Wallet software ('Software', 'Web Application', 'Application' or 'App') directly from GLEEC or indirectly through a GLEEC authorized entity, reseller or distributor (a 'Distributor').\n\nPlease read this EULA agreement carefully before completing the installation process and using the GLEEC Wallet software. It provides a license to use the GLEEC Wallet software and contains warranty information and liability disclaimers.\n\nIf you register for the beta program of the GLEEC Wallet software, this EULA agreement will also govern that trial. By clicking 'accept' or installing and/or using the GLEEC Wallet software, you are confirming your acceptance of the Software and agreeing to become bound by the terms of this EULA agreement.\n\nIf you are entering into this EULA agreement on behalf of a company or other legal entity, you represent that you have the authority to bind such entity and its affiliates to these terms and conditions. If you do not have such authority or if you do not agree with the terms and conditions of this EULA agreement, do not install or use the Software, and you must not accept this EULA agreement.\n\nThis EULA agreement shall apply only to the Software supplied by GLEEC herewith regardless of whether other software is referred to or described herein. The terms also apply to any GLEEC updates, supplements, internet based services, and support services for the Software, unless other terms accompany those items on delivery. If so, those terms apply.\n\nLicense Grant\nGLEEC hereby grants you a personal, non-transferable, non-exclusive license to use the GLEEC Wallet software on your devices in accordance with the terms of this EULA agreement.\n\nYou are permitted to load the GLEEC Wallet software (for example a PC, laptop, mobile or tablet) under your control. You are responsible for ensuring your device meets the minimum security and resource requirements of the GLEEC Wallet software.\n\nYou are not permitted to:\nEdit, alter, modify, adapt, translate or otherwise change the whole or any part of the Software nor permit the whole or any part of the Software to be combined with or become incorporated in any other software, nor decompile, disassemble or reverse engineer the Software or attempt to do any such things\nReproduce, copy, distribute, resell or otherwise use the Software for any commercial purpose\nUse the Software in any way which breaches any applicable local, national or international law\nuse the Software for any purpose that GLEEC considers is a breach of this EULA agreement\nIntellectual Property and Ownership\nGLEEC shall at all times retain ownership of the Software as originally downloaded by you and all subsequent downloads of the Software by you. The Software (and the copyright, and other intellectual property rights of whatever nature in the Software, including any modifications made thereto) are and shall remain the property of GLEEC.\n\nGLEEC reserves the right to grant licences to use the Software to third parties.\n\nTermination\nThis EULA agreement is effective from the date you first use the Software and shall continue until terminated. You may terminate it at any time upon written notice to GLEEC.\n\nIt will also terminate immediately if you fail to comply with any term of this EULA agreement. Upon such termination, the licenses granted by this EULA agreement will immediately terminate and you agree to stop all access and use of the Software. The provisions that by their nature continue and survive will survive any termination of this EULA agreement.\n\nGoverning Law\nThis EULA agreement, and any dispute arising out of or in connection with this EULA agreement, shall be governed by and construed in accordance with the laws of Estonia.\n\nThis document was last updated on December 10th, 2025"; - -// Terms of Conditions content -const String disclaimerTocParagraph2 = - 'This disclaimer applies to the contents and services of the app GLEEC Wallet and is valid for all users of the "Application" ("Software", "Web Application", "Application" or "App").\n\nThe Application is owned by GLEEC.\n\nWe reserve the right to amend the following Terms and Conditions (governing the use of the application GLEEC Wallet) at any time without prior notice and at our sole discretion. It is your responsibility to periodically check these Terms and Conditions for any updates to these Terms, which shall come into force once published.\n\nYour continued use of the application shall be deemed as acceptance of the following Terms. \n\nWe are a company incorporated in Estonia and these Terms and Conditions are governed by and subject to the laws of Estonia. \n\nIf You do not agree with these Terms and Conditions, You must not use or access this software.\n\n'; -const String disclaimerTocParagraph3 = - 'By entering into this User (each subject accessing or using the site) Agreement (this writing) You declare that You are an individual over the age of majority (at least 18 or older) and have the capacity to enter into this User Agreement and accept to be legally bound by the terms and conditions of this User Agreement, as incorporated herein and amended from time to time. \n\n'; -const String disclaimerTocParagraph4 = - 'We may change the terms of this User Agreement at any time. Any such changes will take effect when published in the application, or when You use the Services.\n\n\nRead the User Agreement carefully every time You use our Services. Your continued use of the Services shall signify your acceptance to be bound by the current User Agreement. Our failure or delay in enforcing or partially enforcing any provision of this User Agreement shall not be construed as a waiver of any.\n\n'; -const String disclaimerTocParagraph5 = - "You are not allowed to decompile, decode, disassemble, rent, lease, loan, sell, sublicense, or create derivative works from the GLEEC Wallet application or the user content. Nor are You allowed to use any network monitoring or detection software to determine the software architecture, or extract information about usage or individuals' or users' identities. \n\nYou are not allowed to copy, modify, reproduce, republish, distribute, display, or transmit for commercial, non-profit or public purposes all or any portion of the application or the user content without our prior written authorization.\n\n"; -const String disclaimerTocParagraph6 = - 'If you create an account in the Web Application, you are responsible for maintaining the security of your account and you are fully responsible for all activities that occur under the account and any other actions taken in connection with it. We will not be liable for any acts or omissions by you, including any damages of any kind incurred as a result of such acts or omissions. \n\n GLEEC Wallet is a non-custodial wallet implementation and thus GLEEC can not access nor restore your account in case of (data) loss.\n\n'; -const String disclaimerTocParagraph7 = - 'We are not responsible for seed phrases residing in the Web Application. In no event shall we be held liable for any loss of any kind. It is your sole responsibility to maintain appropriate backups of your accounts and their seed phrases.\n\n'; -const String disclaimerTocParagraph8 = - 'You should not act, or refrain from acting solely on the basis of the content of this application. \n\nYour access to this application does not itself create an adviser-client relationship between You and us. \n\nThe content of this application does not constitute a solicitation or inducement to invest in any financial products or services offered by us. \n\nAny advice included in this application has been prepared without taking into account your objectives, financial situation or needs. You should consider our Risk Disclosure Notice before making any decision on whether to acquire the product described in that document.\n\n'; -const String disclaimerTocParagraph9 = - 'We do not guarantee your continuous access to the application or that your access or use will be error-free. \n\nWe will not be liable in the event that the application is unavailable to You for any reason (for example, due to computer downtime ascribable to malfunctions, upgrades, server problems, precautionary or corrective maintenance activities or interruption in telecommunication supplies). \n\n'; -const String disclaimerTocParagraph10 = - 'GLEEC is the owner and/or authorized user of all trademarks, service marks, design marks, patents, copyrights, database rights, and all other intellectual property appearing on or contained within the application, unless otherwise indicated. All information, text, material, graphics, software, and advertisements on the application interface are copyright of GLEEC, its suppliers and licensors, unless otherwise expressly indicated by GLEEC. \n\nExcept as provided in the Terms, use of the application does not grant You any right, title, interest, or license to any such intellectual property You may have access to on the application. \n\nWe own the rights, or have permission to use, the trademarks listed in our application. You are not authorised to use any of those trademarks without our written authorization – doing so would constitute a breach of our or another party’s intellectual property rights. \n\nAlternatively, we might authorise You to use the content in our application if You previously contact us and we agree in writing.\n\n'; -const String disclaimerTocParagraph11 = - "GLEEC cannot guarantee the safety or security of your computer systems. We do not accept liability for any loss or corruption of electronically stored data or any damage to any computer system which occurrs in connection with the use of the application or user content.\n\nGLEEC makes no representation or warranty of any kind, express or implied, as to the operation of the application or the user content. You expressly agree that your use of the application is entirely at your sole risk.\n\nYou agree that the content provided in the application and the user content do not constitute financial product, legal, or taxation advice, and You agree on not representing the user content or the application as such.\n\nTo the extent permitted by current legislation, the application is provided on an “as is, as available” basis.\n\nGLEEC expressly disclaims all responsibility for any loss, injury, claim, liability, or damage, or any indirect, incidental, special, or consequential damages or loss of profits whatsoever resulting from, arising out of or in any way related to: \n(a) any errors in or omissions of the application and/or the user content, including but not limited to technical inaccuracies and typographical errors; \n(b) any third party website, application or content directly or indirectly accessed through links in the application, including but not limited to any errors or omissions; \n(c) the unavailability of the application or any portion of it; \n(d) your use of the application;\n(e) your use of any equipment or software in connection with the application. \n\nAny Services offered in connection with the Platform are provided on an 'as is' basis, without any representation or warranty, whether express, implied or statutory. To the maximum extent permitted by applicable law, we specifically disclaim any implied warranties of title, merchantability, suitability for a particular purpose and/or non-infringement. We do not make any representations or warranties that use of the Platform will be continuous, uninterrupted, timely, or error-free.\n\nWe make no warranty that any Platform will be free from viruses, malware, or other related harmful material and that your ability to access any Platform will be uninterrupted. Any defects or malfunction in the product should be directed to the third party offering the Platform, not to GLEEC. \n\nWe will not be responsible or liable to You for any loss of any kind, from action taken, or taken in reliance on the material or information contained in or through the Platform.\n\nThis is experimental and unfinished software. Use at your own risk. No warranty for any kind of damage. By using this application you agree to this terms and conditions.\n\n"; -const String disclaimerTocParagraph12 = - 'When accessing or using the Services, You agree that You are solely responsible for your conduct while accessing and using our Services. Without limiting the generality of the foregoing, You agree that You will not:\n(a) Use the Services in any manner that could interfere with, disrupt, negatively affect or inhibit other users from fully enjoying the Services, or that could damage, disable, overburden or impair the functioning of our Services in any manner;\n(b) Use the Services to pay for, support or otherwise engage in any illegal activities, including, but not limited to illegal gambling, fraud, money laundering, or terrorist activities;\n(c) Use any robot, spider, crawler, scraper or other automated means or interface not provided by us to access our Services or to extract data;\n(d) Use or attempt to use another user’s Wallet or credentials without authorization;\n(e) Attempt to circumvent any content filtering techniques we employ, or attempt to access any service or area of our Services that You are not authorized to access;\n(f) Introduce to the Services any virus, Trojan, worms, logic bombs or other harmful material;\n(g) Develop any third-party applications that interact with our Services without our prior written consent;\n(h) Provide false, inaccurate, or misleading information; \n(i) Encourage or induce any other person to engage in any of the activities prohibited under this Section.\n\n\n'; -const String disclaimerTocParagraph13 = - 'You agree and understand that there are risks associated with utilizing Services involving Virtual Currencies including, but not limited to, the risk of failure of hardware, software and internet connections, the risk of malicious software introduction, and the risk that third parties may obtain unauthorized access to information stored within your Wallet, including but not limited to your public and private keys. You agree and understand that GLEEC will not be responsible for any communication failures, disruptions, errors, distortions or delays You may experience when using the Services, however caused.\n\nYou accept and acknowledge that there are risks associated with utilizing any virtual currency network, including, but not limited to, the risk of unknown vulnerabilities in or unanticipated changes to the network protocol. You acknowledge and accept that GLEEC has no control over any cryptocurrency network and will not be responsible for any harm occurring as a result of such risks, including, but not limited to, the inability to reverse a transaction, and any losses in connection therewith due to erroneous or fraudulent actions.\n\nThe risk of loss in using Services involving Virtual Currencies may be substantial and losses may occur over a short period of time. In addition, price and liquidity are subject to significant fluctuations that may be unpredictable.\n\nVirtual Currencies are not legal tender and are not backed by any sovereign government. In addition, the legislative and regulatory landscape around Virtual Currencies is constantly changing and may affect your ability to use, transfer, or exchange Virtual Currencies.\n\nCFDs are complex instruments and come with a high risk of losing money rapidly due to leverage. 80.6% of retail investor accounts lose money when trading CFDs with this provider. You should consider whether You understand how CFDs work and whether You can afford to take the high risk of losing your money.\n\n'; -const String disclaimerTocParagraph14 = - 'You agree to indemnify, defend, and hold harmless GLEEC, its officers, directors, employees, agents, licensors, suppliers, and any third party information providers to the application from and against all losses, expenses, damages and costs, including reasonable lawyer fees, resulting from any violation of the Terms by You.\n\nYou also agree to indemnify GLEEC against any claims that information or material which You have submitted to GLEEC is in violation of any law or in breach of any third party rights (including, but not limited to, claims in respect of defamation, invasion of privacy, breach of confidence, infringement of copyright or infringement of any other intellectual property right).\n\n'; -const String disclaimerTocParagraph15 = - 'In order to be completed, any Virtual Currency transaction created with the GLEEC must be confirmed and recorded in the Virtual Currency ledger associated with the relevant Virtual Currency network. Such networks are decentralized, peer-to-peer networks supported by independent third parties, which are not owned, controlled or operated by GLEEC.\n\nGLEEC has no control over any Virtual Currency network and therefore cannot and does not ensure that any transaction details You submit via our Services will be confirmed on the relevant Virtual Currency network. You agree and understand that the transaction details You submit via our Services may not be completed, or may be substantially delayed, by the Virtual Currency network used to process the transaction. We do not guarantee that the Wallet can transfer title or rights in any Virtual Currency or make any warranties whatsoever with regard to title.\n\nOnce transaction details have been submitted to a Virtual Currency network, we cannot assist You to cancel or otherwise modify your transaction or transaction details. GLEEC has no control over any Virtual Currency network and does not have the ability to facilitate any cancellation or modification requests.\n\nIn the event of a Fork, GLEEC may not be able to support activity related to your Virtual Currency. You agree and understand that, in the event of a Fork, the transactions may not be completed, completed partially, incorrectly completed, or substantially delayed. GLEEC is not responsible for any loss incurred by You caused in whole or in part, directly or indirectly, by a Fork.\n\nIn no event shall GLEEC, its affiliates and service providers, or any of their respective officers, directors, agents, employees, or representatives, be liable for any lost profits or any special, incidental, indirect, intangible, or consequential damages, whether based on contract, tort, negligence, strict liability, or otherwise, arising out of or in connection with authorized or unauthorized use of the services, or this agreement, even if an authorized representative of GLEEC has been advised of, has known of, or should have known of the possibility of such damages. \n\nFor example (and without limiting the scope of the preceding sentence), You may not recover for lost profits, lost business opportunities, or other types of special, incidental, indirect, intangible, or consequential damages. Some jurisdictions do not allow the exclusion or limitation of incidental or consequential damages, so the above limitation may not apply to You. \n\nWe will not be responsible or liable to You for any loss and take no responsibility for damages or claims arising in whole or in part, directly or indirectly from: (a) user error such as forgotten passwords, incorrectly constructed transactions, or mistyped Virtual Currency addresses; (b) server failure or data loss; (c) corrupted or otherwise non-performing Wallets or Wallet files; (d) unauthorized access to applications; (e) any unauthorized activities, including without limitation the use of hacking, viruses, phishing, brute forcing, or any other means of attack against the Services.\n\n'; -const String disclaimerTocParagraph16 = - 'For the avoidance of doubt, GLEEC does not provide investment, tax, or legal advice, nor does GLEEC broker trades on your behalf. All GLEEC trades are executed automatically, based on the parameters of your order instructions and in accordance with posted Trade execution procedures, and You are solely responsible for determining whether any investment, investment strategy or related transaction is appropriate for You based on your personal investment objectives, financial circumstances and risk tolerance. You should consult your legal or tax professional regarding your specific situation. Neither GLEEC nor its owners, members, officers, directors, partners, consultants, nor anyone involved in the publication of this application, is a registered investment adviser or broker-dealer or associated person with a registered investment adviser or broker-dealer and none of the foregoing make any recommendation that the purchase or sale of crypto-assets or securities of any company profiled in the web Application is suitable or advisable for any person or that an investment or transaction in such crypto-assets or securities will be profitable. The information contained in the web Application is not intended to be, and shall not constitute, an offer to sell or the solicitation of any offer to buy any crypto-asset or security. The information presented in the web Application is provided for informational purposes only and is not to be treated as advice or a recommendation to make any specific investment or transaction. Please consult with a qualified professional before making any decisions.The opinions and analysis included in this applications are based on information from sources deemed to be reliable and are provided “as is” in good faith. GLEEC makes no representation or warranty, expressed, implied, or statutory, as to the accuracy or completeness of such information, which may be subject to change without notice. GLEEC shall not be liable for any errors or any actions taken in relation to the above. Statements of opinion and belief are those of the authors and/or editors who contribute to this application, and are based solely upon the information possessed by such authors and/or editors. No inference should be drawn that GLEEC or such authors or editors have any special or greater knowledge about the crypto-assets or companies profiled or any particular expertise in the industries or markets in which the profiled crypto-assets and companies operate and compete.Information on this application is obtained from sources deemed to be reliable; however, GLEEC takes no responsibility for verifying the accuracy of such information and makes no representation that such information is accurate or complete. Certain statements included in this application may be forward-looking statements based on current expectations. GLEEC makes no representation and provides no assurance or guarantee that such forward-looking statements will prove to be accurate. Persons using the GLEEC application are urged to consult with a qualified professional with respect to an investment or transaction in any crypto-asset or company profiled herein. Additionally, persons using this application expressly represent that the content in this application is not and will not be a consideration in such persons’ investment or transaction decisions. Traders should verify independently information provided in the GLEEC application by completing their own due diligence on any crypto-asset or company in which they are contemplating an investment or transaction of any kind and review a complete information package on that crypto-asset or company, which should include, but not be limited to, related blog updates and press releases. Past performance of profiled crypto-assets and securities is not indicative of future results. Crypto-assets and companies profiled on this site may lack an active trading market and invest in a crypto-asset or security that lacks an active trading market or trade on certain media, platforms and markets are deemed highly speculative and carry a high degree of risk. Anyone holding such crypto-assets and securities should be financially able and prepared to bear the risk of loss and the actual loss of his or her entire trade. The information in this application is not designed to be used as a basis for an investment decision. Persons using the GLEEC application should confirm to their own satisfaction the veracity of any information prior to entering into any investment or making any transaction. The decision to buy or sell any crypto-asset or security that may be featured by GLEEC is done purely and entirely at the reader’s own risk. As a reader and user of this application, You agree that under no circumstances will You seek to hold liable owners, members, officers, directors, partners, consultants or other persons involved in the publication of this application for any losses incurred by the use of information contained in this applicationGLEEC and its contractors and affiliates may profit in the event the crypto-assets and securities increase or decrease in value. Such crypto-assets and securities may be bought or sold from time to time, even after GLEEC has distributed positive information regarding the crypto-assets and companies. GLEEC has no obligation to inform readers of its trading activities or the trading activities of any of its owners, members, officers, directors, contractors and affiliates and/or any companies affiliated with BC Relations’ owners, members, officers, directors, contractors and affiliates. GLEEC and its affiliates may from time to time enter into agreements to purchase crypto-assets or securities to provide a method to reach their goals.\n\n'; -const String disclaimerTocParagraph17 = - 'The Terms are effective until terminated by GLEEC. \n\nIn the event of termination, You are no longer authorized to access the Application, but all restrictions imposed on You and the disclaimers and limitations of liability set out in the Terms will survive termination. \n\nSuch termination shall not affect any legal right that may have accrued to GLEEC against You up to the date of termination. \n\nGLEEC may also remove the Application as a whole or any sections or features of the Application at any time. \n\n'; -const String disclaimerTocParagraph18 = - 'The provisions of previous paragraphs are for the benefit of GLEEC and its officers, directors, employees, agents, licensors, suppliers, and any third party information providers to the Application. Each of these individuals or entities shall have the right to assert and enforce those provisions directly against You on its own behalf.\n\n'; -const String disclaimerTocParagraph19 = - 'GLEEC Wallet is a non-custodial, decentralized and blockchain based application and as such does GLEEC never store any user data (accounts and authentication data). \n\nWe also collect and process non-personal, anonymized data for statistical purposes and analysis and to help us provide a better service.\n\nThis document was last updated on December 10th, 2025\n\n'; diff --git a/lib/shared/widgets/disclaimer/disclaimer.dart b/lib/shared/widgets/disclaimer/disclaimer.dart index a8c02c5940..5c6d3aa8d9 100644 --- a/lib/shared/widgets/disclaimer/disclaimer.dart +++ b/lib/shared/widgets/disclaimer/disclaimer.dart @@ -1,139 +1,29 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; -import 'package:web_dex/shared/widgets/disclaimer/constants.dart'; -import 'package:web_dex/shared/widgets/disclaimer/tos_content.dart'; - -class Disclaimer extends StatefulWidget { - const Disclaimer({Key? key, required this.onClose}) : super(key: key); - final Function() onClose; +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/services/legal_documents/legal_document.dart'; +import 'package:web_dex/shared/widgets/legal_documents/legal_document_view.dart'; - @override - State createState() => _DisclaimerState(); -} +class Disclaimer extends StatelessWidget { + const Disclaimer({super.key, required this.onClose}); + final VoidCallback onClose; -class _DisclaimerState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { - final List disclaimerToSText = [ - TextSpan( - text: disclaimerTocTitle2, - style: Theme.of(context).textTheme.titleLarge), - TextSpan( - text: disclaimerTocParagraph2, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerTocTitle3, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocTitle4, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocParagraph3, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerTocTitle5, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocParagraph4, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerTocTitle6, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocParagraph5, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerTocTitle7, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocParagraph6, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerTocTitle8, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocParagraph7, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerTocTitle9, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocParagraph8, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerTocTitle10, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocParagraph9, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerTocTitle11, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocParagraph10, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerTocTitle12, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocParagraph11, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerTocTitle13, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocParagraph12, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerTocTitle14, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocParagraph13, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerTocTitle15, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocParagraph14, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerTocTitle16, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocParagraph15, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerTocTitle17, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocTitle19, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocParagraph18, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerTocTitle20, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerTocParagraph19, - style: Theme.of(context).textTheme.bodyMedium) - ]; - return Column( children: [ SizedBox( height: MediaQuery.of(context).size.height * 2 / 3, - child: SingleChildScrollView( - controller: ScrollController(), - child: TosContent(disclaimerToSText: disclaimerToSText), + child: const LegalDocumentView( + document: LegalDocumentType.termsOfService, + scrollable: true, ), ), const SizedBox(height: 24), UiPrimaryButton( key: const Key('close-disclaimer'), - onPressed: widget.onClose, + onPressed: onClose, width: 300, text: LocaleKeys.close.tr(), ), diff --git a/lib/shared/widgets/disclaimer/eula.dart b/lib/shared/widgets/disclaimer/eula.dart index e53d7d87bf..7f01f4f201 100644 --- a/lib/shared/widgets/disclaimer/eula.dart +++ b/lib/shared/widgets/disclaimer/eula.dart @@ -1,111 +1,29 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; -import 'package:web_dex/shared/widgets/disclaimer/constants.dart'; -import 'package:web_dex/shared/widgets/disclaimer/tos_content.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/services/legal_documents/legal_document.dart'; +import 'package:web_dex/shared/widgets/legal_documents/legal_document_view.dart'; -class Eula extends StatefulWidget { - const Eula({Key? key, required this.onClose}) : super(key: key); - final Function() onClose; +class Eula extends StatelessWidget { + const Eula({super.key, required this.onClose}); + final VoidCallback onClose; - @override - State createState() => _EulaState(); -} - -class _EulaState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { - final List disclaimerToSText = [ - TextSpan( - text: disclaimerEulaTitle1, - style: Theme.of(context).textTheme.titleLarge), - TextSpan( - text: disclaimerEulaParagraph1, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaParagraph2, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaParagraph3, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaParagraph4, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaParagraph5, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaParagraph6, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaTitle2, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerEulaParagraph7, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaParagraph8, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaTitle3, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerEulaParagraph9, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaParagraph10, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaParagraph11, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaParagraph12, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaParagraph13, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaTitle4, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerEulaParagraph14, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaParagraph15, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaTitle5, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerEulaParagraph16, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaParagraph17, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaTitle6, - style: Theme.of(context).textTheme.titleSmall), - TextSpan( - text: disclaimerEulaParagraph18, - style: Theme.of(context).textTheme.bodyMedium), - TextSpan( - text: disclaimerEulaParagraph19, - style: Theme.of(context).textTheme.bodyMedium), - ]; - return Column( children: [ SizedBox( - height: MediaQuery.of(context).size.height * 2 / 3, - child: SingleChildScrollView( - controller: ScrollController(), - child: TosContent(disclaimerToSText: disclaimerToSText), - )), + height: MediaQuery.of(context).size.height * 2 / 3, + child: const LegalDocumentView( + document: LegalDocumentType.eula, + scrollable: true, + ), + ), const SizedBox(height: 24), UiPrimaryButton( key: const Key('close-disclaimer'), - onPressed: widget.onClose, + onPressed: onClose, width: 300, text: LocaleKeys.close.tr(), ), diff --git a/lib/shared/widgets/disclaimer/eula_tos_checkboxes.dart b/lib/shared/widgets/disclaimer/eula_tos_checkboxes.dart index 54d0937770..3f37dec3cb 100644 --- a/lib/shared/widgets/disclaimer/eula_tos_checkboxes.dart +++ b/lib/shared/widgets/disclaimer/eula_tos_checkboxes.dart @@ -1,17 +1,21 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:web_dex/dispatchers/popup_dispatcher.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/widgets/app_dialog.dart'; import 'package:web_dex/shared/widgets/disclaimer/disclaimer.dart'; import 'package:web_dex/shared/widgets/disclaimer/eula.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; class EulaTosCheckboxes extends StatefulWidget { - const EulaTosCheckboxes( - {Key? key, this.isChecked = false, required this.onCheck}) - : super(key: key); + const EulaTosCheckboxes({ + super.key, + this.isChecked = false, + required this.onCheck, + }); final bool isChecked; final void Function(bool) onCheck; @@ -22,8 +26,6 @@ class EulaTosCheckboxes extends StatefulWidget { class _EulaTosCheckboxesState extends State { bool _checkBox = false; - PopupDispatcher? _eulaPopupManager; - PopupDispatcher? _disclaimerPopupManager; @override Widget build(BuildContext context) { @@ -68,24 +70,6 @@ class _EulaTosCheckboxesState extends State { @override void initState() { _checkBox = widget.isChecked; - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _disclaimerPopupManager = PopupDispatcher( - context: context, - popupContent: Disclaimer( - onClose: () { - _disclaimerPopupManager?.close(); - }, - ), - ); - _eulaPopupManager = PopupDispatcher( - context: context, - popupContent: Eula( - onClose: () { - _eulaPopupManager?.close(); - }, - ), - ); - }); super.initState(); } @@ -94,10 +78,24 @@ class _EulaTosCheckboxesState extends State { } void _showDisclaimer() { - _disclaimerPopupManager?.show(); + unawaited( + AppDialog.showWithCallback( + context: context, + useRootNavigator: false, + width: 640, + childBuilder: (closeDialog) => Disclaimer(onClose: closeDialog), + ), + ); } void _showEula() { - _eulaPopupManager?.show(); + unawaited( + AppDialog.showWithCallback( + context: context, + useRootNavigator: false, + width: 640, + childBuilder: (closeDialog) => Eula(onClose: closeDialog), + ), + ); } } diff --git a/lib/shared/widgets/disclaimer/tos_content.dart b/lib/shared/widgets/disclaimer/tos_content.dart deleted file mode 100644 index d5b45fa636..0000000000 --- a/lib/shared/widgets/disclaimer/tos_content.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/material.dart'; - -class TosContent extends StatelessWidget { - const TosContent({ - super.key, - required this.disclaimerToSText, - }); - - final List disclaimerToSText; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: SelectableText.rich( - TextSpan( - style: Theme.of(context).textTheme.bodyMedium, - children: disclaimerToSText, - ), - ), - ); - } -} diff --git a/lib/shared/widgets/launch_native_explorer_button.dart b/lib/shared/widgets/launch_native_explorer_button.dart index fadc3247ed..32fc628e27 100644 --- a/lib/shared/widgets/launch_native_explorer_button.dart +++ b/lib/shared/widgets/launch_native_explorer_button.dart @@ -6,22 +6,19 @@ import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/shared/utils/utils.dart'; class LaunchNativeExplorerButton extends StatelessWidget { - const LaunchNativeExplorerButton({ - Key? key, - required this.coin, - this.address, - }) : super(key: key); + const LaunchNativeExplorerButton({Key? key, required this.coin, this.address}) + : super(key: key); final Coin coin; final String? address; @override Widget build(BuildContext context) { + final url = getNativeExplorerUrlByCoin(coin, address); + return UiPrimaryButton( width: 160, height: 30, - onPressed: () { - launchURLString(getNativeExplorerUrlByCoin(coin, address)); - }, + onPressed: url.isEmpty ? null : () => launchURLString(url), text: LocaleKeys.viewOnExplorer.tr(), ); } diff --git a/lib/shared/widgets/legal_documents/legal_document_view.dart b/lib/shared/widgets/legal_documents/legal_document_view.dart new file mode 100644 index 0000000000..f15e540da4 --- /dev/null +++ b/lib/shared/widgets/legal_documents/legal_document_view.dart @@ -0,0 +1,179 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/services/legal_documents/legal_document.dart'; +import 'package:web_dex/services/legal_documents/legal_documents_repository.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class LegalDocumentView extends StatefulWidget { + const LegalDocumentView({ + super.key, + required this.document, + this.padding = const EdgeInsets.all(16), + this.scrollable = false, + }); + + final LegalDocumentType document; + final EdgeInsetsGeometry padding; + final bool scrollable; + + @override + State createState() => _LegalDocumentViewState(); +} + +class _LegalDocumentViewState extends State { + final ScrollController _scrollController = ScrollController(); + int _requestId = 0; + LegalDocumentContent? _content; + Object? _loadingError; + bool _isRefreshing = false; + + @override + void initState() { + super.initState(); + _loadDocument(); + } + + @override + void didUpdateWidget(covariant LegalDocumentView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.document != widget.document) { + _loadDocument(); + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_content == null) { + if (_loadingError != null) { + return Padding( + padding: widget.padding, + child: Text( + LocaleKeys.legalDocumentLoadError.tr(), + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + + return const Center(child: CircularProgressIndicator()); + } + + final markdown = Padding( + padding: widget.padding, + child: MarkdownBody( + data: _content!.markdown, + selectable: true, + softLineBreak: true, + styleSheet: _buildStyleSheet(context), + onTapLink: (_, String? href, __) { + if (href == null || href.isEmpty) return; + unawaited(launchUrlString(href)); + }, + ), + ); + + final Widget body = widget.scrollable + ? DexScrollbar( + scrollController: _scrollController, + child: SingleChildScrollView( + controller: _scrollController, + child: markdown, + ), + ) + : markdown; + + if (!widget.scrollable) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_isRefreshing) const LinearProgressIndicator(minHeight: 2), + if (_isRefreshing) const SizedBox(height: 12), + body, + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_isRefreshing) const LinearProgressIndicator(minHeight: 2), + if (_isRefreshing) const SizedBox(height: 12), + Expanded(child: body), + ], + ); + } + + MarkdownStyleSheet _buildStyleSheet(BuildContext context) { + final base = MarkdownStyleSheet.fromTheme(Theme.of(context)); + final linkStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: Theme.of(context).colorScheme.primary, + ); + + return base.copyWith( + a: linkStyle, + p: Theme.of(context).textTheme.bodyMedium, + listBullet: Theme.of(context).textTheme.bodyMedium, + ); + } + + Future _loadDocument() async { + final requestId = ++_requestId; + setState(() { + _loadingError = null; + _content = null; + _isRefreshing = false; + }); + + final repository = context.read(); + + try { + final initialContent = await repository.loadPreferredContent( + widget.document, + ); + if (!mounted || requestId != _requestId) return; + + setState(() { + _content = initialContent; + }); + } catch (error) { + if (!mounted || requestId != _requestId) return; + + setState(() { + _loadingError = error; + }); + return; + } + + if (!mounted || requestId != _requestId) return; + + setState(() { + _isRefreshing = true; + }); + + final refreshedContent = await repository.refreshFromRemote( + widget.document, + ); + if (!mounted || requestId != _requestId) return; + + setState(() { + _isRefreshing = false; + if (refreshedContent != null) { + _content = refreshedContent; + } + }); + } +} diff --git a/lib/shared/widgets/remember_wallet_service.dart b/lib/shared/widgets/remember_wallet_service.dart index 64a0864a60..39f0a86905 100644 --- a/lib/shared/widgets/remember_wallet_service.dart +++ b/lib/shared/widgets/remember_wallet_service.dart @@ -92,6 +92,7 @@ class RememberWalletService { await AppDialog.showWithCallback( context: context, width: 320, + barrierDismissible: false, // Keep default useRootNavigator (true) to avoid navigation stack corruption childBuilder: (closeDialog) => WalletsManagerWrapper( eventType: WalletsManagerEventType.header, diff --git a/lib/views/bridge/view/table/bridge_tickers_list.dart b/lib/views/bridge/view/table/bridge_tickers_list.dart index 1e56186c2d..0882e2e0f9 100644 --- a/lib/views/bridge/view/table/bridge_tickers_list.dart +++ b/lib/views/bridge/view/table/bridge_tickers_list.dart @@ -18,7 +18,7 @@ import 'package:web_dex/views/dex/simple/form/tables/nothing_found.dart'; import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; class BridgeTickersList extends StatefulWidget { - const BridgeTickersList({required this.onSelect, Key? key}) : super(key: key); + const BridgeTickersList({required this.onSelect, super.key}); final Function(Coin) onSelect; @@ -27,13 +27,23 @@ class BridgeTickersList extends StatefulWidget { } class _BridgeTickersListState extends State { - String? _searchTerm; + String _searchTerm = ''; + late final TextEditingController _searchController; + late final FocusNode _searchFocusNode; @override void initState() { + super.initState(); + _searchController = TextEditingController(); + _searchFocusNode = FocusNode(); context.read().add(const BridgeUpdateTickers()); + } - super.initState(); + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); } @override @@ -64,6 +74,8 @@ class _BridgeTickersListState extends State { Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), child: BorderLessSearchField( + controller: _searchController, + focusNode: _searchFocusNode, onChanged: (String value) { if (_searchTerm == value) return; @@ -116,8 +128,8 @@ class _BridgeTickersListState extends State { .where((coin) => tradingState.canTradeAssets([coin.id])) .toList(); - if (_searchTerm != null && _searchTerm!.isNotEmpty) { - final String searchTerm = _searchTerm!.toLowerCase(); + if (_searchTerm.isNotEmpty) { + final String searchTerm = _searchTerm.toLowerCase(); coinsList.removeWhere((t) { if (t.abbr.toLowerCase().contains(searchTerm)) return false; if (t.name.toLowerCase().contains(searchTerm)) return false; diff --git a/lib/views/common/header/actions/account_switcher.dart b/lib/views/common/header/actions/account_switcher.dart index 466a99d75a..35232c89d6 100644 --- a/lib/views/common/header/actions/account_switcher.dart +++ b/lib/views/common/header/actions/account_switcher.dart @@ -6,9 +6,8 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; -import 'package:web_dex/dispatchers/popup_dispatcher.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; -import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/shared/widgets/app_dialog.dart'; import 'package:web_dex/shared/widgets/connect_wallet/connect_wallet_wrapper.dart'; import 'package:web_dex/shared/widgets/logout_popup.dart'; import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; @@ -17,33 +16,23 @@ const double minWidth = 100; const double maxWidth = 350; class AccountSwitcher extends StatefulWidget { - const AccountSwitcher({Key? key}) : super(key: key); + const AccountSwitcher({super.key}); @override State createState() => _AccountSwitcherState(); } class _AccountSwitcherState extends State { - late PopupDispatcher _logOutPopupManager; bool _isOpen = false; - @override - void initState() { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _logOutPopupManager = PopupDispatcher( - context: scaffoldKey.currentContext ?? context, - popupContent: LogOutPopup( - onConfirm: () => _logOutPopupManager.close(), - onCancel: () => _logOutPopupManager.close(), - ), - ); - }); - super.initState(); - } - @override - void dispose() { - _logOutPopupManager.close(); - super.dispose(); + Future _showLogoutDialog() async { + await AppDialog.showWithCallback( + context: context, + width: 320, + barrierDismissible: true, + childBuilder: (closeDialog) => + LogOutPopup(onConfirm: closeDialog, onCancel: closeDialog), + ); } @override @@ -56,16 +45,17 @@ class _AccountSwitcherState extends State { isOpen: _isOpen, onSwitch: (isOpen) { WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; setState(() => _isOpen = isOpen); }); }, switcher: const _AccountSwitcher(), dropdown: _AccountDropdown( - onTap: () { - _logOutPopupManager.show(); - setState(() { - _isOpen = false; - }); + onTap: () async { + if (mounted) { + setState(() => _isOpen = false); + } + await _showLogoutDialog(); }, ), ), @@ -78,7 +68,6 @@ class _AccountSwitcher extends StatelessWidget { @override Widget build(BuildContext context) { - final currentWallet = context.read().state.currentUser?.wallet; return Container( constraints: const BoxConstraints(minWidth: minWidth), padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), @@ -91,7 +80,7 @@ class _AccountSwitcher extends StatelessWidget { return Container( constraints: const BoxConstraints(maxWidth: maxWidth), child: Text( - currentWallet?.name ?? '', + state.currentUser?.walletId.name ?? '', style: TextStyle( fontWeight: FontWeight.w600, fontSize: 14, @@ -113,17 +102,16 @@ class _AccountSwitcher extends StatelessWidget { } class _AccountDropdown extends StatelessWidget { - final VoidCallback onTap; const _AccountDropdown({required this.onTap}); + final VoidCallback onTap; + @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, - border: Border.all( - color: theme.custom.specificButtonBorderColor, - ), + border: Border.all(color: theme.custom.specificButtonBorderColor), ), constraints: const BoxConstraints(minWidth: minWidth, maxWidth: maxWidth), child: InkWell( @@ -138,7 +126,9 @@ class _AccountDropdown extends StatelessWidget { child: Text( LocaleKeys.logOut.tr(), style: const TextStyle( - fontSize: 14, fontWeight: FontWeight.w600), + fontSize: 14, + fontWeight: FontWeight.w600, + ), overflow: TextOverflow.ellipsis, ), ), @@ -158,8 +148,9 @@ class _AccountIcon extends StatelessWidget { return Container( padding: const EdgeInsets.all(2.0), decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).colorScheme.tertiary), + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.tertiary, + ), child: ClipRRect( borderRadius: BorderRadius.circular(18), child: SvgPicture.asset( diff --git a/lib/views/common/hw_wallet_dialog/hw_dialog_wallet_select.dart b/lib/views/common/hw_wallet_dialog/hw_dialog_wallet_select.dart index 0b5959b35b..b28e8e7ed3 100644 --- a/lib/views/common/hw_wallet_dialog/hw_dialog_wallet_select.dart +++ b/lib/views/common/hw_wallet_dialog/hw_dialog_wallet_select.dart @@ -13,10 +13,8 @@ import 'package:web_dex/model/hw_wallet/hw_wallet.dart'; import 'package:web_dex/views/common/hw_wallet_dialog/constants.dart'; class HwDialogWalletSelect extends StatefulWidget { - const HwDialogWalletSelect({ - Key? key, - required this.onSelect, - }) : super(key: key); + const HwDialogWalletSelect({Key? key, required this.onSelect}) + : super(key: key); final void Function(WalletBrand) onSelect; @@ -45,6 +43,14 @@ class _HwDialogWalletSelectState extends State { style: trezorDialogSubtitle, textAlign: TextAlign.center, ), + const SizedBox(height: 8), + Text( + LocaleKeys.trezorWalletOnlyNotice.tr(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), const SizedBox(height: 24), _HwWalletTile( disabled: !platformState.isTrezorSupported, @@ -66,16 +72,14 @@ class _HwDialogWalletSelectState extends State { margin: const EdgeInsets.only(top: 4.0), padding: const EdgeInsets.symmetric(vertical: 8.0), decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .error - .withValues(alpha: 0.1), + color: Theme.of( + context, + ).colorScheme.error.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), border: Border.all( - color: Theme.of(context) - .colorScheme - .error - .withValues(alpha: 0.3), + color: Theme.of( + context, + ).colorScheme.error.withValues(alpha: 0.3), ), ), child: Text( diff --git a/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_select_wallet.dart b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_select_wallet.dart index 011bb96082..192c1f6e10 100644 --- a/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_select_wallet.dart +++ b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_select_wallet.dart @@ -8,33 +8,34 @@ import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; import 'package:web_dex/shared/widgets/password_visibility_control.dart'; class TrezorDialogSelectWallet extends StatelessWidget { - const TrezorDialogSelectWallet({Key? key, required this.onComplete}) - : super(key: key); + const TrezorDialogSelectWallet({super.key, required this.onComplete}); final Function(String) onComplete; @override Widget build(BuildContext context) { - return ScreenshotSensitive(child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - LocaleKeys.selectWalletType.tr(), - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: 18), - _TrezorStandardWallet(onTap: () => onComplete('')), - const Padding( - padding: EdgeInsets.symmetric(vertical: 6.0), - child: UiDivider(), - ), - _TrezorHiddenWallet( - onSubmit: (String passphrase) => onComplete(passphrase), - ), - ], - )); + return ScreenshotSensitive( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + LocaleKeys.selectWalletType.tr(), + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 18), + _TrezorStandardWallet(onTap: () => onComplete('')), + const Padding( + padding: EdgeInsets.symmetric(vertical: 6.0), + child: UiDivider(), + ), + _TrezorHiddenWallet( + onSubmit: (String passphrase) => onComplete(passphrase), + ), + ], + ), + ); } } @@ -90,7 +91,7 @@ class _TrezorHiddenWalletState extends State<_TrezorHiddenWallet> { description: LocaleKeys.passphraseRequired.tr(), icon: Icons.lock_outline, isIconShown: _isSendAllowed, - onTap: _onSubmit, + onTap: _isSendAllowed ? _onSubmit : null, ), const SizedBox(height: 12), ConstrainedBox( @@ -109,6 +110,7 @@ class _TrezorHiddenWalletState extends State<_TrezorHiddenWallet> { autofocus: true, hintText: LocaleKeys.passphrase.tr(), keyboardType: TextInputType.text, + validationMode: InputValidationMode.lazy, autofillHints: const [AutofillHints.password], obscureText: _isObscured, maxLength: passwordMaxLength, @@ -158,7 +160,7 @@ class _TrezorWalletItem extends StatelessWidget { final String title; final String description; final IconData icon; - final VoidCallback onTap; + final VoidCallback? onTap; final bool isIconShown; @override diff --git a/lib/views/common/main_menu/main_menu_bar_mobile.dart b/lib/views/common/main_menu/main_menu_bar_mobile.dart index 71bbaf4759..e53783cd21 100644 --- a/lib/views/common/main_menu/main_menu_bar_mobile.dart +++ b/lib/views/common/main_menu/main_menu_bar_mobile.dart @@ -25,6 +25,24 @@ class MainMenuBarMobile extends StatelessWidget { .watch() .state .isEnabled; + final bool isHardwareWallet = currentWallet?.isHW == true; + + String tradingTooltipMessage() { + if (isHardwareWallet) { + return LocaleKeys.trezorWalletOnlyTooltip.tr(); + } + if (!tradingEnabled) { + return LocaleKeys.tradingDisabledTooltip.tr(); + } + return ''; + } + + String walletOnlyTooltipMessage() { + return isHardwareWallet + ? LocaleKeys.trezorWalletOnlyTooltip.tr() + : ''; + } + return DecoratedBox( decoration: BoxDecoration( color: theme.currentGlobal.cardColor, @@ -50,29 +68,28 @@ class MainMenuBarMobile extends StatelessWidget { ), ), Expanded( - child: MainMenuBarMobileItem( - value: MainMenuValue.fiat, - enabled: currentWallet?.isHW != true, - isActive: selected == MainMenuValue.fiat, + child: Tooltip( + message: tradingTooltipMessage(), + child: MainMenuBarMobileItem( + value: MainMenuValue.dex, + enabled: currentWallet?.isHW != true, + isActive: selected == MainMenuValue.dex, + ), ), ), Expanded( child: Tooltip( - message: tradingEnabled - ? '' - : LocaleKeys.tradingDisabledTooltip.tr(), + message: walletOnlyTooltipMessage(), child: MainMenuBarMobileItem( - value: MainMenuValue.dex, + value: MainMenuValue.fiat, enabled: currentWallet?.isHW != true, - isActive: selected == MainMenuValue.dex, + isActive: selected == MainMenuValue.fiat, ), ), ), Expanded( child: Tooltip( - message: tradingEnabled - ? '' - : LocaleKeys.tradingDisabledTooltip.tr(), + message: tradingTooltipMessage(), child: MainMenuBarMobileItem( value: MainMenuValue.bridge, enabled: currentWallet?.isHW != true, @@ -83,9 +100,7 @@ class MainMenuBarMobile extends StatelessWidget { if (isMMBotEnabled) Expanded( child: Tooltip( - message: tradingEnabled - ? '' - : LocaleKeys.tradingDisabledTooltip.tr(), + message: tradingTooltipMessage(), child: MainMenuBarMobileItem( enabled: currentWallet?.isHW != true, value: MainMenuValue.marketMakerBot, diff --git a/lib/views/common/main_menu/main_menu_desktop.dart b/lib/views/common/main_menu/main_menu_desktop.dart index ab5b4bba44..eaa59be295 100644 --- a/lib/views/common/main_menu/main_menu_desktop.dart +++ b/lib/views/common/main_menu/main_menu_desktop.dart @@ -41,6 +41,23 @@ class _MainMenuDesktopState extends State { .isEnabled; final SettingsBloc settings = context.read(); final currentWallet = state.currentUser?.wallet; + final bool isHardwareWallet = currentWallet?.isHW == true; + + String tradingTooltipMessage() { + if (isHardwareWallet) { + return LocaleKeys.trezorWalletOnlyTooltip.tr(); + } + if (!tradingEnabled) { + return LocaleKeys.tradingDisabledTooltip.tr(); + } + return ''; + } + + String walletOnlyTooltipMessage() { + return isHardwareWallet + ? LocaleKeys.trezorWalletOnlyTooltip.tr() + : ''; + } return Container( height: double.infinity, @@ -78,19 +95,8 @@ class _MainMenuDesktopState extends State { MainMenuValue.wallet, ), ), - DesktopMenuDesktopItem( - key: const Key('main-menu-fiat'), - enabled: currentWallet?.isHW != true, - menu: MainMenuValue.fiat, - onTap: onTapItem, - isSelected: _checkSelectedItem( - MainMenuValue.fiat, - ), - ), Tooltip( - message: tradingEnabled - ? '' - : LocaleKeys.tradingDisabledTooltip.tr(), + message: tradingTooltipMessage(), child: DesktopMenuDesktopItem( key: const Key('main-menu-dex'), enabled: currentWallet?.isHW != true, @@ -102,9 +108,19 @@ class _MainMenuDesktopState extends State { ), ), Tooltip( - message: tradingEnabled - ? '' - : LocaleKeys.tradingDisabledTooltip.tr(), + message: walletOnlyTooltipMessage(), + child: DesktopMenuDesktopItem( + key: const Key('main-menu-fiat'), + enabled: currentWallet?.isHW != true, + menu: MainMenuValue.fiat, + onTap: onTapItem, + isSelected: _checkSelectedItem( + MainMenuValue.fiat, + ), + ), + ), + Tooltip( + message: tradingTooltipMessage(), child: DesktopMenuDesktopItem( key: const Key('main-menu-bridge'), enabled: currentWallet?.isHW != true, @@ -117,9 +133,7 @@ class _MainMenuDesktopState extends State { ), if (isMMBotEnabled && isAuthenticated) Tooltip( - message: tradingEnabled - ? '' - : LocaleKeys.tradingDisabledTooltip.tr(), + message: tradingTooltipMessage(), child: DesktopMenuDesktopItem( key: const Key('main-menu-market-maker-bot'), enabled: currentWallet?.isHW != true, diff --git a/lib/views/custom_token_import/custom_token_import_dialog.dart b/lib/views/custom_token_import/custom_token_import_dialog.dart index 06abc9ee8f..e72bfc7d34 100644 --- a/lib/views/custom_token_import/custom_token_import_dialog.dart +++ b/lib/views/custom_token_import/custom_token_import_dialog.dart @@ -58,12 +58,8 @@ class CustomTokenImportDialogState extends State { controller: _pageController, physics: const NeverScrollableScrollPhysics(), children: [ - ImportFormPage( - onNextPage: goToNextPage, - ), - ImportSubmitPage( - onPreviousPage: goToPreviousPage, - ), + ImportFormPage(onNextPage: goToNextPage), + ImportSubmitPage(onPreviousPage: goToPreviousPage), ], ), ), @@ -105,12 +101,7 @@ class BasePage extends StatelessWidget { splashRadius: 20, ), if (onBackPressed != null) const SizedBox(width: 16), - Text( - title, - style: const TextStyle( - fontSize: 18, - ), - ), + Text(title, style: const TextStyle(fontSize: 18)), const Spacer(), IconButton( icon: const Icon(Icons.close), @@ -173,9 +164,7 @@ class ImportFormPage extends StatelessWidget { Expanded( child: Text( LocaleKeys.importTokenWarning.tr(), - style: const TextStyle( - fontSize: 14, - ), + style: const TextStyle(fontSize: 14), ), ), ], @@ -183,13 +172,13 @@ class ImportFormPage extends StatelessWidget { ), const SizedBox(height: 24), DropdownButtonFormField( - value: state.network, + initialValue: state.network, isExpanded: true, decoration: InputDecoration( labelText: LocaleKeys.selectNetwork.tr(), border: const OutlineInputBorder(), ), - items: state.evmNetworks.map((CoinSubClass coinSubClass) { + items: state.supportedNetworks.map((CoinSubClass coinSubClass) { return DropdownMenuItem( value: coinSubClass, child: Text(coinSubClass.formatted), @@ -198,9 +187,9 @@ class ImportFormPage extends StatelessWidget { onChanged: !initialState ? null : (CoinSubClass? value) { - context - .read() - .add(UpdateNetworkEvent(value)); + context.read().add( + UpdateNetworkEvent(value), + ); }, ), const SizedBox(height: 24), @@ -208,9 +197,9 @@ class ImportFormPage extends StatelessWidget { controller: addressController, enabled: initialState, onChanged: (value) { - context - .read() - .add(UpdateAddressEvent(value)); + context.read().add( + UpdateAddressEvent(value), + ); }, decoration: InputDecoration( labelText: LocaleKeys.tokenContractAddress.tr(), @@ -221,9 +210,9 @@ class ImportFormPage extends StatelessWidget { UiPrimaryButton( onPressed: isSubmitEnabled ? () { - context - .read() - .add(const SubmitFetchCustomTokenEvent()); + context.read().add( + const SubmitFetchCustomTokenEvent(), + ); } : null, child: state.formStatus == FormStatus.initial @@ -259,16 +248,17 @@ class ImportSubmitPage extends StatelessWidget { final newCoinUsdBalance = '\$${formatAmt(state.coinBalanceUsd.toDouble())}'; - final isSubmitEnabled = state.importStatus != FormStatus.submitting && + final isSubmitEnabled = + state.importStatus != FormStatus.submitting && state.importStatus != FormStatus.success && newCoin != null; return BasePage( title: LocaleKeys.importCustomToken.tr(), onBackPressed: () { - context - .read() - .add(const ResetFormStatusEvent()); + context.read().add( + const ResetFormStatusEvent(), + ); onPreviousPage(); }, child: Column( @@ -285,9 +275,7 @@ class ImportSubmitPage extends StatelessWidget { height: 250, filterQuality: FilterQuality.high, ), - Text( - LocaleKeys.tokenNotFound.tr(), - ), + Text(LocaleKeys.tokenNotFound.tr()), ], ), ), @@ -298,10 +286,7 @@ class ImportSubmitPage extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - AssetLogo.ofId( - newCoin.id, - size: 80, - ), + AssetLogo.ofId(newCoin.id, size: 80), const SizedBox(height: 12), Text( newCoin.id.id, @@ -310,10 +295,9 @@ class ImportSubmitPage extends StatelessWidget { const SizedBox(height: 32), Text( LocaleKeys.balance.tr(), - style: - Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Colors.grey, - ), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: Colors.grey), ), const SizedBox(height: 8), Text( @@ -338,12 +322,13 @@ class ImportSubmitPage extends StatelessWidget { UiPrimaryButton( onPressed: isSubmitEnabled ? () { - context - .read() - .add(const SubmitImportCustomTokenEvent()); + context.read().add( + const SubmitImportCustomTokenEvent(), + ); } : null, - child: state.importStatus == FormStatus.submitting || + child: + state.importStatus == FormStatus.submitting || state.importStatus == FormStatus.success ? const UiSpinner(color: Colors.white) : Text(LocaleKeys.importToken.tr()), diff --git a/lib/views/dex/dex_helpers.dart b/lib/views/dex/dex_helpers.dart index a0bb7c2b83..2397a1b717 100644 --- a/lib/views/dex/dex_helpers.dart +++ b/lib/views/dex/dex_helpers.dart @@ -221,6 +221,8 @@ double compareToCex(double baseUsdPrice, double relUsdPrice, Rational rate) { return (dexRate - cexRate) * 100 / cexRate; } +final Set _activationInFlight = {}; + Future> activateCoinIfNeeded( String? abbr, CoinsRepo coinsRepository, @@ -231,6 +233,11 @@ Future> activateCoinIfNeeded( final Coin? coin = coinsRepository.getCoin(abbr); if (coin == null) return errors; + if (_activationInFlight.contains(coin.abbr)) { + return errors; + } + + _activationInFlight.add(coin.abbr); try { // sdk handles parent activation logic, so simply call // activation here @@ -241,6 +248,8 @@ Future> activateCoinIfNeeded( error: '${LocaleKeys.unableToActiveCoin.tr(args: [coin.abbr])}: $e', ), ); + } finally { + _activationInFlight.remove(coin.abbr); } return errors; diff --git a/lib/views/dex/dex_list_filter/mobile/dex_list_filter_coins_list_mobile.dart b/lib/views/dex/dex_list_filter/mobile/dex_list_filter_coins_list_mobile.dart index 5f3394a69c..6fecc4968f 100644 --- a/lib/views/dex/dex_list_filter/mobile/dex_list_filter_coins_list_mobile.dart +++ b/lib/views/dex/dex_list_filter/mobile/dex_list_filter_coins_list_mobile.dart @@ -14,12 +14,12 @@ import 'package:web_dex/views/dex/dex_helpers.dart'; class DexListFilterCoinsList extends StatefulWidget { const DexListFilterCoinsList({ - Key? key, required this.isSellCoin, required this.anotherCoin, required this.onCoinSelect, required this.listType, - }) : super(key: key); + super.key, + }); final DexListType listType; final bool isSellCoin; final String? anotherCoin; @@ -31,6 +31,22 @@ class DexListFilterCoinsList extends StatefulWidget { class _DexListFilterCoinsListState extends State { String _searchPhrase = ''; + late final TextEditingController _searchController; + late final FocusNode _searchFocusNode; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + _searchFocusNode = FocusNode(); + } + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -40,6 +56,8 @@ class _DexListFilterCoinsListState extends State { mainAxisSize: MainAxisSize.min, children: [ UiTextFormField( + controller: _searchController, + focusNode: _searchFocusNode, hintText: LocaleKeys.searchAssets.tr(), autofocus: true, onChanged: (String? searchPhrase) { @@ -67,8 +85,9 @@ class _DexListFilterCoinsListState extends State { } Widget _buildSwapCoinList() { - final tradingEntitiesBloc = - RepositoryProvider.of(context); + final tradingEntitiesBloc = RepositoryProvider.of( + context, + ); return StreamBuilder>( stream: tradingEntitiesBloc.outSwaps, initialData: tradingEntitiesBloc.swaps, @@ -86,8 +105,9 @@ class _DexListFilterCoinsListState extends State { } Widget _buildOrderCoinList() { - final tradingEntitiesBloc = - RepositoryProvider.of(context); + final tradingEntitiesBloc = RepositoryProvider.of( + context, + ); return StreamBuilder>( stream: tradingEntitiesBloc.outMyOrders, initialData: tradingEntitiesBloc.myOrders, @@ -102,14 +122,15 @@ class _DexListFilterCoinsListState extends State { } Widget _buildCoinList(Map> coinAbbrMap) { - final List coinAbbrList = (_searchPhrase.isEmpty - ? coinAbbrMap.keys.toList() - : coinAbbrMap.keys.where( - (String coinAbbr) => - coinAbbr.toLowerCase().contains(_searchPhrase), - )) - .where((abbr) => abbr != widget.anotherCoin) - .toList(); + final List coinAbbrList = + (_searchPhrase.isEmpty + ? coinAbbrMap.keys.toList() + : coinAbbrMap.keys.where( + (String coinAbbr) => + coinAbbr.toLowerCase().contains(_searchPhrase), + )) + .where((abbr) => abbr != widget.anotherCoin) + .toList(); final int lastIndex = coinAbbrList.length - 1; diff --git a/lib/views/dex/dex_list_filter/mobile/dex_list_header_mobile.dart b/lib/views/dex/dex_list_filter/mobile/dex_list_header_mobile.dart index 28f9c059bb..3dcde1f82b 100644 --- a/lib/views/dex/dex_list_filter/mobile/dex_list_header_mobile.dart +++ b/lib/views/dex/dex_list_filter/mobile/dex_list_header_mobile.dart @@ -52,16 +52,27 @@ class DexListHeaderMobile extends StatelessWidget { const SizedBox(width: 8), ], if (listType == DexListType.orders) - UiPrimaryButton( - text: LocaleKeys.cancelAll.tr(), - width: 100, - height: 30, - onPressed: - onCancelAll ?? () => tradingEntitiesBloc.cancelAllOrders(), - textStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), + PopupMenuButton<_DexListHeaderAction>( + icon: const Icon(Icons.more_vert, size: 20), + onSelected: (action) { + switch (action) { + case _DexListHeaderAction.cancelAll: + (onCancelAll ?? + () => tradingEntitiesBloc.cancelAllOrders())(); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: _DexListHeaderAction.cancelAll, + child: Text( + LocaleKeys.cancelAll.tr(), + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), ], ), @@ -355,3 +366,5 @@ class DexListHeaderMobile extends StatelessWidget { ); } } + +enum _DexListHeaderAction { cancelAll } diff --git a/lib/views/dex/entities_list/common/swap_actions_menu.dart b/lib/views/dex/entities_list/common/swap_actions_menu.dart new file mode 100644 index 0000000000..9cbfacbcb0 --- /dev/null +++ b/lib/views/dex/entities_list/common/swap_actions_menu.dart @@ -0,0 +1,59 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/shared/utils/swap_export.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +enum SwapAction { copyUuid, exportData } + +class SwapActionsMenu extends StatefulWidget { + const SwapActionsMenu({super.key, required this.swap}); + + final Swap swap; + + @override + State createState() => _SwapActionsMenuState(); +} + +class _SwapActionsMenuState extends State { + bool _isExporting = false; + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + icon: _isExporting + ? const UiSpinner(width: 16, height: 16, strokeWidth: 2) + : const Icon(Icons.more_vert, size: 18), + onSelected: (action) async { + switch (action) { + case SwapAction.copyUuid: + copyToClipBoard( + context, + widget.swap.uuid, + LocaleKeys.copiedUuidToClipboard.tr(), + ); + case SwapAction.exportData: + if (_isExporting) return; + setState(() => _isExporting = true); + try { + await exportSwapData(context, widget.swap.uuid); + } finally { + if (mounted) setState(() => _isExporting = false); + } + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: SwapAction.copyUuid, + child: Text(LocaleKeys.copyUuid.tr()), + ), + PopupMenuItem( + value: SwapAction.exportData, + child: Text(LocaleKeys.exportSwapData.tr()), + ), + ], + ); + } +} diff --git a/lib/views/dex/entities_list/history/history_item.dart b/lib/views/dex/entities_list/history/history_item.dart index f28517a90a..80fd04e147 100644 --- a/lib/views/dex/entities_list/history/history_item.dart +++ b/lib/views/dex/entities_list/history/history_item.dart @@ -13,11 +13,12 @@ import 'package:web_dex/shared/utils/formatters.dart'; import 'package:web_dex/shared/widgets/focusable_widget.dart'; import 'package:web_dex/views/dex/entities_list/common/coin_amount_mobile.dart'; import 'package:web_dex/views/dex/entities_list/common/entity_item_status_wrapper.dart'; +import 'package:web_dex/views/dex/entities_list/common/swap_actions_menu.dart'; import 'package:web_dex/views/dex/entities_list/common/trade_amount_desktop.dart'; class HistoryItem extends StatefulWidget { const HistoryItem(this.swap, {Key? key, required this.onClick}) - : super(key: key); + : super(key: key); final Swap swap; final VoidCallback onClick; @@ -42,8 +43,9 @@ class _HistoryItemState extends State { final bool isSuccessful = !widget.swap.isFailed; final bool isTaker = widget.swap.isTaker; final bool isRecoverable = widget.swap.recoverable; - final tradingEntitiesBloc = - RepositoryProvider.of(context); + final tradingEntitiesBloc = RepositoryProvider.of( + context, + ); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -71,6 +73,7 @@ class _HistoryItemState extends State { child: isMobile ? _HistoryItemMobile( key: Key('swap-item-$uuid-mobile'), + swap: widget.swap, uuid: uuid, isRecovering: _isRecovering, buyAmount: buyAmount, @@ -106,8 +109,9 @@ class _HistoryItemState extends State { setState(() { _isRecovering = true; }); - final tradingEntitiesBloc = - RepositoryProvider.of(context); + final tradingEntitiesBloc = RepositoryProvider.of( + context, + ); await tradingEntitiesBloc.recoverFundsOfSwap(widget.swap.uuid); setState(() { _isRecovering = false; @@ -149,8 +153,9 @@ class _HistoryItemDesktop extends StatelessWidget { @override Widget build(BuildContext context) { - final tradingEntitiesBloc = - RepositoryProvider.of(context); + final tradingEntitiesBloc = RepositoryProvider.of( + context, + ); return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -169,7 +174,9 @@ class _HistoryItemDesktop extends StatelessWidget { Icons.check, size: 12, color: theme - .custom.dexPageTheme.successfulSwapStatusColor, + .custom + .dexPageTheme + .successfulSwapStatusColor, ) : Icon( Icons.circle, @@ -181,8 +188,10 @@ class _HistoryItemDesktop extends StatelessWidget { ? theme.custom.dexPageTheme.successfulSwapStatusColor : Theme.of(context).textTheme.bodyMedium?.color, backgroundColor: isSuccessful - ? theme.custom.dexPageTheme - .successfulSwapStatusBackgroundColor + ? theme + .custom + .dexPageTheme + .successfulSwapStatusBackgroundColor : Theme.of(context).colorScheme.surface, ), ), @@ -191,24 +200,15 @@ class _HistoryItemDesktop extends StatelessWidget { ), Expanded( key: Key('history-item-$uuid-sell-amount'), - child: TradeAmountDesktop( - coinAbbr: sellCoin, - amount: sellAmount, - ), + child: TradeAmountDesktop(coinAbbr: sellCoin, amount: sellAmount), ), Expanded( - child: TradeAmountDesktop( - coinAbbr: buyCoin, - amount: buyAmount, - ), + child: TradeAmountDesktop(coinAbbr: buyCoin, amount: buyAmount), ), Expanded( child: Text( formatAmt( - tradingEntitiesBloc.getPriceFromAmount( - sellAmount, - buyAmount, - ), + tradingEntitiesBloc.getPriceFromAmount(sellAmount, buyAmount), ), style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), ), @@ -216,10 +216,7 @@ class _HistoryItemDesktop extends StatelessWidget { Expanded( child: Text( date, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - ), + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), ), ), Expanded( @@ -252,8 +249,10 @@ class _HistoryItemDesktop extends StatelessWidget { color: Colors.orange, ) : null, - textStyle: - const TextStyle(color: Colors.white, fontSize: 12), + textStyle: const TextStyle( + color: Colors.white, + fontSize: 12, + ), onPressed: onRecoverPressed, ) : const SizedBox(width: 80), @@ -268,6 +267,7 @@ class _HistoryItemDesktop extends StatelessWidget { class _HistoryItemMobile extends StatelessWidget { const _HistoryItemMobile({ Key? key, + required this.swap, required this.uuid, required this.isRecovering, required this.sellCoin, @@ -278,6 +278,7 @@ class _HistoryItemMobile extends StatelessWidget { required this.date, required this.onRecoverPressed, }) : super(key: key); + final Swap swap; final String uuid; final bool isRecovering; final String sellCoin; @@ -295,9 +296,8 @@ class _HistoryItemMobile extends StatelessWidget { children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Flexible( + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -318,8 +318,11 @@ class _HistoryItemMobile extends StatelessWidget { ], ), ), - onRecoverPressed != null - ? UiLightButton( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (onRecoverPressed != null) + UiLightButton( width: 70, height: 22, prefix: isRecovering @@ -328,21 +331,23 @@ class _HistoryItemMobile extends StatelessWidget { backgroundColor: theme.custom.dexPageTheme.failedSwapStatusColor, text: isRecovering ? '' : LocaleKeys.recover.tr(), - textStyle: - const TextStyle(color: Colors.white, fontSize: 11), + textStyle: const TextStyle( + color: Colors.white, + fontSize: 11, + ), onPressed: onRecoverPressed, - ) - : const SizedBox(), + ), + if (onRecoverPressed != null) const SizedBox(width: 4), + SwapActionsMenu(swap: swap), + ], + ), ], ), Padding( padding: const EdgeInsets.only(top: 12), child: Text( LocaleKeys.receive.tr(), - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - ), + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w500), ), ), Padding( @@ -351,10 +356,7 @@ class _HistoryItemMobile extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( - child: CoinAmountMobile( - coinAbbr: buyCoin, - amount: buyAmount, - ), + child: CoinAmountMobile(coinAbbr: buyCoin, amount: buyAmount), ), Text( date, diff --git a/lib/views/dex/entities_list/history/history_list.dart b/lib/views/dex/entities_list/history/history_list.dart index aab661823c..050dd5bd98 100644 --- a/lib/views/dex/entities_list/history/history_list.dart +++ b/lib/views/dex/entities_list/history/history_list.dart @@ -48,6 +48,7 @@ class _HistoryListState extends State List _unprocessedSwaps = []; String? error; + bool _hasReceivedData = false; @override void initState() { super.initState(); @@ -61,6 +62,10 @@ class _HistoryListState extends State return const DexErrorMessage(); } + if (!_hasReceivedData) { + return const Center(child: UiSpinner(width: 26, height: 26)); + } + if (_processedSwaps.isEmpty) { return const DexEmptyList(); } @@ -69,10 +74,7 @@ class _HistoryListState extends State mainAxisSize: MainAxisSize.min, children: [ if (!isMobile) - HistoryListHeader( - sortData: _sortData, - onSortChange: _onSortChange, - ), + HistoryListHeader(sortData: _sortData, onSortChange: _onSortChange), Expanded( child: Padding( padding: EdgeInsets.only(top: isMobile ? 0 : 10.0), @@ -109,28 +111,25 @@ class _HistoryListState extends State } StreamSubscription> listenForSwaps() { - final tradingEntitiesBloc = - RepositoryProvider.of(context); - return tradingEntitiesBloc.outSwaps.where((swaps) { - final didSwapsChange = !areSwapsSame(swaps, _unprocessedSwaps); - - _unprocessedSwaps = swaps; - - return didSwapsChange; - }).listen( - _processSwapFilters, - onError: (e) { - setState(() => error = e.toString()); - }, - cancelOnError: false, + final tradingEntitiesBloc = RepositoryProvider.of( + context, ); - } - - /// Clears the error message and triggers rebuild only if there was an error. - void clearErrorIfExists() { - if (error != null) { - setState(() => error = null); - } + return tradingEntitiesBloc.outSwaps + .where((swaps) { + final didSwapsChange = + !_hasReceivedData || !areSwapsSame(swaps, _unprocessedSwaps); + + _unprocessedSwaps = swaps; + + return didSwapsChange; + }) + .listen( + _processSwapFilters, + onError: (e) { + setState(() => error = e.toString()); + }, + cancelOnError: false, + ); } void _processSwapFilters(List swaps) { @@ -147,7 +146,8 @@ class _HistoryListState extends State : completedSwaps.toList(); setState(() { - clearErrorIfExists(); + error = null; + _hasReceivedData = true; _processedSwaps = sortSwaps(context, filteredSwaps, sortData: _sortData); }); } @@ -156,7 +156,8 @@ class _HistoryListState extends State void didUpdateWidget(covariant HistoryList oldWidget) { super.didUpdateWidget(oldWidget); - final didFiltersChange = oldWidget.filter != widget.filter || + final didFiltersChange = + oldWidget.filter != widget.filter || oldWidget.entitiesFilterData != widget.entitiesFilterData; if (didFiltersChange) { diff --git a/lib/views/dex/entities_list/history/swap_history_sort_mixin.dart b/lib/views/dex/entities_list/history/swap_history_sort_mixin.dart index 25ef855fed..8489134f22 100644 --- a/lib/views/dex/entities_list/history/swap_history_sort_mixin.dart +++ b/lib/views/dex/entities_list/history/swap_history_sort_mixin.dart @@ -41,23 +41,21 @@ mixin SwapHistorySortingMixin { } List _sortByStatus(List swaps, SortDirection sortDirection) { + if (sortDirection == SortDirection.none) { + return swaps; + } + + int statusRank(Swap swap) => swap.isFailed ? 1 : 0; + swaps.sort((first, second) { - switch (sortDirection) { - case SortDirection.increase: - if (first.isFailed) { - return second.isFailed ? -1 : 1; - } else { - return second.isFailed ? 1 : -1; - } - case SortDirection.decrease: - if (first.isCompleted) { - return second.isCompleted ? -1 : 1; - } else { - return second.isCompleted ? 1 : -1; - } - case SortDirection.none: - return -1; + final firstRank = statusRank(first); + final secondRank = statusRank(second); + + if (sortDirection == SortDirection.increase) { + return firstRank.compareTo(secondRank); } + + return secondRank.compareTo(firstRank); }); return swaps; } @@ -92,8 +90,9 @@ mixin SwapHistorySortingMixin { List swaps, { required SortDirection sortDirection, }) { - final tradingEntitiesBloc = - RepositoryProvider.of(context); + final tradingEntitiesBloc = RepositoryProvider.of( + context, + ); swaps.sort( (first, second) => sortByDouble( tradingEntitiesBloc.getPriceFromAmount( @@ -129,11 +128,8 @@ mixin SwapHistorySortingMixin { required SortDirection sortDirection, }) { swaps.sort( - (first, second) => sortByBool( - first.isTaker, - second.isTaker, - sortDirection, - ), + (first, second) => + sortByBool(first.isTaker, second.isTaker, sortDirection), ); return swaps; } diff --git a/lib/views/dex/entities_list/in_progress/in_progress_item.dart b/lib/views/dex/entities_list/in_progress/in_progress_item.dart index a42a4aa728..c19b1af9c9 100644 --- a/lib/views/dex/entities_list/in_progress/in_progress_item.dart +++ b/lib/views/dex/entities_list/in_progress/in_progress_item.dart @@ -13,11 +13,12 @@ import 'package:web_dex/shared/widgets/focusable_widget.dart'; import 'package:web_dex/views/dex/entities_list/common/buy_price_mobile.dart'; import 'package:web_dex/views/dex/entities_list/common/coin_amount_mobile.dart'; import 'package:web_dex/views/dex/entities_list/common/entity_item_status_wrapper.dart'; +import 'package:web_dex/views/dex/entities_list/common/swap_actions_menu.dart'; import 'package:web_dex/views/dex/entities_list/common/trade_amount_desktop.dart'; class InProgressItem extends StatelessWidget { const InProgressItem(this.swap, {Key? key, required this.onClick}) - : super(key: key); + : super(key: key); final Swap swap; final VoidCallback onClick; @@ -27,11 +28,13 @@ class InProgressItem extends StatelessWidget { final Rational sellAmount = swap.sellAmount; final String buyCoin = swap.buyCoin; final Rational buyAmount = swap.buyAmount; - final String date = - swap.myInfo != null ? getFormattedDate(swap.myInfo!.startedAt) : '-'; + final String date = swap.myInfo != null + ? getFormattedDate(swap.myInfo!.startedAt) + : '-'; final bool isTaker = swap.isTaker; - final tradingEntitiesBloc = - RepositoryProvider.of(context); + final tradingEntitiesBloc = RepositoryProvider.of( + context, + ); final String typeText = tradingEntitiesBloc.getTypeString(isTaker); return Column( @@ -58,6 +61,7 @@ class InProgressItem extends StatelessWidget { ), child: isMobile ? _InProgressItemMobile( + swap: swap, buyAmount: buyAmount, buyCoin: buyCoin, date: date, @@ -113,8 +117,9 @@ class _InProgressItemDesktop extends StatelessWidget { @override Widget build(BuildContext context) { - final tradingEntitiesBloc = - RepositoryProvider.of(context); + final tradingEntitiesBloc = RepositoryProvider.of( + context, + ); return Row( mainAxisSize: MainAxisSize.max, children: [ @@ -144,35 +149,35 @@ class _InProgressItemDesktop extends StatelessWidget { ), ), Expanded( - child: TradeAmountDesktop(coinAbbr: sellCoin, amount: sellAmount)), + child: TradeAmountDesktop(coinAbbr: sellCoin, amount: sellAmount), + ), Expanded( child: TradeAmountDesktop(coinAbbr: buyCoin, amount: buyAmount), ), Expanded( child: Text( - formatDexAmt(tradingEntitiesBloc.getPriceFromAmount( - sellAmount, - buyAmount, - )), - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - )), + formatDexAmt( + tradingEntitiesBloc.getPriceFromAmount(sellAmount, buyAmount), + ), + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + ), ), Expanded( - child: Text(date, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - ))), + child: Text( + date, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + ), + ), Expanded( flex: 0, - child: Text(typeText, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: protocolColor, - )), + child: Text( + typeText, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: protocolColor, + ), + ), ), ], ); @@ -182,6 +187,7 @@ class _InProgressItemDesktop extends StatelessWidget { class _InProgressItemMobile extends StatelessWidget { const _InProgressItemMobile({ Key? key, + required this.swap, required this.sellCoin, required this.sellAmount, required this.buyCoin, @@ -190,6 +196,7 @@ class _InProgressItemMobile extends StatelessWidget { required this.statusStep, required this.date, }) : super(key: key); + final Swap swap; final String sellCoin; final Rational sellAmount; final String buyCoin; @@ -207,7 +214,7 @@ class _InProgressItemMobile extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.max, children: [ - Flexible( + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -228,10 +235,17 @@ class _InProgressItemMobile extends StatelessWidget { ], ), ), - BuyPriceMobile( - buyCoin: buyCoin, - buyAmount: buyAmount, - sellAmount: sellAmount, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + BuyPriceMobile( + buyCoin: buyCoin, + buyAmount: buyAmount, + sellAmount: sellAmount, + ), + const SizedBox(width: 4), + SwapActionsMenu(swap: swap), + ], ), ], ), @@ -239,10 +253,7 @@ class _InProgressItemMobile extends StatelessWidget { padding: const EdgeInsets.only(top: 12), child: Text( LocaleKeys.receive.tr(), - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - ), + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w500), ), ), Padding( @@ -252,10 +263,7 @@ class _InProgressItemMobile extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( - child: CoinAmountMobile( - coinAbbr: buyCoin, - amount: buyAmount, - ), + child: CoinAmountMobile(coinAbbr: buyCoin, amount: buyAmount), ), Text( date, @@ -272,8 +280,9 @@ class _InProgressItemMobile extends StatelessWidget { padding: const EdgeInsets.fromLTRB(6, 12, 6, 12), width: double.infinity, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(9), - color: Theme.of(context).colorScheme.onSurface), + borderRadius: BorderRadius.circular(9), + color: Theme.of(context).colorScheme.onSurface, + ), child: Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/views/dex/entities_list/orders/order_item.dart b/lib/views/dex/entities_list/orders/order_item.dart index 78efe2bc40..338d8a594b 100644 --- a/lib/views/dex/entities_list/orders/order_item.dart +++ b/lib/views/dex/entities_list/orders/order_item.dart @@ -9,6 +9,7 @@ import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/my_orders/my_order.dart'; import 'package:web_dex/router/state/routing_state.dart'; import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/widgets/focusable_widget.dart'; import 'package:web_dex/views/dex/entities_list/common/buy_price_mobile.dart'; import 'package:web_dex/views/dex/entities_list/common/coin_amount_mobile.dart'; @@ -36,8 +37,9 @@ class _OrderItemState extends State { final bool isTaker = order.orderType == TradeSide.taker; final String date = getFormattedDate(order.createdAt); final int orderMatchingTime = order.orderMatchingTime; - final tradingEntitiesBloc = - RepositoryProvider.of(context); + final tradingEntitiesBloc = RepositoryProvider.of( + context, + ); final double fillProgress = tradingEntitiesBloc.getProgressFillSwap(order); return Column( @@ -66,6 +68,7 @@ class _OrderItemState extends State { ), child: isMobile ? _OrderItemMobile( + uuid: order.uuid, buyAmount: buyAmount, buyCoin: buyCoin, sellCoin: sellCoin, @@ -122,8 +125,9 @@ class _OrderItemDesktop extends StatelessWidget { @override Widget build(BuildContext context) { - final tradingEntitiesBloc = - RepositoryProvider.of(context); + final tradingEntitiesBloc = RepositoryProvider.of( + context, + ); return Row( mainAxisSize: MainAxisSize.max, children: [ @@ -136,24 +140,15 @@ class _OrderItemDesktop extends StatelessWidget { Expanded( child: Text( formatAmt( - tradingEntitiesBloc.getPriceFromAmount( - sellAmount, - buyAmount, - ), - ), - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, + tradingEntitiesBloc.getPriceFromAmount(sellAmount, buyAmount), ), + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), ), ), Expanded( child: Text( date, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - ), + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), ), ), Expanded( @@ -199,9 +194,7 @@ class _OrderItemDesktop extends StatelessWidget { ? Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, - children: [ - ...actions, - ], + children: [...actions], ) : const SizedBox(width: 80), ), @@ -211,10 +204,7 @@ class _OrderItemDesktop extends StatelessWidget { } class _FillPainter extends CustomPainter { - _FillPainter({ - required this.context, - required this.fillProgress, - }); + _FillPainter({required this.context, required this.fillProgress}); final BuildContext context; final double fillProgress; @@ -254,6 +244,7 @@ class _FillPainter extends CustomPainter { class _OrderItemMobile extends StatelessWidget { const _OrderItemMobile({ + required this.uuid, required this.buyCoin, required this.buyAmount, required this.sellCoin, @@ -265,6 +256,7 @@ class _OrderItemMobile extends StatelessWidget { this.actions = const [], }); + final String uuid; final String buyCoin; final Rational buyAmount; final String sellCoin; @@ -351,6 +343,8 @@ class _OrderItemMobile extends StatelessWidget { ), ), ...actions, + const SizedBox(width: 4), + _OrderActionsMenu(uuid: uuid), ], ), ), @@ -379,8 +373,9 @@ class _OrderItemMobile extends StatelessWidget { Padding( padding: const EdgeInsets.only(left: 10.0), child: Text( - LocaleKeys.percentFilled - .tr(args: [(fillProgress * 100).toStringAsFixed(0)]), + LocaleKeys.percentFilled.tr( + args: [(fillProgress * 100).toStringAsFixed(0)], + ), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -394,3 +389,34 @@ class _OrderItemMobile extends StatelessWidget { ); } } + +class _OrderActionsMenu extends StatelessWidget { + const _OrderActionsMenu({required this.uuid}); + + final String uuid; + + @override + Widget build(BuildContext context) { + return PopupMenuButton<_OrderAction>( + icon: const Icon(Icons.more_vert, size: 18), + onSelected: (action) { + switch (action) { + case _OrderAction.copyUuid: + copyToClipBoard( + context, + uuid, + LocaleKeys.copiedUuidToClipboard.tr(), + ); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: _OrderAction.copyUuid, + child: Text(LocaleKeys.copyUuid.tr()), + ), + ], + ); + } +} + +enum _OrderAction { copyUuid } diff --git a/lib/views/dex/entity_details/swap/swap_details_page.dart b/lib/views/dex/entity_details/swap/swap_details_page.dart index dbe2a50a35..88d14e0ed0 100644 --- a/lib/views/dex/entity_details/swap/swap_details_page.dart +++ b/lib/views/dex/entity_details/swap/swap_details_page.dart @@ -1,17 +1,13 @@ -import 'dart:convert'; import 'dart:math'; import 'package:app_theme/app_theme.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/swap.dart'; -import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/my_swap_status/my_swap_status_req.dart'; -import 'package:web_dex/services/file_loader/file_loader.dart'; +import 'package:web_dex/shared/utils/swap_export.dart'; import 'package:web_dex/views/dex/entity_details/swap/swap_details.dart'; import 'package:web_dex/views/dex/entity_details/trading_details_header.dart'; import 'package:web_dex/views/dex/entity_details/trading_progress_status.dart'; @@ -31,15 +27,7 @@ class _SwapDetailsPageState extends State { Future _exportSwapData() async { setState(() => _isExporting = true); try { - final mm2Api = RepositoryProvider.of(context); - final response = - await mm2Api.getSwapStatus(MySwapStatusReq(uuid: widget.swapStatus.uuid)); - final jsonStr = jsonEncode(response); - await FileLoader.fromPlatform().save( - fileName: 'swap_${widget.swapStatus.uuid}.json', - data: jsonStr, - type: LoadFileType.text, - ); + await exportSwapData(context, widget.swapStatus.uuid); } finally { if (mounted) setState(() => _isExporting = false); } @@ -82,7 +70,8 @@ class _SwapDetailsPageState extends State { if (_isFailed) return LocaleKeys.tradingDetailsTitleFailed.tr(); final haveEvents = widget.swapStatus.events.isNotEmpty; if (haveEvents) { - final isSuccess = widget.swapStatus.events.last.event.type == + final isSuccess = + widget.swapStatus.events.last.event.type == widget.swapStatus.successEvents.last; if (isSuccess) return LocaleKeys.tradingDetailsTitleCompleted.tr(); return LocaleKeys.tradingDetailsTitleInProgress.tr(); @@ -92,17 +81,18 @@ class _SwapDetailsPageState extends State { bool get _isFailed { return widget.swapStatus.events.firstWhereOrNull( - (event) => - widget.swapStatus.errorEvents.contains(event.event.type)) != + (event) => widget.swapStatus.errorEvents.contains(event.event.type), + ) != null; } int get _progress { return min( - 100, - (100 * - widget.swapStatus.events.length / - (widget.swapStatus.successEvents.length - 1)) - .ceil()); + 100, + (100 * + widget.swapStatus.events.length / + (widget.swapStatus.successEvents.length - 1)) + .ceil(), + ); } } diff --git a/lib/views/dex/entity_details/swap/swap_details_step.dart b/lib/views/dex/entity_details/swap/swap_details_step.dart index bd8868157b..812ee05390 100644 --- a/lib/views/dex/entity_details/swap/swap_details_step.dart +++ b/lib/views/dex/entity_details/swap/swap_details_step.dart @@ -63,103 +63,112 @@ class SwapDetailsStep extends StatelessWidget { final String? txHash = this.txHash; final Coin? coin = this.coin; - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - children: [ - Column( - children: [ - Container( - width: 20, - height: 20, - decoration: - BoxDecoration(color: _circleColor, shape: BoxShape.circle), - child: Padding( - padding: const EdgeInsets.all(2), - child: DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isProcessedStep || isFailedStep - ? Colors.transparent - : themeData.colorScheme.surface, - ), - ), - ), - ), - if (!isLastStep) - Container( - height: 40, - width: 1, - color: isProcessedStep - ? theme.custom.progressBarPassedColor - : themeData.textTheme.bodyMedium?.color - ?.withValues(alpha: 0.3) ?? - Colors.transparent, - ), - ], - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Column( children: [ - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: AutoScrollText( - text: event, - style: TextStyle( - color: _textColor, - fontSize: 14, - fontWeight: FontWeight.w700, - ), + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: _circleColor, + shape: BoxShape.circle, + ), + child: Padding( + padding: const EdgeInsets.all(2), + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isProcessedStep || isFailedStep + ? Colors.transparent + : themeData.colorScheme.surface, ), ), - Padding( - padding: const EdgeInsets.only(left: 4.0), - child: _buildAdditionalInfo(context), - ), - ], + ), ), - if (txHash != null && coin != null) + if (!isLastStep) + Container( + height: 40, + width: 1, + color: isProcessedStep + ? theme.custom.progressBarPassedColor + : themeData.textTheme.bodyMedium?.color?.withValues( + alpha: 0.3, + ) ?? + Colors.transparent, + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Flexible( - child: CopiedText( - copiedValue: txHash, - padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), - isTruncated: true, - fontSize: 11, - iconSize: 14, - backgroundColor: - theme.custom.specificButtonBackgroundColor, + Expanded( + child: AutoScrollText( + text: event, + style: TextStyle( + color: _textColor, + fontSize: 14, + fontWeight: FontWeight.w700, + ), ), ), Padding( - padding: const EdgeInsets.only(left: 6.0, right: 10), - child: Material( - child: Tooltip( - message: LocaleKeys.viewOnExplorer.tr(), - child: InkWell( - child: const Icon( - Icons.open_in_browser, - size: 20, + padding: const EdgeInsets.only(left: 4.0), + child: _buildAdditionalInfo(context), + ), + ], + ), + if (txHash != null && coin != null) ...[ + const SizedBox(height: 6), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: CopiedText( + copiedValue: txHash, + padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + isTruncated: true, + fontSize: 11, + iconSize: 14, + backgroundColor: + theme.custom.specificButtonBackgroundColor, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 6.0, right: 10), + child: Material( + child: Tooltip( + message: LocaleKeys.viewOnExplorer.tr(), + child: InkWell( + child: const Icon( + Icons.open_in_browser, + size: 20, + ), + onTap: () => launchURLString( + getTxExplorerUrl(coin, txHash), + ), ), - onTap: () => - launchURLString(getTxExplorerUrl(coin, txHash)), ), ), ), - ), - ], - ), - ], + ], + ), + ], + ], + ), ), - ), - ], + ], + ), ); } diff --git a/lib/views/dex/entity_details/trading_details.dart b/lib/views/dex/entity_details/trading_details.dart index 21f22bb201..9899796564 100644 --- a/lib/views/dex/entity_details/trading_details.dart +++ b/lib/views/dex/entity_details/trading_details.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/dex_repository.dart'; import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; @@ -11,7 +12,6 @@ import 'package:web_dex/analytics/events/cross_chain_events.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/common/screen.dart'; import 'package:web_dex/model/swap.dart'; -import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/shared/utils/extensions/kdf_user_extensions.dart'; import 'package:web_dex/services/orders_service/my_orders_service.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -20,13 +20,14 @@ import 'package:web_dex/views/dex/entity_details/swap/swap_details_page.dart'; import 'package:web_dex/views/dex/entity_details/taker_order/taker_order_details_page.dart'; /// Distinguishes what entity the uuid represents -enum TradingEntityKind { - order, - swap, -} +enum TradingEntityKind { order, swap } class TradingDetails extends StatefulWidget { - const TradingDetails({super.key, required this.uuid, this.kind = TradingEntityKind.swap}); + const TradingDetails({ + super.key, + required this.uuid, + this.kind = TradingEntityKind.swap, + }); final String uuid; final TradingEntityKind kind; @@ -36,31 +37,109 @@ class TradingDetails extends StatefulWidget { } class _TradingDetailsState extends State { - late Timer _statusTimer; + Timer? _statusTimer; + StreamSubscription? _swapStatusSubscription; + StreamSubscription? _orderStatusSubscription; + Swap? _swapStatus; OrderStatus? _orderStatus; + bool _statusUpdateInProgress = false; + DateTime? _lastStatusUpdateAt; bool _loggedSuccess = false; bool _loggedFailure = false; @override void initState() { + super.initState(); + final myOrdersService = RepositoryProvider.of(context); final dexRepository = RepositoryProvider.of(context); + final sdk = RepositoryProvider.of(context); - _statusTimer = Timer.periodic(const Duration(seconds: 1), (_) { - _updateStatus(dexRepository, myOrdersService); + _statusTimer = Timer.periodic(const Duration(seconds: 10), (_) { + _scheduleStatusUpdate(dexRepository, myOrdersService); }); - super.initState(); + _scheduleStatusUpdate(dexRepository, myOrdersService, force: true); + _initStreaming(sdk, dexRepository, myOrdersService).ignore(); } @override void dispose() { - _statusTimer.cancel(); + _statusTimer?.cancel(); + _swapStatusSubscription?.cancel(); + _orderStatusSubscription?.cancel(); super.dispose(); } + Future _initStreaming( + KomodoDefiSdk sdk, + DexRepository dexRepository, + MyOrdersService myOrdersService, + ) async { + try { + if (widget.kind == TradingEntityKind.swap) { + final subscription = await sdk.subscribeToSwapStatus(); + if (!mounted) { + await subscription.cancel(); + return; + } + + _swapStatusSubscription = subscription; + _swapStatusSubscription?.onData((event) { + if (event.uuid != widget.uuid) return; + _scheduleStatusUpdate(dexRepository, myOrdersService); + }); + } else { + final subscription = await sdk.subscribeToOrderStatus(); + if (!mounted) { + await subscription.cancel(); + return; + } + + _orderStatusSubscription = subscription; + _orderStatusSubscription?.onData((event) { + if (event.uuid != widget.uuid) return; + _scheduleStatusUpdate(dexRepository, myOrdersService); + }); + } + } catch (e, s) { + log( + 'Failed to initialize trading details stream for ${widget.kind}', + path: 'TradingDetails._initStreaming', + trace: s, + isError: true, + ); + } + } + + void _scheduleStatusUpdate( + DexRepository dexRepository, + MyOrdersService myOrdersService, { + bool force = false, + }) { + if (_statusUpdateInProgress) return; + + final lastUpdateAt = _lastStatusUpdateAt; + if (!force && + lastUpdateAt != null && + DateTime.now().difference(lastUpdateAt) < + const Duration(milliseconds: 500)) { + return; + } + + _statusUpdateInProgress = true; + () async { + try { + await _updateStatus(dexRepository, myOrdersService); + _lastStatusUpdateAt = DateTime.now(); + } finally { + _statusUpdateInProgress = false; + } + }().ignore(); + } + @override Widget build(BuildContext context) { final dynamic entityStatus = @@ -105,8 +184,8 @@ class _TradingDetailsState extends State { DexRepository dexRepository, MyOrdersService myOrdersService, ) async { - Swap? swapStatus = null; - OrderStatus? orderStatus = null; + Swap? swapStatus; + OrderStatus? orderStatus; try { if (widget.kind == TradingEntityKind.swap) { swapStatus = await dexRepository.getSwapStatus(widget.uuid); @@ -116,7 +195,8 @@ class _TradingDetailsState extends State { } catch (e, s) { log( e.toString(), - path: 'trading_details =>_updateStatus ${widget.kind} error | uuid=${widget.uuid}', + path: + 'trading_details =>_updateStatus ${widget.kind} error | uuid=${widget.uuid}', trace: s, isError: true, ); diff --git a/lib/views/dex/simple/form/maker/maker_form_trade_button.dart b/lib/views/dex/simple/form/maker/maker_form_trade_button.dart index df31afbc5e..8396c1e4ce 100644 --- a/lib/views/dex/simple/form/maker/maker_form_trade_button.dart +++ b/lib/views/dex/simple/form/maker/maker_form_trade_button.dart @@ -66,6 +66,24 @@ class MakerFormTradeButton extends StatelessWidget { ), ) : null, + child: _DexTradeButtonContent( + text: isTradingEnabled + ? LocaleKeys.makeOrder.tr() + : LocaleKeys.tradingDisabled.tr(), + prefix: inProgress + ? Padding( + padding: const EdgeInsets.only(right: 4), + child: UiSpinner( + width: 10, + height: 10, + strokeWidth: 1, + color: theme + .custom + .defaultGradientButtonTextColor, + ), + ) + : null, + ), onPressed: disabled || !isTradingEnabled ? null : () async { @@ -93,3 +111,26 @@ class MakerFormTradeButton extends StatelessWidget { ); } } + +class _DexTradeButtonContent extends StatelessWidget { + const _DexTradeButtonContent({required this.text, this.prefix}); + + final String text; + final Widget? prefix; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (prefix != null) prefix!, + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text(text, maxLines: 1, textAlign: TextAlign.center), + ), + ), + ], + ); + } +} diff --git a/lib/views/dex/simple/form/tables/coins_table/coins_table.dart b/lib/views/dex/simple/form/tables/coins_table/coins_table.dart index 04c6d392e1..2f9db94310 100644 --- a/lib/views/dex/simple/form/tables/coins_table/coins_table.dart +++ b/lib/views/dex/simple/form/tables/coins_table/coins_table.dart @@ -10,8 +10,8 @@ class CoinsTable extends StatefulWidget { required this.onSelect, this.maxHeight = 300, this.head, - Key? key, - }) : super(key: key); + super.key, + }); final Function(Coin) onSelect; final Widget? head; @@ -22,13 +22,17 @@ class CoinsTable extends StatefulWidget { } class _CoinsTableState extends State { - String? _searchTerm; + String _searchTerm = ''; late final Debouncer _searchDebouncer; + late final TextEditingController _searchController; + late final FocusNode _searchFocusNode; @override void initState() { super.initState(); _searchDebouncer = Debouncer(duration: const Duration(milliseconds: 200)); + _searchController = TextEditingController(); + _searchFocusNode = FocusNode(); } @override @@ -44,15 +48,10 @@ class _CoinsTableState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: TableSearchField( + controller: _searchController, + focusNode: _searchFocusNode, height: 30, - onChanged: (String value) { - final nextValue = value; - _searchDebouncer.run(() { - if (!mounted) return; - if (_searchTerm == nextValue) return; - setState(() => _searchTerm = nextValue); - }); - }, + onChanged: (_) => _searchDebouncer.run(_updateSearchTerm), ), ), const SizedBox(height: 5), @@ -70,6 +69,21 @@ class _CoinsTableState extends State { @override void dispose() { _searchDebouncer.dispose(); + _searchController.dispose(); + _searchFocusNode.dispose(); super.dispose(); } + + void _updateSearchTerm() { + if (!mounted) { + return; + } + + final nextValue = _searchController.text; + if (_searchTerm == nextValue) { + return; + } + + setState(() => _searchTerm = nextValue); + } } diff --git a/lib/views/dex/simple/form/tables/coins_table/coins_table_item.dart b/lib/views/dex/simple/form/tables/coins_table/coins_table_item.dart index 4aca3a3cfc..2f5fb8b85f 100644 --- a/lib/views/dex/simple/form/tables/coins_table/coins_table_item.dart +++ b/lib/views/dex/simple/form/tables/coins_table/coins_table_item.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/common/screen.dart'; import 'package:web_dex/shared/widgets/coin_balance.dart'; import 'package:web_dex/shared/widgets/coin_item/coin_item.dart'; import 'package:web_dex/shared/widgets/coin_item/coin_item_size.dart'; @@ -25,22 +26,39 @@ class CoinsTableItem extends StatelessWidget { @override Widget build(BuildContext context) { + final bool isMobileLayout = isMobile; + final CoinItemSize itemSize = isMobileLayout + ? CoinItemSize.medium + : CoinItemSize.large; + final double spacerWidth = isMobileLayout ? 6 : 8; + final BoxConstraints trailingConstraints = BoxConstraints( + minWidth: isMobileLayout ? 90 : 110, + maxWidth: isMobileLayout ? 120 : 160, + ); final child = ItemDecoration( child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.max, children: [ - CoinItem( - coin: coin, - size: CoinItemSize.large, - subtitleText: subtitleText, - showNetworkLogo: !isGroupHeader, + Expanded( + child: CoinItem( + coin: coin, + size: itemSize, + subtitleText: subtitleText, + showNetworkLogo: !isGroupHeader, + ), + ), + SizedBox(width: spacerWidth), + ConstrainedBox( + constraints: trailingConstraints, + child: Align( + alignment: Alignment.centerRight, + child: + trailing ?? + (coin.isActive + ? CoinBalance(coin: coin, isVertical: true) + : const SizedBox.shrink()), + ), ), - const SizedBox(width: 8), - if (trailing != null) - trailing! - else if (coin.isActive) - CoinBalance(coin: coin, isVertical: true), ], ), ); diff --git a/lib/views/dex/simple/form/tables/table_search_field.dart b/lib/views/dex/simple/form/tables/table_search_field.dart index 1af2589819..efab229976 100644 --- a/lib/views/dex/simple/form/tables/table_search_field.dart +++ b/lib/views/dex/simple/form/tables/table_search_field.dart @@ -4,20 +4,30 @@ import 'package:web_dex/common/screen.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; class TableSearchField extends StatelessWidget { - const TableSearchField({Key? key, required this.onChanged, this.height = 44}) - : super(key: key); - final Function(String) onChanged; + const TableSearchField({ + super.key, + required this.onChanged, + this.height = 44, + this.controller, + this.focusNode, + }); + final ValueChanged onChanged; final double height; + final TextEditingController? controller; + final FocusNode? focusNode; @override Widget build(BuildContext context) { - final style = - Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 12); + final style = Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontSize: 12); return SizedBox( height: height, child: TextField( key: const Key('search-field'), + controller: controller, + focusNode: focusNode, onChanged: onChanged, autofocus: isDesktop, decoration: InputDecoration( diff --git a/lib/views/dex/simple/form/taker/available_balance.dart b/lib/views/dex/simple/form/taker/available_balance.dart index 9f1a24ad9d..34ab2bc090 100644 --- a/lib/views/dex/simple/form/taker/available_balance.dart +++ b/lib/views/dex/simple/form/taker/available_balance.dart @@ -1,4 +1,3 @@ -import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:rational/rational.dart'; @@ -10,13 +9,26 @@ import 'package:komodo_ui_kit/komodo_ui_kit.dart'; class AvailableBalance extends StatelessWidget { const AvailableBalance(this.availableBalance, this.state, [Key? key]) - : super(key: key); + : super(key: key); final Rational? availableBalance; final AvailableBalanceState state; @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final labelStyle = + theme.textTheme.bodySmall?.copyWith( + fontSize: 12, + fontWeight: FontWeight.w400, + color: theme.colorScheme.onSurfaceVariant, + ) ?? + TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + color: theme.colorScheme.onSurfaceVariant, + ); + return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -24,20 +36,13 @@ class AvailableBalance extends StatelessWidget { isMobile ? LocaleKeys.available.tr() : LocaleKeys.availableForSwaps.tr(), - style: TextStyle( - color: dexPageColors.inactiveText, - fontSize: 12, - fontWeight: FontWeight.w400, - ), + style: labelStyle, ), const SizedBox(width: 8), Flexible( child: ConstrainedBox( constraints: const BoxConstraints(minWidth: 38), - child: _Balance( - availableBalance: availableBalance, - state: state, - ), + child: _Balance(availableBalance: availableBalance, state: state), ), ), ], @@ -60,11 +65,7 @@ class _Balance extends StatelessWidget { if (availableBalance == null) { return const Padding( padding: EdgeInsets.symmetric(horizontal: 13), - child: UiSpinner( - height: 12, - width: 12, - strokeWidth: 1.5, - ), + child: UiSpinner(height: 12, width: 12, strokeWidth: 1.5), ); } break; @@ -78,11 +79,17 @@ class _Balance extends StatelessWidget { return AutoScrollText( text: value, - style: TextStyle( - color: dexPageColors.activeText, - fontSize: 12, - fontWeight: FontWeight.w600, - ), + style: + Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ) ?? + TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), textAlign: TextAlign.end, ); } diff --git a/lib/views/dex/simple/form/taker/coin_item/coin_group_name.dart b/lib/views/dex/simple/form/taker/coin_item/coin_group_name.dart index 74f0aa9db7..eacde1f105 100644 --- a/lib/views/dex/simple/form/taker/coin_item/coin_group_name.dart +++ b/lib/views/dex/simple/form/taker/coin_item/coin_group_name.dart @@ -1,4 +1,3 @@ -import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:web_dex/common/app_assets.dart'; @@ -14,6 +13,17 @@ class CoinGroupName extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final textStyle = + theme.textTheme.bodyMedium?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + ) ?? + TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface, + ); final title = _getTitleFromCoinId(coin?.abbr); final chevron = opened ? const DexSvgImage( @@ -27,14 +37,7 @@ class CoinGroupName extends StatelessWidget { return Row( children: [ - Text( - title, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: dexPageColors.activeText, - ), - ), + Text(title, style: textStyle), const SizedBox(width: 4), chevron, ], diff --git a/lib/views/dex/simple/form/taker/taker_form_content.dart b/lib/views/dex/simple/form/taker/taker_form_content.dart index 3b9da98bba..62876c3902 100644 --- a/lib/views/dex/simple/form/taker/taker_form_content.dart +++ b/lib/views/dex/simple/form/taker/taker_form_content.dart @@ -88,10 +88,7 @@ class _FormControls extends StatelessWidget { child: ConnectWalletWrapper( key: const Key('connect-wallet-taker-form'), eventType: WalletsManagerEventType.dex, - buttonSize: Size( - 112, - isMobile ? 52 : 40, - ), + buttonSize: Size(112, isMobile ? 52 : 40), child: const TradeButton(), ), ), @@ -124,7 +121,7 @@ class TradeButton extends StatelessWidget { builder: (context, systemHealthState) { final bool isSystemClockValid = systemHealthState is SystemHealthLoadSuccess && - systemHealthState.isValid; + systemHealthState.isValid; final tradingStatusState = context.watch().state; final takerState = context.watch().state; @@ -151,10 +148,16 @@ class TradeButton extends StatelessWidget { ? LocaleKeys.swapNow.tr() : LocaleKeys.tradingDisabled.tr(), prefix: inProgress ? const TradeButtonSpinner() : null, + child: _DexTradeButtonContent( + text: isTradingEnabled + ? LocaleKeys.swapNow.tr() + : LocaleKeys.tradingDisabled.tr(), + prefix: inProgress ? const TradeButtonSpinner() : null, + ), onPressed: disabled || !isTradingEnabled ? null : () => - context.read().add(TakerFormSubmitClick()), + context.read().add(TakerFormSubmitClick()), height: isMobile ? 52 : 40, ), ); @@ -181,3 +184,26 @@ class TradeButtonSpinner extends StatelessWidget { ); } } + +class _DexTradeButtonContent extends StatelessWidget { + const _DexTradeButtonContent({required this.text, this.prefix}); + + final String text; + final Widget? prefix; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (prefix != null) prefix!, + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text(text, maxLines: 1, textAlign: TextAlign.center), + ), + ), + ], + ); + } +} diff --git a/lib/views/main_layout/main_layout.dart b/lib/views/main_layout/main_layout.dart index 8de47019d4..ac4fc3fb32 100644 --- a/lib/views/main_layout/main_layout.dart +++ b/lib/views/main_layout/main_layout.dart @@ -106,6 +106,7 @@ class _MainLayoutState extends State { : null, floatingActionButton: MainLayoutFab( showAddCoinButton: + isMobile && routingState.selectedMenu == MainMenuValue.wallet && routingState.walletState.selectedCoin.isEmpty && routingState.walletState.action.isEmpty && diff --git a/lib/views/main_layout/widgets/main_layout_top_bar.dart b/lib/views/main_layout/widgets/main_layout_top_bar.dart index 6466729310..c8b5ff33a7 100644 --- a/lib/views/main_layout/widgets/main_layout_top_bar.dart +++ b/lib/views/main_layout/widgets/main_layout_top_bar.dart @@ -4,11 +4,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/app_config/app_config.dart'; -import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; -import 'package:web_dex/model/coin.dart'; import 'package:web_dex/release_options.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/balance_utils.dart'; import 'package:web_dex/shared/utils/extensions/sdk_extensions.dart'; import 'package:web_dex/shared/utils/formatters.dart'; import 'package:web_dex/views/common/header/actions/account_switcher.dart'; @@ -18,6 +19,13 @@ class MainLayoutTopBar extends StatelessWidget { @override Widget build(BuildContext context) { + final hideBalances = context.select( + (SettingsBloc bloc) => bloc.state.hideBalances, + ); + final screenWidth = MediaQuery.of(context).size.width; + final bool isCompact = screenWidth < 1100; + final double horizontalPadding = isCompact ? 16 : 32; + final double leadingWidth = isCompact ? 160 : 200; return Container( decoration: BoxDecoration( border: Border( @@ -30,9 +38,10 @@ class MainLayoutTopBar extends StatelessWidget { elevation: 0, leading: BlocBuilder( builder: (context, state) { - final totalBalance = _getTotalBalance( - state.walletCoins.values, - context, + final totalBalance = computeWalletTotalUsd( + coins: state.walletCoins.values, + coinsState: state, + sdk: context.sdk, ); if (totalBalance == null) { @@ -40,44 +49,28 @@ class MainLayoutTopBar extends StatelessWidget { } return Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: ActionTextButton( text: LocaleKeys.balance.tr(), - secondaryText: '\$${formatAmt(totalBalance)}', + secondaryText: hideBalances + ? '\$$maskedBalanceText' + : '\$${formatAmt(totalBalance)}', onTap: null, ), ); }, ), - leadingWidth: 200, - actions: _getHeaderActions(context), + leadingWidth: leadingWidth, + actions: _getHeaderActions(context, horizontalPadding), titleSpacing: 0, ), ); } - double? _getTotalBalance(Iterable coins, BuildContext context) { - bool hasAnyUsdBalance = coins.any( - (coin) => coin.usdBalance(context.sdk) != null, - ); - - if (!hasAnyUsdBalance) { - return null; - } - - double total = coins.fold( - 0, - (prev, coin) => prev + (coin.usdBalance(context.sdk) ?? 0), - ); - - if (total > 0.01) { - return total; - } - - return total != 0 ? 0.01 : 0; - } - - List _getHeaderActions(BuildContext context) { + List _getHeaderActions( + BuildContext context, + double horizontalPadding, + ) { final languageCodes = localeList.map((e) => e.languageCode).toList(); final langCode2flags = { for (var loc in languageCodes) @@ -86,7 +79,7 @@ class MainLayoutTopBar extends StatelessWidget { return [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ diff --git a/lib/views/market_maker_bot/coin_search_dropdown.dart b/lib/views/market_maker_bot/coin_search_dropdown.dart index 5c8e1a5138..871a059472 100644 --- a/lib/views/market_maker_bot/coin_search_dropdown.dart +++ b/lib/views/market_maker_bot/coin_search_dropdown.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_ui/komodo_ui.dart'; @@ -83,10 +84,19 @@ class _CoinDropdownState extends State { OverlayEntry? _overlayEntry; void _showSearch() async { + if (_overlayEntry != null) { + _removeOverlay(); + return; + } _overlayEntry = _createOverlayEntry(); Overlay.of(context).insert(_overlayEntry!); } + void _removeOverlay() { + _overlayEntry?.remove(); + _overlayEntry = null; + } + OverlayEntry _createOverlayEntry() { final renderBox = context.findRenderObject() as RenderBox; final size = renderBox.size; @@ -109,30 +119,37 @@ class _CoinDropdownState extends State { return OverlayEntry( builder: (context) { return GestureDetector( - onTap: () { - _overlayEntry?.remove(); - _overlayEntry = null; - }, + onTap: _removeOverlay, behavior: HitTestBehavior.translucent, child: Stack( children: [ - Positioned( - left: offset.dx, - top: showAbove - ? offset.dy - dropdownHeight - : offset.dy + size.height, - width: size.width, - child: _SearchableDropdown( - items: widget.items, - onItemSelected: (value) { - if (value != null) { - setState(() => selectedItem = value); - widget.onItemSelected(value); + Positioned.fill( + child: Listener( + onPointerSignal: (event) { + if (event is PointerScrollEvent) { + _removeOverlay(); } - _overlayEntry?.remove(); - _overlayEntry = null; }, - maxHeight: dropdownHeight, + child: const SizedBox.expand(), + ), + ), + CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: Offset(0, showAbove ? -dropdownHeight : size.height), + child: SizedBox( + width: size.width, + child: _SearchableDropdown( + items: widget.items, + onItemSelected: (value) { + if (value != null) { + setState(() => selectedItem = value); + widget.onItemSelected(value); + } + _removeOverlay(); + }, + maxHeight: dropdownHeight, + ), ), ), ], @@ -210,10 +227,13 @@ class _SearchableDropdownState extends State<_SearchableDropdown> { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return Card( elevation: 4, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - color: Theme.of(context).colorScheme.surfaceContainer, + color: colorScheme.surfaceContainer, child: Container( constraints: BoxConstraints(maxHeight: widget.maxHeight), child: Column( @@ -226,10 +246,26 @@ class _SearchableDropdownState extends State<_SearchableDropdown> { autofocus: true, decoration: InputDecoration( hintText: 'Search', + hintStyle: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + filled: true, + fillColor: colorScheme.surfaceContainerHighest, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), - prefixIcon: const Icon(Icons.search), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: colorScheme.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: colorScheme.primary), + ), + prefixIcon: Icon( + Icons.search, + color: colorScheme.onSurfaceVariant, + ), ), onChanged: updateSearchQuery, ), diff --git a/lib/views/market_maker_bot/market_maker_bot_form.dart b/lib/views/market_maker_bot/market_maker_bot_form.dart index 3f27e1190c..8cc8971539 100644 --- a/lib/views/market_maker_bot/market_maker_bot_form.dart +++ b/lib/views/market_maker_bot/market_maker_bot_form.dart @@ -10,6 +10,7 @@ import 'package:web_dex/bloc/market_maker_bot/market_maker_trade_form/market_mak import 'package:web_dex/common/screen.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/orderbook/order.dart'; +import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/views/dex/orderbook/orderbook_view.dart'; import 'package:web_dex/views/market_maker_bot/market_maker_bot_confirmation_form.dart'; import 'package:web_dex/views/market_maker_bot/market_maker_bot_form_content.dart'; @@ -109,14 +110,26 @@ class _MakerFormDesktopLayoutState extends State<_MakerFormDesktopLayout> { ), child: BlocBuilder( builder: (context, state) { - final coins = state.walletCoins.values - .where((e) { - final usdPrice = e.usdPrice?.price?.toDouble() ?? 0.0; + final buyCoins = state.coins.values + .where((coin) { + final usdPrice = + coin.lastKnownUsdPrice(context.sdk) ?? 0.0; return usdPrice > 0; }) .cast() .toList(); - return MarketMakerBotFormContent(coins: coins); + final sellCoins = state.walletCoins.values + .where((coin) { + final usdPrice = + coin.lastKnownUsdPrice(context.sdk) ?? 0.0; + return usdPrice > 0; + }) + .cast() + .toList(); + return MarketMakerBotFormContent( + sellCoins: sellCoins, + buyCoins: buyCoins, + ); }, ), ), @@ -172,14 +185,26 @@ class _MakerFormMobileLayoutState extends State<_MakerFormMobileLayout> { children: [ BlocBuilder( builder: (context, state) { - final coins = state.walletCoins.values - .where((e) { - final usdPrice = e.usdPrice?.price?.toDouble() ?? 0.0; + final buyCoins = state.coins.values + .where((coin) { + final usdPrice = + coin.lastKnownUsdPrice(context.sdk) ?? 0.0; + return usdPrice > 0; + }) + .cast() + .toList(); + final sellCoins = state.walletCoins.values + .where((coin) { + final usdPrice = + coin.lastKnownUsdPrice(context.sdk) ?? 0.0; return usdPrice > 0; }) .cast() .toList(); - return MarketMakerBotFormContent(coins: coins); + return MarketMakerBotFormContent( + sellCoins: sellCoins, + buyCoins: buyCoins, + ); }, ), const SizedBox(height: 22), diff --git a/lib/views/market_maker_bot/market_maker_bot_form_content.dart b/lib/views/market_maker_bot/market_maker_bot_form_content.dart index cfe76669ad..5e3ce99956 100644 --- a/lib/views/market_maker_bot/market_maker_bot_form_content.dart +++ b/lib/views/market_maker_bot/market_maker_bot_form_content.dart @@ -1,4 +1,5 @@ import 'package:app_theme/app_theme.dart'; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -6,7 +7,6 @@ import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/analytics/events/market_bot_events.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; -import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/coin.dart'; @@ -27,9 +27,14 @@ import 'package:web_dex/views/market_maker_bot/update_interval_dropdown.dart'; import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; class MarketMakerBotFormContent extends StatefulWidget { - const MarketMakerBotFormContent({required this.coins, super.key}); + const MarketMakerBotFormContent({ + required this.sellCoins, + required this.buyCoins, + super.key, + }); - final List coins; + final List sellCoins; + final List buyCoins; @override State createState() => @@ -45,7 +50,8 @@ class _MarketMakerBotFormContentState extends State { @override void didUpdateWidget(MarketMakerBotFormContent oldWidget) { - if (oldWidget.coins != widget.coins) { + if (oldWidget.sellCoins != widget.sellCoins || + oldWidget.buyCoins != widget.buyCoins) { final formBloc = context.read(); if (formBloc.state.sellCoin.value == null) { _setSellCoinToDefaultCoin(); @@ -77,7 +83,7 @@ class _MarketMakerBotFormContentState extends State { key: const Key('$keyPrefix-sell-select'), sellCoin: state.sellCoin, sellAmount: state.sellAmount, - coins: _coinsWithUsdBalance(widget.coins), + coins: _coinsWithUsdBalance(widget.sellCoins), minimumTradeVolume: state.minimumTradeVolume, maximumTradeVolume: state.maximumTradeVolume, onItemSelected: _onSelectSellCoin, @@ -88,7 +94,7 @@ class _MarketMakerBotFormContentState extends State { key: const Key('$keyPrefix-buy-select'), buyCoin: state.buyCoin, buyAmount: state.buyAmount, - coins: _filteredCoinsList(state.sellCoin.value), + coins: _filteredBuyCoinsList(state.sellCoin.value), onItemSelected: _onBuyCoinSelected, ), ), @@ -131,7 +137,8 @@ class _MarketMakerBotFormContentState extends State { const SizedBox(height: 12), if (state.tradePreImageError != null) ImportantNote( - text: state.tradePreImageError?.textWithMin( + text: + state.tradePreImageError?.textWithMin( state.sellCoin.value, state.buyCoin.value, state.minTradingVolume?.toString(), @@ -200,16 +207,20 @@ class _MarketMakerBotFormContentState extends State { } void _setSellCoinToDefaultCoin() { - final coinsRepository = RepositoryProvider.of(context); - final defaultCoin = coinsRepository.getCoin(defaultDexCoin); + final availableSellCoins = _coinsWithUsdBalance(widget.sellCoins); + final defaultCoin = availableSellCoins.firstWhereOrNull( + (coin) => coin.abbr == defaultDexCoin, + ); + final fallbackCoin = availableSellCoins.firstOrNull; final tradeFormBloc = context.read(); - if (defaultCoin != null && tradeFormBloc.state.sellCoin.value == null) { - tradeFormBloc.add(MarketMakerTradeFormSellCoinChanged(defaultCoin)); + final nextSellCoin = defaultCoin ?? fallbackCoin; + if (nextSellCoin != null && tradeFormBloc.state.sellCoin.value == null) { + tradeFormBloc.add(MarketMakerTradeFormSellCoinChanged(nextSellCoin)); } } - List _filteredCoinsList(Coin? coin) { - return widget.coins.where((e) => e.abbr != coin?.abbr).toList(); + List _filteredBuyCoinsList(Coin? coin) { + return widget.buyCoins.where((e) => e.abbr != coin?.abbr).toList(); } void _onTradeMarginChanged(String value) { diff --git a/lib/views/market_maker_bot/market_maker_bot_page.dart b/lib/views/market_maker_bot/market_maker_bot_page.dart index 6160a0093f..e82537a0d9 100644 --- a/lib/views/market_maker_bot/market_maker_bot_page.dart +++ b/lib/views/market_maker_bot/market_maker_bot_page.dart @@ -13,6 +13,7 @@ import 'package:web_dex/bloc/settings/settings_repository.dart'; import 'package:web_dex/blocs/trading_entities_bloc.dart'; import 'package:web_dex/model/authorize_mode.dart'; import 'package:web_dex/model/dex_list_type.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/market_maker_bot/trade_coin_pair_config.dart'; import 'package:web_dex/router/state/routing_state.dart'; import 'package:web_dex/services/orders_service/my_orders_service.dart'; import 'package:web_dex/views/dex/entity_details/trading_details.dart'; @@ -27,6 +28,7 @@ class MarketMakerBotPage extends StatefulWidget { class _MarketMakerBotPageState extends State { bool isTradingDetails = false; + final Map _orderConfigByUuid = {}; @override void initState() { @@ -42,8 +44,9 @@ class _MarketMakerBotPageState extends State { @override Widget build(BuildContext context) { - final tradingEntitiesBloc = - RepositoryProvider.of(context); + final tradingEntitiesBloc = RepositoryProvider.of( + context, + ); final coinsRepository = RepositoryProvider.of(context); final myOrdersService = RepositoryProvider.of(context); @@ -78,31 +81,33 @@ class _MarketMakerBotPageState extends State { ), ), ], - child: BlocListener( - listener: (context, state) { - if (state.mode == AuthorizeMode.noLogin) { - context - .read() - .add(const MarketMakerBotStopRequested()); - } - }, + child: MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) { + if (state.mode == AuthorizeMode.noLogin) { + context.read().add( + const MarketMakerBotStopRequested(), + ); + _orderConfigByUuid.clear(); + } + }, + ), + BlocListener( + listenWhen: (previous, current) => + previous.makerBotOrders != current.makerBotOrders, + listener: (context, state) => _handleOrderListUpdate(state), + ), + ], child: BlocBuilder( builder: (context, state) { - // Defensive bounds check for tabIndex - final bool inRange = - state.tabIndex >= 0 && state.tabIndex < DexListType.values.length; - final tab = inRange - ? DexListType.values[state.tabIndex] - : DexListType.swap; - // Explicit mapping: only orders tab shows order entities, all others show swaps - final kind = switch (tab) { - DexListType.orders => TradingEntityKind.order, - DexListType.swap => TradingEntityKind.swap, - DexListType.inProgress => TradingEntityKind.swap, - DexListType.history => TradingEntityKind.swap, - }; + final tab = _safeDexListType(state); + final kind = _kindForTab(tab); return isTradingDetails - ? TradingDetails(uuid: routingState.marketMakerState.uuid, kind: kind) + ? TradingDetails( + uuid: routingState.marketMakerState.uuid, + kind: kind, + ) : MarketMakerBotView(); }, ), @@ -116,4 +121,60 @@ class _MarketMakerBotPageState extends State { () => isTradingDetails = routingState.marketMakerState.isTradingDetails, ); } + + DexListType _safeDexListType(DexTabBarState state) { + final index = state.tabIndex; + if (index < 0 || index >= DexListType.values.length) { + return DexListType.swap; + } + return DexListType.values[index]; + } + + TradingEntityKind _kindForTab(DexListType tab) { + return switch (tab) { + DexListType.orders => TradingEntityKind.order, + DexListType.swap => TradingEntityKind.swap, + DexListType.inProgress => TradingEntityKind.swap, + DexListType.history => TradingEntityKind.swap, + }; + } + + void _handleOrderListUpdate(MarketMakerOrderListState state) { + for (final pair in state.makerBotOrders) { + final orderUuid = pair.order?.uuid; + if (orderUuid != null && orderUuid.isNotEmpty) { + _orderConfigByUuid[orderUuid] = pair.config; + } + } + + if (!routingState.marketMakerState.isTradingDetails) return; + + final currentTab = _safeDexListType(context.read().state); + if (currentTab != DexListType.orders) return; + + final currentUuid = routingState.marketMakerState.uuid; + if (currentUuid.isEmpty) return; + + final hasCurrentUuid = state.makerBotOrders.any( + (pair) => pair.order?.uuid == currentUuid, + ); + if (hasCurrentUuid) return; + + final previousConfig = _orderConfigByUuid[currentUuid]; + if (previousConfig == null) return; + + String? replacementUuid; + for (final pair in state.makerBotOrders) { + final orderUuid = pair.order?.uuid; + if (orderUuid == null || orderUuid.isEmpty) continue; + if (pair.config == previousConfig) { + replacementUuid = orderUuid; + break; + } + } + + if (replacementUuid != null && replacementUuid != currentUuid) { + routingState.marketMakerState.uuid = replacementUuid; + } + } } diff --git a/lib/views/market_maker_bot/trade_bot_update_interval.dart b/lib/views/market_maker_bot/trade_bot_update_interval.dart index 27db0fbf4e..a5b126ec78 100644 --- a/lib/views/market_maker_bot/trade_bot_update_interval.dart +++ b/lib/views/market_maker_bot/trade_bot_update_interval.dart @@ -1,7 +1,11 @@ enum TradeBotUpdateInterval { oneMinute, threeMinutes, - fiveMinutes; + fiveMinutes, + tenMinutes, + fifteenMinutes, + thirtyMinutes, + sixtyMinutes; @override String toString() { @@ -12,26 +16,38 @@ enum TradeBotUpdateInterval { return '3'; case TradeBotUpdateInterval.fiveMinutes: return '5'; + case TradeBotUpdateInterval.tenMinutes: + return '10'; + case TradeBotUpdateInterval.fifteenMinutes: + return '15'; + case TradeBotUpdateInterval.thirtyMinutes: + return '30'; + case TradeBotUpdateInterval.sixtyMinutes: + return '60'; } } static TradeBotUpdateInterval fromString(String interval) { - switch (interval) { - case '1': - return TradeBotUpdateInterval.oneMinute; - case '3': - return TradeBotUpdateInterval.threeMinutes; - case '5': - return TradeBotUpdateInterval.fiveMinutes; - case '60': - return TradeBotUpdateInterval.oneMinute; - case '180': - return TradeBotUpdateInterval.threeMinutes; - case '300': - return TradeBotUpdateInterval.fiveMinutes; - default: - throw ArgumentError('Invalid interval'); + final parsed = int.tryParse(interval); + if (parsed == null) return TradeBotUpdateInterval.fiveMinutes; + + // Backward compatibility: legacy values can be saved either as minutes + // (1/3/5) or as seconds (60/180/300). + final int seconds = parsed < 60 ? parsed * 60 : parsed; + final options = TradeBotUpdateInterval.values; + + TradeBotUpdateInterval closest = options.first; + int closestDiff = (closest.seconds - seconds).abs(); + + for (final option in options.skip(1)) { + final int diff = (option.seconds - seconds).abs(); + if (diff < closestDiff) { + closest = option; + closestDiff = diff; + } } + + return closest; } int get minutes { @@ -42,6 +58,14 @@ enum TradeBotUpdateInterval { return 3; case TradeBotUpdateInterval.fiveMinutes: return 5; + case TradeBotUpdateInterval.tenMinutes: + return 10; + case TradeBotUpdateInterval.fifteenMinutes: + return 15; + case TradeBotUpdateInterval.thirtyMinutes: + return 30; + case TradeBotUpdateInterval.sixtyMinutes: + return 60; } } @@ -53,6 +77,14 @@ enum TradeBotUpdateInterval { return 180; case TradeBotUpdateInterval.fiveMinutes: return 300; + case TradeBotUpdateInterval.tenMinutes: + return 600; + case TradeBotUpdateInterval.fifteenMinutes: + return 900; + case TradeBotUpdateInterval.thirtyMinutes: + return 1800; + case TradeBotUpdateInterval.sixtyMinutes: + return 3600; } } } diff --git a/lib/views/nfts/nft_main/nft_main_controls.dart b/lib/views/nfts/nft_main/nft_main_controls.dart index 0dc1b63650..664461b5bc 100644 --- a/lib/views/nfts/nft_main/nft_main_controls.dart +++ b/lib/views/nfts/nft_main/nft_main_controls.dart @@ -34,8 +34,9 @@ class _NftMainControlsState extends State { @override Widget build(BuildContext context) { - final ColorSchemeExtension colorScheme = - Theme.of(context).extension()!; + final ColorSchemeExtension colorScheme = Theme.of( + context, + ).extension()!; final textTheme = Theme.of(context).extension()!; return Row( mainAxisSize: MainAxisSize.max, @@ -47,11 +48,9 @@ class _NftMainControlsState extends State { height: 40, backgroundColor: colorScheme.surfContHighest, prefix: Transform.rotate( - angle: math.pi / 4, - child: Icon( - Icons.arrow_forward, - color: colorScheme.primary, - )), + angle: math.pi / 4, + child: Icon(Icons.arrow_forward, color: colorScheme.primary), + ), onPressed: _onReceiveNft, textStyle: textTheme.bodySBold.copyWith(color: colorScheme.primary), ), @@ -89,6 +88,7 @@ class _NftMainControlsState extends State { return PopupDispatcher( borderColor: theme.custom.specificButtonBorderColor, barrierColor: isMobile ? Theme.of(context).colorScheme.onSurface : null, + barrierDismissible: false, width: 320, context: scaffoldKey.currentContext ?? context, popupContent: WalletsManagerWrapper( diff --git a/lib/views/nfts/nft_tabs/nft_tab.dart b/lib/views/nfts/nft_tabs/nft_tab.dart index 92e0758def..e478e03393 100644 --- a/lib/views/nfts/nft_tabs/nft_tab.dart +++ b/lib/views/nfts/nft_tabs/nft_tab.dart @@ -22,10 +22,10 @@ class NftTab extends StatelessWidget { @override Widget build(BuildContext context) { final ThemeData themeData = Theme.of(context); - final ColorSchemeExtension colorScheme = - themeData.extension()!; - final TextThemeExtension textTheme = - themeData.extension()!; + final ColorSchemeExtension colorScheme = themeData + .extension()!; + final TextThemeExtension textTheme = themeData + .extension()!; return BlocSelector( selector: (state) { @@ -33,6 +33,7 @@ class NftTab extends StatelessWidget { }, builder: (context, selectedChain) { final bool isSelected = selectedChain == chain; + final chainColor = _getChainColor(chain); return InkWell( key: Key('nft-tab-bnt-$chain'), hoverColor: Colors.transparent, @@ -49,9 +50,7 @@ class NftTab extends StatelessWidget { padding: EdgeInsets.only(left: isFirst ? 0 : 20, bottom: 8), decoration: isSelected ? BoxDecoration( - border: Border( - bottom: BorderSide(color: colorScheme.secondary), - ), + border: Border(bottom: BorderSide(color: chainColor)), ) : null, child: Row( @@ -62,7 +61,7 @@ class NftTab extends StatelessWidget { height: 24, decoration: BoxDecoration( shape: BoxShape.circle, - color: isSelected ? colorScheme.secondary : colorScheme.s40, + color: isSelected ? chainColor : colorScheme.s40, ), child: Center( child: SvgPicture.asset( @@ -71,7 +70,7 @@ class NftTab extends StatelessWidget { height: 16, key: Key('nft-tab-btn-icon-$chain'), colorFilter: ColorFilter.mode( - isSelected ? colorScheme.surf : colorScheme.s70, + isSelected ? colorScheme.surf : chainColor, BlendMode.srcIn, ), ), @@ -86,9 +85,7 @@ class NftTab extends StatelessWidget { _title, key: Key('nft-tab-btn-text-$chain'), style: textTheme.bodySBold.copyWith( - color: isSelected - ? colorScheme.secondary - : colorScheme.s50, + color: isSelected ? chainColor : colorScheme.s50, ), ), _NftCount(chain: chain), @@ -116,6 +113,21 @@ class NftTab extends StatelessWidget { return 'Fantom'; } } + + Color _getChainColor(NftBlockchains chain) { + switch (chain) { + case NftBlockchains.eth: + return const Color(0xFF3D77E9); + case NftBlockchains.bsc: + return const Color(0xFFE6BC41); + case NftBlockchains.avalanche: + return const Color(0xFFD54F49); + case NftBlockchains.polygon: + return const Color(0xFF7B49DD); + case NftBlockchains.fantom: + return const Color(0xFF3267F6); + } + } } class _NftCount extends StatelessWidget { @@ -132,14 +144,17 @@ class _NftCount extends StatelessWidget { }, builder: (context, nftCount) { final int? count = nftCount[chain]; - final ColorSchemeExtension colorScheme = - Theme.of(context).extension()!; - final TextThemeExtension textTheme = - Theme.of(context).extension()!; + final ColorSchemeExtension colorScheme = Theme.of( + context, + ).extension()!; + final TextThemeExtension textTheme = Theme.of( + context, + ).extension()!; return Text( - count != null ? LocaleKeys.nItems.tr(args: [count.toString()]) : '', - style: textTheme.bodyXXSBold.copyWith(color: colorScheme.s40), - key: Key('ntf-tab-count-$chain')); + count != null ? LocaleKeys.nItems.tr(args: [count.toString()]) : '', + style: textTheme.bodyXXSBold.copyWith(color: colorScheme.s40), + key: Key('ntf-tab-count-$chain'), + ); }, ); } diff --git a/lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_filters.dart b/lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_filters.dart index 6f174cc091..3f40e533c4 100644 --- a/lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_filters.dart +++ b/lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_filters.dart @@ -13,9 +13,50 @@ import 'package:web_dex/model/nft.dart'; const double _itemHeight = 42; -class NftTxnDesktopFilters extends StatelessWidget { +class NftTxnDesktopFilters extends StatefulWidget { const NftTxnDesktopFilters({super.key}); + @override + State createState() => _NftTxnDesktopFiltersState(); +} + +class _NftTxnDesktopFiltersState extends State { + late final TextEditingController _searchController; + late final FocusNode _searchFocusNode; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController( + text: context.read().state.filters.searchLine, + ); + _searchFocusNode = FocusNode(); + } + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + void _syncSearchField(String searchLine) { + if (_searchController.text == searchLine) { + return; + } + + _searchController.value = _searchController.value.copyWith( + text: searchLine, + selection: TextSelection.collapsed(offset: searchLine.length), + composing: TextRange.empty, + ); + } + + void _clearSearch(BuildContext context) { + _searchController.clear(); + context.read().add(const NftTxnEventSearchChanged('')); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).extension(); @@ -26,145 +67,154 @@ class NftTxnDesktopFilters extends StatelessWidget { selectedContainerColor: colorScheme?.primary, selectedTextColor: colorScheme?.surf, ); - return BlocBuilder( - builder: (context, state) { - return Container( - height: 56, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: colorScheme?.surfContHighest, - ), - child: Row(children: [ - Flexible( - flex: 3, - child: SizedBox( - height: 40, - child: CupertinoSearchTextField( - controller: state.filters.searchLine.isEmpty - ? TextEditingController() - : null, - onSubmitted: (value) { - context - .read() - .add(NftTxnEventSearchChanged(value)); - }, - style: Theme.of(context).textTheme.bodySmall, - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - color: colorScheme?.surfCont, - ), - prefixInsets: const EdgeInsets.only(left: 16, right: 8), - prefixIcon: SvgPicture.asset( - '$assetsPath/custom_icons/16px/search.svg', - width: 16, - height: 16, - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.secondary, - BlendMode.srcIn, + + return BlocListener( + listenWhen: (previous, current) => + previous.filters.searchLine != current.filters.searchLine, + listener: (context, state) => _syncSearchField(state.filters.searchLine), + child: BlocBuilder( + builder: (context, state) { + return Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: colorScheme?.surfContHighest, + ), + child: Row( + children: [ + Flexible( + flex: 3, + child: SizedBox( + height: 40, + child: CupertinoSearchTextField( + controller: _searchController, + focusNode: _searchFocusNode, + onSubmitted: (value) { + context.read().add( + NftTxnEventSearchChanged(value), + ); + }, + style: Theme.of(context).textTheme.bodySmall, + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + color: colorScheme?.surfCont, ), + prefixInsets: const EdgeInsets.only(left: 16, right: 8), + prefixIcon: SvgPicture.asset( + '$assetsPath/custom_icons/16px/search.svg', + width: 16, + height: 16, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.secondary, + BlendMode.srcIn, + ), + ), + suffixInsets: const EdgeInsets.only(left: 16, right: 8), + suffixIcon: Icon( + Icons.clear, + color: Theme.of(context).colorScheme.secondary, + size: 18, + ), + onSuffixTap: () => _clearSearch(context), ), - suffixInsets: const EdgeInsets.only(left: 16, right: 8), - suffixIcon: Icon( - Icons.clear, - color: Theme.of(context).colorScheme.secondary, - size: 18, - ), - onSuffixTap: () { - context - .read() - .add(const NftTxnEventSearchChanged('')); + ), + ), + const SizedBox(width: 24), + MultiSelectDropdownButton( + title: 'Status', + items: NftTransactionStatuses.values, + displayItem: (p0) => p0.toString(), + selectedItems: state.filters.statuses, + onChanged: (value) { + context.read().add( + NftTxnEventStatusesChanged(value), + ); + }, + colorScheme: chipColorScheme, + ), + const SizedBox(width: 8), + MultiSelectDropdownButton( + title: 'Blockchain', + items: NftBlockchains.values, + displayItem: (p0) => p0.toString(), + selectedItems: state.filters.blockchain, + onChanged: (value) { + context.read().add( + NftTxnEventBlockchainChanged(value), + ); + }, + colorScheme: chipColorScheme, + ), + const SizedBox(width: 8), + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 120, + maxHeight: _itemHeight, + ), + child: UiDatePicker( + formatter: DateFormat('dd.MM.yyyy').format, + date: state.filters.dateFrom, + text: LocaleKeys.fromDate.tr(), + endDate: state.filters.dateTo, + onDateSelect: (time) { + context.read().add( + NftTxnEventStartDateChanged(time), + ); }, ), - )), - const SizedBox(width: 24), - MultiSelectDropdownButton( - title: 'Status', - items: NftTransactionStatuses.values, - displayItem: (p0) => p0.toString(), - selectedItems: state.filters.statuses, - onChanged: (value) { - context - .read() - .add(NftTxnEventStatusesChanged(value)); - }, - colorScheme: chipColorScheme, - ), - const SizedBox(width: 8), - MultiSelectDropdownButton( - title: 'Blockchain', - items: NftBlockchains.values, - displayItem: (p0) => p0.toString(), - selectedItems: state.filters.blockchain, - onChanged: (value) { - context - .read() - .add(NftTxnEventBlockchainChanged(value)); - }, - colorScheme: chipColorScheme, - ), - const SizedBox(width: 8), - ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 120, maxHeight: _itemHeight), - child: UiDatePicker( - formatter: DateFormat('dd.MM.yyyy').format, - date: state.filters.dateFrom, - text: LocaleKeys.fromDate.tr(), - endDate: state.filters.dateTo, - onDateSelect: (time) { - context - .read() - .add(NftTxnEventStartDateChanged(time)); - }, - ), - ), - const SizedBox(width: 8), - ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 120, maxHeight: _itemHeight), - child: UiDatePicker( - formatter: DateFormat('dd.MM.yyyy').format, - date: state.filters.dateTo, - text: LocaleKeys.toDate.tr(), - startDate: state.filters.dateFrom, - onDateSelect: (time) { - context - .read() - .add(NftTxnEventEndDateChanged(time)); - }, - ), - ), - const SizedBox(width: 24), - const Flex(direction: Axis.horizontal), - state.filters.isEmpty - ? UiSecondaryButton( - height: _itemHeight, - width: 72, - text: LocaleKeys.reset.tr(), - borderColor: colorScheme?.s70, - textStyle: Theme.of(context).textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme?.s70, - fontSize: 14, - ), - onPressed: null, - ) - : UiPrimaryButton( - width: 72, - height: _itemHeight, - text: LocaleKeys.reset.tr(), - padding: EdgeInsets.zero, - onPressed: () { - context - .read() - .add(const NftTxnClearFilters()); + ), + const SizedBox(width: 8), + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 120, + maxHeight: _itemHeight, + ), + child: UiDatePicker( + formatter: DateFormat('dd.MM.yyyy').format, + date: state.filters.dateTo, + text: LocaleKeys.toDate.tr(), + startDate: state.filters.dateFrom, + onDateSelect: (time) { + context.read().add( + NftTxnEventEndDateChanged(time), + ); }, ), - ]), - ); - }, + ), + const SizedBox(width: 24), + const Flex(direction: Axis.horizontal), + state.filters.isEmpty + ? UiSecondaryButton( + height: _itemHeight, + width: 72, + text: LocaleKeys.reset.tr(), + borderColor: colorScheme?.s70, + textStyle: Theme.of(context).textTheme.labelLarge + ?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme?.s70, + fontSize: 14, + ), + onPressed: null, + ) + : UiPrimaryButton( + width: 72, + height: _itemHeight, + text: LocaleKeys.reset.tr(), + padding: EdgeInsets.zero, + onPressed: () { + context.read().add( + const NftTxnClearFilters(), + ); + }, + ), + ], + ), + ); + }, + ), ); } } diff --git a/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_filter_card.dart b/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_filter_card.dart index c278b66b66..9f7e0b4c96 100644 --- a/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_filter_card.dart +++ b/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_filter_card.dart @@ -7,19 +7,22 @@ class NftTxnMobileFilterCard extends StatelessWidget { final String svgPath; final VoidCallback onTap; final bool isSelected; + final Color? accentColor; const NftTxnMobileFilterCard({ super.key, required this.title, required this.svgPath, required this.onTap, this.isSelected = false, + this.accentColor, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).extension()!; final textScheme = Theme.of(context).extension()!; - final color = isSelected ? colorScheme.surf : colorScheme.s70; + final Color inactiveColor = accentColor ?? colorScheme.s70; + final color = isSelected ? colorScheme.surf : inactiveColor; return InkWell( onTap: onTap, child: Container( @@ -34,17 +37,11 @@ class NftTxnMobileFilterCard extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - title, - style: textScheme.bodyS.copyWith(color: color), - ), + Text(title, style: textScheme.bodyS.copyWith(color: color)), const SizedBox(width: 8), SvgPicture.asset( svgPath, - colorFilter: ColorFilter.mode( - color, - BlendMode.srcIn, - ), + colorFilter: ColorFilter.mode(color, BlendMode.srcIn), width: 24, height: 24, ), diff --git a/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_filters.dart b/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_filters.dart index d6210d3345..0d36af256a 100644 --- a/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_filters.dart +++ b/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_filters.dart @@ -13,10 +13,7 @@ class NftTxnMobileFilters extends StatefulWidget { final NftTransactionsFilter filters; final void Function(NftTransactionsFilter?) onApply; - const NftTxnMobileFilters({ - required this.filters, - required this.onApply, - }); + const NftTxnMobileFilters({required this.filters, required this.onApply}); @override State createState() => _NftTxnMobileFiltersState(); @@ -50,7 +47,8 @@ class _NftTxnMobileFiltersState extends State { mainAxisExtent: 56, ); - final bool isButtonDisabled = statuses.isEmpty && + final bool isButtonDisabled = + statuses.isEmpty && blockchains.isEmpty && dateFrom == null && dateTo == null; @@ -59,163 +57,171 @@ class _NftTxnMobileFiltersState extends State { data: Theme.of(context).brightness == Brightness.light ? newThemeLight : newThemeDark, - child: Builder(builder: (context) { - final colorScheme = Theme.of(context).extension(); - final textScheme = Theme.of(context).extension(); - return Container( - decoration: BoxDecoration( + child: Builder( + builder: (context) { + final colorScheme = Theme.of( + context, + ).extension(); + final textScheme = Theme.of(context).extension(); + return Container( + decoration: BoxDecoration( color: colorScheme?.surfContLowest, border: Border.all( - color: colorScheme?.s40 ?? Colors.transparent, width: 1), + color: colorScheme?.s40 ?? Colors.transparent, + width: 1, + ), borderRadius: const BorderRadius.only( - topLeft: Radius.circular(24), topRight: Radius.circular(24))), - padding: const EdgeInsets.all(16), - child: SingleChildScrollView( - controller: ScrollController(), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 80, - height: 4, - decoration: BoxDecoration( + topLeft: Radius.circular(24), + topRight: Radius.circular(24), + ), + ), + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + controller: ScrollController(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 4, + decoration: BoxDecoration( color: colorScheme?.surf, - borderRadius: BorderRadius.circular(20)), - ), - const SizedBox(height: 24), - Text( - LocaleKeys.filters.tr(), - style: textScheme?.bodyMBold, - ), - const SizedBox(height: 24), - GridView( - gridDelegate: gridDelete, - shrinkWrap: true, - children: [ - NftTxnMobileFilterCard( - title: LocaleKeys.send.tr(), - onTap: () { - setState(() { - statuses.contains(NftTransactionStatuses.send) - ? statuses.remove(NftTransactionStatuses.send) - : statuses.add(NftTransactionStatuses.send); - }); - widget.onApply(getFilters()); - }, - isSelected: - statuses.contains(NftTransactionStatuses.send), - svgPath: '$assetsPath/custom_icons/send.svg', - ), - NftTxnMobileFilterCard( - title: LocaleKeys.receive.tr(), - onTap: () { - setState(() { - statuses.contains(NftTransactionStatuses.receive) - ? statuses.remove(NftTransactionStatuses.receive) - : statuses.add(NftTransactionStatuses.receive); - }); - widget.onApply(getFilters()); - }, - isSelected: - statuses.contains(NftTransactionStatuses.receive), - svgPath: '$assetsPath/custom_icons/receive.svg', - ), - ], - ), - const SizedBox(height: 20), - Text( - LocaleKeys.blockchain.tr(), - style: textScheme?.bodyM, - ), - const SizedBox(height: 8), - GridView.builder( - shrinkWrap: true, - gridDelegate: gridDelete, - itemBuilder: (context, index) { - final NftBlockchains blockchain = - NftBlockchains.values[index]; - return NftTxnMobileFilterCard( - onTap: () { - setState(() { - blockchains.contains(blockchain) - ? blockchains.remove(blockchain) - : blockchains.add(blockchain); - }); - widget.onApply(getFilters()); - }, - title: blockchain.toString(), - isSelected: blockchains.contains(blockchain), - svgPath: - '$assetsPath/blockchain_icons/svg/32px/${blockchain.toApiRequest().toLowerCase()}.svg', - ); - }, - itemCount: NftBlockchains.values.length, - ), - const SizedBox(height: 20), - Text( - LocaleKeys.date.tr(), - style: textScheme?.bodyM, - ), - const SizedBox(height: 8), - GridView( - gridDelegate: gridDelete, - shrinkWrap: true, - children: [ - UiDatePicker( - formatter: dateFormatter.format, - isMobileAlternative: true, - date: dateFrom, - text: LocaleKeys.fromDate.tr(), - endDate: dateTo, - onDateSelect: (time) { - setState(() { - dateFrom = time; - }); - widget.onApply(getFilters()); - }, + borderRadius: BorderRadius.circular(20), ), - UiDatePicker( - formatter: dateFormatter.format, - isMobileAlternative: true, - date: dateTo, - text: LocaleKeys.toDate.tr(), - startDate: dateFrom, - onDateSelect: (time) { - setState(() { - dateTo = time; - }); - widget.onApply(getFilters()); - }, - ), - ], - ), - const SizedBox(height: 20), - Flexible( - child: UiPrimaryButton( - border: Border.all( + ), + const SizedBox(height: 24), + Text(LocaleKeys.filters.tr(), style: textScheme?.bodyMBold), + const SizedBox(height: 24), + GridView( + gridDelegate: gridDelete, + shrinkWrap: true, + children: [ + NftTxnMobileFilterCard( + title: LocaleKeys.send.tr(), + onTap: () { + setState(() { + statuses.contains(NftTransactionStatuses.send) + ? statuses.remove(NftTransactionStatuses.send) + : statuses.add(NftTransactionStatuses.send); + }); + widget.onApply(getFilters()); + }, + isSelected: statuses.contains( + NftTransactionStatuses.send, + ), + svgPath: '$assetsPath/custom_icons/send.svg', + ), + NftTxnMobileFilterCard( + title: LocaleKeys.receive.tr(), + onTap: () { + setState(() { + statuses.contains(NftTransactionStatuses.receive) + ? statuses.remove( + NftTransactionStatuses.receive, + ) + : statuses.add(NftTransactionStatuses.receive); + }); + widget.onApply(getFilters()); + }, + isSelected: statuses.contains( + NftTransactionStatuses.receive, + ), + svgPath: '$assetsPath/custom_icons/receive.svg', + ), + ], + ), + const SizedBox(height: 20), + Text(LocaleKeys.blockchain.tr(), style: textScheme?.bodyM), + const SizedBox(height: 8), + GridView.builder( + shrinkWrap: true, + gridDelegate: gridDelete, + itemBuilder: (context, index) { + final NftBlockchains blockchain = + NftBlockchains.values[index]; + final chainColor = _getChainColor(blockchain); + return NftTxnMobileFilterCard( + onTap: () { + setState(() { + blockchains.contains(blockchain) + ? blockchains.remove(blockchain) + : blockchains.add(blockchain); + }); + widget.onApply(getFilters()); + }, + title: blockchain.toString(), + isSelected: blockchains.contains(blockchain), + accentColor: chainColor, + svgPath: + '$assetsPath/blockchain_icons/svg/32px/${blockchain.toApiRequest().toLowerCase()}.svg', + ); + }, + itemCount: NftBlockchains.values.length, + ), + const SizedBox(height: 20), + Text(LocaleKeys.date.tr(), style: textScheme?.bodyM), + const SizedBox(height: 8), + GridView( + gridDelegate: gridDelete, + shrinkWrap: true, + children: [ + UiDatePicker( + formatter: dateFormatter.format, + isMobileAlternative: true, + date: dateFrom, + text: LocaleKeys.fromDate.tr(), + endDate: dateTo, + onDateSelect: (time) { + setState(() { + dateFrom = time; + }); + widget.onApply(getFilters()); + }, + ), + UiDatePicker( + formatter: dateFormatter.format, + isMobileAlternative: true, + date: dateTo, + text: LocaleKeys.toDate.tr(), + startDate: dateFrom, + onDateSelect: (time) { + setState(() { + dateTo = time; + }); + widget.onApply(getFilters()); + }, + ), + ], + ), + const SizedBox(height: 20), + Flexible( + child: UiPrimaryButton( + border: Border.all( color: colorScheme?.secondary ?? Colors.transparent, - width: 2), - backgroundColor: Colors.transparent, - height: 40, - text: LocaleKeys.clearFilter.tr(), - onPressed: isButtonDisabled - ? null - : () { - setState(() { - statuses.clear(); - blockchains.clear(); - dateFrom = null; - dateTo = null; - }); - widget.onApply(getFilters()); - }, + width: 2, + ), + backgroundColor: Colors.transparent, + height: 40, + text: LocaleKeys.clearFilter.tr(), + onPressed: isButtonDisabled + ? null + : () { + setState(() { + statuses.clear(); + blockchains.clear(); + dateFrom = null; + dateTo = null; + }); + widget.onApply(getFilters()); + }, + ), ), - ), - ], + ], + ), ), - ), - ); - }), + ); + }, + ), ); } @@ -227,4 +233,19 @@ class _NftTxnMobileFiltersState extends State { dateTo: dateTo, ); } + + Color _getChainColor(NftBlockchains chain) { + switch (chain) { + case NftBlockchains.eth: + return const Color(0xFF3D77E9); + case NftBlockchains.bsc: + return const Color(0xFFE6BC41); + case NftBlockchains.avalanche: + return const Color(0xFFD54F49); + case NftBlockchains.polygon: + return const Color(0xFF7B49DD); + case NftBlockchains.fantom: + return const Color(0xFF3267F6); + } + } } diff --git a/lib/views/settings/settings_page.dart b/lib/views/settings/settings_page.dart index 694f7725cd..ffe199e3a4 100644 --- a/lib/views/settings/settings_page.dart +++ b/lib/views/settings/settings_page.dart @@ -8,6 +8,8 @@ import 'package:web_dex/views/common/page_header/page_header.dart'; import 'package:web_dex/views/common/pages/page_layout.dart'; import 'package:web_dex/views/settings/widgets/common/settings_content_wrapper.dart'; import 'package:web_dex/views/settings/widgets/general_settings/general_settings.dart'; +import 'package:web_dex/views/settings/widgets/kyc_policy_page/kyc_policy_page.dart'; +import 'package:web_dex/views/settings/widgets/privacy_notice_page/privacy_notice_page.dart'; import 'package:web_dex/views/settings/widgets/security_settings/security_settings_page.dart'; import 'package:web_dex/views/settings/widgets/settings_menu/settings_menu.dart'; import 'package:web_dex/views/settings/widgets/support_page/support_page.dart'; @@ -42,6 +44,10 @@ class SettingsPage extends StatelessWidget { return const GeneralSettings(); case SettingsMenuValue.security: return SecuritySettingsPage(onBackPressed: _onBackButtonPressed); + case SettingsMenuValue.privacy: + return const PrivacyNoticePage(); + case SettingsMenuValue.kycPolicy: + return const KycPolicyPage(); case SettingsMenuValue.support: return SupportPage(); @@ -87,6 +93,8 @@ class _MobileContentLayout extends StatelessWidget { case SettingsMenuValue.security: return content; case SettingsMenuValue.general: + case SettingsMenuValue.privacy: + case SettingsMenuValue.kycPolicy: case SettingsMenuValue.support: case SettingsMenuValue.feedback: return PageLayout( @@ -95,11 +103,7 @@ class _MobileContentLayout extends StatelessWidget { backText: '', onBackButtonPressed: _onBackButtonPressed, ), - content: Flexible( - child: SettingsContentWrapper( - child: content, - ), - ), + content: Flexible(child: SettingsContentWrapper(child: content)), ); case SettingsMenuValue.none: throw Error(); @@ -115,8 +119,11 @@ class _DesktopLayout extends StatelessWidget { @override Widget build(BuildContext context) { - final isTopSpace = selectedMenu != SettingsMenuValue.security && - selectedMenu != SettingsMenuValue.support; + final isTopSpace = + selectedMenu != SettingsMenuValue.security && + selectedMenu != SettingsMenuValue.support && + selectedMenu != SettingsMenuValue.privacy && + selectedMenu != SettingsMenuValue.kycPolicy; return PageLayout( content: Flexible( diff --git a/lib/views/settings/widgets/general_settings/app_version_number.dart b/lib/views/settings/widgets/general_settings/app_version_number.dart index 3145180dd9..96966cb816 100644 --- a/lib/views/settings/widgets/general_settings/app_version_number.dart +++ b/lib/views/settings/widgets/general_settings/app_version_number.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:web_dex/bloc/version_info/version_info_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/widgets/copied_text.dart'; class AppVersionNumber extends StatelessWidget { const AppVersionNumber({super.key}); @@ -19,19 +20,24 @@ class AppVersionNumber extends StatelessWidget { children: [ SelectableText(LocaleKeys.komodoWallet.tr(), style: _textStyle), if (state.appVersion != null) - SelectableText( - '${LocaleKeys.version.tr()}: ${state.appVersion}', - style: _textStyle, + _MetadataRow( + label: LocaleKeys.version.tr(), + value: state.appVersion!, + ), + if (state.buildDate != null) + _MetadataRow( + label: LocaleKeys.buildDate.tr(), + value: state.buildDate!, ), if (state.commitHash != null) - SelectableText( - '${LocaleKeys.commit.tr()}: ${state.commitHash}', - style: _textStyle, + _MetadataRow( + label: LocaleKeys.commit.tr(), + value: state.commitHash!, ), if (state.apiCommitHash != null) - SelectableText( - '${LocaleKeys.api.tr()}: ${state.apiCommitHash}', - style: _textStyle, + _MetadataRow( + label: LocaleKeys.api.tr(), + value: state.apiCommitHash!, ), const SizedBox(height: 4), CoinsCommitInfo(state: state), @@ -61,14 +67,14 @@ class CoinsCommitInfo extends StatelessWidget { children: [ Text(LocaleKeys.coinAssets.tr(), style: _textStyle), if (state.currentCoinsCommit != null) - SelectableText( - '${LocaleKeys.bundled.tr()}: ${state.currentCoinsCommit}', - style: _textStyle, + _MetadataRow( + label: LocaleKeys.bundled.tr(), + value: state.currentCoinsCommit!, ), if (state.latestCoinsCommit != null) - SelectableText( - '${LocaleKeys.updated.tr()}: ${state.latestCoinsCommit}', - style: _textStyle, + _MetadataRow( + label: LocaleKeys.updated.tr(), + value: state.latestCoinsCommit!, ), ], ); @@ -76,3 +82,33 @@ class CoinsCommitInfo extends StatelessWidget { } const _textStyle = TextStyle(fontSize: 13, fontWeight: FontWeight.w500); + +class _MetadataRow extends StatelessWidget { + const _MetadataRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 4, + children: [ + Text('$label:', style: _textStyle), + CopiedTextV2( + copiedValue: value, + isTruncated: true, + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + textColor: Theme.of(context).textTheme.bodyMedium?.color, + ), + ], + ), + ); + } +} diff --git a/lib/views/settings/widgets/general_settings/general_settings.dart b/lib/views/settings/widgets/general_settings/general_settings.dart index 46df5ab878..d55cc49fdd 100644 --- a/lib/views/settings/widgets/general_settings/general_settings.dart +++ b/lib/views/settings/widgets/general_settings/general_settings.dart @@ -6,6 +6,7 @@ import 'package:web_dex/shared/widgets/hidden_with_wallet.dart'; import 'package:web_dex/shared/widgets/hidden_without_wallet.dart'; import 'package:web_dex/views/settings/widgets/general_settings/import_swaps.dart'; import 'package:web_dex/views/settings/widgets/general_settings/settings_download_logs.dart'; +import 'package:web_dex/views/settings/widgets/general_settings/settings_hide_balances.dart'; import 'package:web_dex/views/settings/widgets/general_settings/settings_manage_analytics.dart'; import 'package:web_dex/views/settings/widgets/general_settings/settings_manage_diagnostic_logging.dart'; import 'package:web_dex/views/settings/widgets/general_settings/settings_manage_test_coins.dart'; @@ -29,6 +30,8 @@ class GeneralSettings extends StatelessWidget { const SizedBox(height: 25), const SettingsManageAnalytics(), const SizedBox(height: 25), + const SettingsHideBalances(), + const SizedBox(height: 25), const SettingsManageTestCoins(), const SizedBox(height: 25), const HiddenWithoutWallet( diff --git a/lib/views/settings/widgets/general_settings/settings_hide_balances.dart b/lib/views/settings/widgets/general_settings/settings_hide_balances.dart new file mode 100644 index 0000000000..a9ce39c59a --- /dev/null +++ b/lib/views/settings/widgets/general_settings/settings_hide_balances.dart @@ -0,0 +1,46 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_event.dart'; +import 'package:web_dex/bloc/settings/settings_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/settings/widgets/common/settings_section.dart'; + +class SettingsHideBalances extends StatelessWidget { + const SettingsHideBalances({super.key}); + + @override + Widget build(BuildContext context) { + return SettingsSection( + title: LocaleKeys.hideBalancesTitle.tr(), + child: const _HideBalancesSwitcher(), + ); + } +} + +class _HideBalancesSwitcher extends StatelessWidget { + const _HideBalancesSwitcher(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => Row( + children: [ + UiSwitcher( + key: const Key('hide-balances-switcher'), + value: state.hideBalances, + onChanged: (value) => _onSwitcherChanged(context, value), + ), + const SizedBox(width: 15), + Expanded(child: Text(LocaleKeys.hideBalancesSubtitle.tr())), + ], + ), + ); + } + + void _onSwitcherChanged(BuildContext context, bool value) { + context.read().add(HideBalancesChanged(hideBalances: value)); + } +} diff --git a/lib/views/settings/widgets/general_settings/settings_manage_trading_bot.dart b/lib/views/settings/widgets/general_settings/settings_manage_trading_bot.dart index 9bc55875d8..722ff97be1 100644 --- a/lib/views/settings/widgets/general_settings/settings_manage_trading_bot.dart +++ b/lib/views/settings/widgets/general_settings/settings_manage_trading_bot.dart @@ -1,3 +1,7 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -5,35 +9,281 @@ import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/market_maker_bot/market_maker_bot/market_maker_bot_bloc.dart'; import 'package:web_dex/bloc/settings/settings_bloc.dart'; import 'package:web_dex/bloc/settings/settings_event.dart'; +import 'package:web_dex/bloc/settings/settings_repository.dart'; import 'package:web_dex/bloc/settings/settings_state.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/market_maker_bot/trade_coin_pair_config.dart'; +import 'package:web_dex/services/file_loader/file_loader.dart'; import 'package:web_dex/views/settings/widgets/common/settings_section.dart'; -class SettingsManageTradingBot extends StatelessWidget { +class SettingsManageTradingBot extends StatefulWidget { const SettingsManageTradingBot({super.key}); + @override + State createState() => + _SettingsManageTradingBotState(); +} + +class _SettingsManageTradingBotState extends State { + final SettingsRepository _settingsRepository = SettingsRepository(); + + bool _isExporting = false; + bool _isImporting = false; + @override Widget build(BuildContext context) { - return Column( - children: [ - SettingsSection( - title: LocaleKeys.expertMode.tr(), - child: const EnableTradingBotSwitcher(), + return SettingsSection( + title: LocaleKeys.expertMode.tr(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _EnableTradingBotSwitcher(settingsRepository: _settingsRepository), + const SizedBox(height: 14), + _SaveOrdersSwitcher(settingsRepository: _settingsRepository), + const SizedBox(height: 14), + Wrap( + spacing: 12, + runSpacing: 10, + children: [ + _buildExportButton(context), + _buildImportButton(context), + ], + ), + const SizedBox(height: 8), + Text( + 'saveOrdersRestartHint'.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } + + Widget _buildExportButton(BuildContext context) { + return UiBorderButton( + width: 180, + height: 32, + borderWidth: 1, + borderColor: theme.custom.specificButtonBorderColor, + backgroundColor: theme.custom.specificButtonBackgroundColor, + fontWeight: FontWeight.w500, + text: 'exportMakerOrders'.tr(), + icon: _isExporting + ? const UiSpinner() + : Icon( + Icons.file_download, + color: Theme.of(context).textTheme.bodyMedium?.color, + size: 18, + ), + onPressed: _isExporting || _isImporting ? null : _exportMakerOrders, + ); + } + + Widget _buildImportButton(BuildContext context) { + return UiBorderButton( + width: 180, + height: 32, + borderWidth: 1, + borderColor: theme.custom.specificButtonBorderColor, + backgroundColor: theme.custom.specificButtonBackgroundColor, + fontWeight: FontWeight.w500, + text: 'importMakerOrders'.tr(), + icon: _isImporting + ? const UiSpinner() + : Icon( + Icons.file_upload, + color: Theme.of(context).textTheme.bodyMedium?.color, + size: 18, + ), + onPressed: _isExporting || _isImporting ? null : _importMakerOrders, + ); + } + + Future _exportMakerOrders() async { + setState(() => _isExporting = true); + + try { + final settings = await _settingsRepository.loadSettings(); + final configs = settings.marketMakerBotSettings.tradeCoinPairConfigs; + if (configs.isEmpty) { + _showMessage('noMakerOrdersToExport'.tr()); + return; + } + + final payload = { + 'version': 1, + 'exported_at': DateTime.now().toUtc().toIso8601String(), + 'trade_coin_pair_configs': configs.map((e) => e.toJson()).toList(), + }; + final timestamp = DateTime.now().toUtc().toIso8601String().replaceAll( + ':', + '-', + ); + + await FileLoader.fromPlatform().save( + fileName: 'maker_orders_$timestamp.json', + data: jsonEncode(payload), + type: LoadFileType.text, + ); + _showMessage( + 'makerOrdersExportSuccess'.tr(args: [configs.length.toString()]), + ); + } catch (error) { + _showMessage( + 'makerOrdersExportFailed'.tr(args: [_readableError(error)]), + isError: true, + ); + } finally { + if (mounted) { + setState(() => _isExporting = false); + } + } + } + + Future _importMakerOrders() async { + setState(() => _isImporting = true); + + try { + await FileLoader.fromPlatform().upload( + fileType: LoadFileType.text, + onUpload: (_, content) { + unawaited(_applyImportedOrders(content)); + }, + onError: (error) { + _showMessage( + 'makerOrdersImportFailed'.tr(args: [error]), + isError: true, + ); + if (mounted) { + setState(() => _isImporting = false); + } + }, + ); + } catch (error) { + _showMessage( + 'makerOrdersImportFailed'.tr(args: [_readableError(error)]), + isError: true, + ); + } finally { + if (mounted) { + // On desktop/native file picker this also handles cancel events. + setState(() => _isImporting = false); + } + } + } + + Future _applyImportedOrders(String? rawContent) async { + try { + final content = rawContent?.trim() ?? ''; + if (content.isEmpty) { + throw const FormatException('File is empty'); + } + + final importedConfigs = _decodeTradePairConfigs(content); + final stored = await _settingsRepository.loadSettings(); + final updatedMmSettings = stored.marketMakerBotSettings.copyWith( + tradeCoinPairConfigs: importedConfigs, + ); + + await _settingsRepository.updateSettings( + stored.copyWith(marketMakerBotSettings: updatedMmSettings), + ); + + if (!mounted) return; + context.read().add( + MarketMakerBotSettingsChanged(updatedMmSettings), + ); + _showMessage( + 'makerOrdersImportSuccess'.tr( + args: [importedConfigs.length.toString()], ), - ], + ); + } catch (error) { + _showMessage( + 'makerOrdersImportFailed'.tr(args: [_readableError(error)]), + isError: true, + ); + } finally { + if (mounted) { + setState(() => _isImporting = false); + } + } + } + + List _decodeTradePairConfigs(String jsonPayload) { + final decoded = jsonDecode(jsonPayload); + final dynamic rawConfigs; + if (decoded is List) { + rawConfigs = decoded; + } else if (decoded is Map) { + rawConfigs = + decoded['trade_coin_pair_configs'] ?? + decoded['tradeCoinPairConfigs'] ?? + decoded['orders']; + } else { + throw const FormatException('Unsupported file format'); + } + + if (rawConfigs is! List) { + throw const FormatException('Missing maker order configuration list'); + } + + final dedupedByName = {}; + for (final item in rawConfigs) { + if (item is! Map) { + continue; + } + + try { + final config = TradeCoinPairConfig.fromJson( + Map.from(item), + ); + dedupedByName[config.name] = config; + } catch (_) { + // Skip malformed entries and continue parsing. + } + } + + if (dedupedByName.isEmpty) { + throw const FormatException('No valid maker order configurations found'); + } + + return dedupedByName.values.toList(); + } + + void _showMessage(String message, {bool isError = false}) { + if (!mounted) return; + + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) return; + + messenger.showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Theme.of(context).colorScheme.error : null, + ), ); } + + String _readableError(Object error) { + final value = error.toString().trim(); + if (value.startsWith('Exception: ')) { + return value.replaceFirst('Exception: ', '').trim(); + } + return value.isEmpty ? LocaleKeys.somethingWrong.tr() : value; + } } -class EnableTradingBotSwitcher extends StatelessWidget { - const EnableTradingBotSwitcher({super.key}); +class _EnableTradingBotSwitcher extends StatelessWidget { + const _EnableTradingBotSwitcher({required this.settingsRepository}); + + final SettingsRepository settingsRepository; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) => Row( mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.max, children: [ UiSwitcher( key: const Key('enable-trading-bot-switcher'), @@ -41,16 +291,19 @@ class EnableTradingBotSwitcher extends StatelessWidget { onChanged: (value) => _onSwitcherChanged(context, value), ), const SizedBox(width: 15), - Text(LocaleKeys.enableTradingBot.tr()), + Flexible(child: Text(LocaleKeys.enableTradingBot.tr())), ], ), ); } - void _onSwitcherChanged(BuildContext context, bool value) { - final settings = context.read().state.mmBotSettings.copyWith( + Future _onSwitcherChanged(BuildContext context, bool value) async { + final stored = await settingsRepository.loadSettings(); + final settings = stored.marketMakerBotSettings.copyWith( isMMBotEnabled: value, ); + + if (!context.mounted) return; context.read().add(MarketMakerBotSettingsChanged(settings)); if (!value) { @@ -60,3 +313,37 @@ class EnableTradingBotSwitcher extends StatelessWidget { } } } + +class _SaveOrdersSwitcher extends StatelessWidget { + const _SaveOrdersSwitcher({required this.settingsRepository}); + + final SettingsRepository settingsRepository; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + UiSwitcher( + key: const Key('save-orders-switcher'), + value: state.mmBotSettings.saveOrdersBetweenLaunches, + onChanged: (value) => _onSwitcherChanged(context, value), + ), + const SizedBox(width: 15), + Flexible(child: Text('saveOrders'.tr())), + ], + ), + ); + } + + Future _onSwitcherChanged(BuildContext context, bool value) async { + final stored = await settingsRepository.loadSettings(); + final settings = stored.marketMakerBotSettings.copyWith( + saveOrdersBetweenLaunches: value, + ); + + if (!context.mounted) return; + context.read().add(MarketMakerBotSettingsChanged(settings)); + } +} diff --git a/lib/views/settings/widgets/kyc_policy_page/kyc_policy_page.dart b/lib/views/settings/widgets/kyc_policy_page/kyc_policy_page.dart new file mode 100644 index 0000000000..79755b69eb --- /dev/null +++ b/lib/views/settings/widgets/kyc_policy_page/kyc_policy_page.dart @@ -0,0 +1,33 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/services/legal_documents/legal_document.dart'; +import 'package:web_dex/shared/widgets/legal_documents/legal_document_view.dart'; + +class KycPolicyPage extends StatelessWidget { + const KycPolicyPage({super.key = const Key('kyc-policy-page')}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 25, vertical: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: Theme.of(context).colorScheme.surface, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + LocaleKeys.settingsMenuKycPolicy.tr(), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 16), + const LegalDocumentView( + document: LegalDocumentType.kycDueDiligencePolicy, + ), + ], + ), + ); + } +} diff --git a/lib/views/settings/widgets/privacy_notice_page/privacy_notice_page.dart b/lib/views/settings/widgets/privacy_notice_page/privacy_notice_page.dart new file mode 100644 index 0000000000..465c6691b1 --- /dev/null +++ b/lib/views/settings/widgets/privacy_notice_page/privacy_notice_page.dart @@ -0,0 +1,31 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/services/legal_documents/legal_document.dart'; +import 'package:web_dex/shared/widgets/legal_documents/legal_document_view.dart'; + +class PrivacyNoticePage extends StatelessWidget { + const PrivacyNoticePage({super.key = const Key('privacy-notice-page')}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 25, vertical: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: Theme.of(context).colorScheme.surface, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + LocaleKeys.settingsMenuPrivacy.tr(), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 16), + const LegalDocumentView(document: LegalDocumentType.privacyNotice), + ], + ), + ); + } +} diff --git a/lib/views/settings/widgets/settings_menu/settings_logout_button.dart b/lib/views/settings/widgets/settings_menu/settings_logout_button.dart index 33f2ca0036..23bc90345c 100644 --- a/lib/views/settings/widgets/settings_menu/settings_logout_button.dart +++ b/lib/views/settings/widgets/settings_menu/settings_logout_button.dart @@ -1,10 +1,9 @@ import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/common/screen.dart'; -import 'package:web_dex/dispatchers/popup_dispatcher.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/widgets/app_dialog.dart'; import 'package:web_dex/shared/widgets/logout_popup.dart'; class SettingsLogoutButton extends StatefulWidget { @@ -15,26 +14,14 @@ class SettingsLogoutButton extends StatefulWidget { } class _SettingsLogoutButtonState extends State { - late PopupDispatcher _logOutPopupManager; - - @override - void initState() { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _logOutPopupManager = PopupDispatcher( - context: scaffoldKey.currentContext ?? context, - popupContent: LogOutPopup( - onConfirm: () => _logOutPopupManager.close(), - onCancel: () => _logOutPopupManager.close(), - ), - ); - }); - super.initState(); - } - - @override - void dispose() { - _logOutPopupManager.close(); - super.dispose(); + Future _showLogoutDialog() async { + await AppDialog.showWithCallback( + context: context, + width: 320, + barrierDismissible: true, + childBuilder: (closeDialog) => + LogOutPopup(onConfirm: closeDialog, onCancel: closeDialog), + ); } @override @@ -42,30 +29,27 @@ class _SettingsLogoutButtonState extends State { return InkWell( key: const Key('settings-logout-button'), onTap: () { - _logOutPopupManager.show(); + _showLogoutDialog(); }, borderRadius: BorderRadius.circular(18), child: Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(24, 20, 0, 20), child: Row( - mainAxisAlignment: - isMobile ? MainAxisAlignment.center : MainAxisAlignment.start, + mainAxisAlignment: isMobile + ? MainAxisAlignment.center + : MainAxisAlignment.start, children: [ Text( LocaleKeys.logOut.tr(), style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 16, - fontWeight: FontWeight.w600, - color: theme.custom.warningColor, - ), + fontSize: 16, + fontWeight: FontWeight.w600, + color: theme.custom.warningColor, + ), ), const SizedBox(width: 6), - Icon( - Icons.exit_to_app, - color: theme.custom.warningColor, - size: 18, - ), + Icon(Icons.exit_to_app, color: theme.custom.warningColor, size: 18), ], ), ), diff --git a/lib/views/settings/widgets/settings_menu/settings_menu.dart b/lib/views/settings/widgets/settings_menu/settings_menu.dart index 0e3b1ca700..c4e2ca8163 100644 --- a/lib/views/settings/widgets/settings_menu/settings_menu.dart +++ b/lib/views/settings/widgets/settings_menu/settings_menu.dart @@ -30,6 +30,8 @@ class SettingsMenu extends StatelessWidget { final Set menuItems = { SettingsMenuValue.general, if (showSecurity) SettingsMenuValue.security, + SettingsMenuValue.privacy, + SettingsMenuValue.kycPolicy, if (context.isFeedbackAvailable) SettingsMenuValue.feedback, }; return FocusTraversalGroup( diff --git a/lib/views/wallet/coin_details/coin_details.dart b/lib/views/wallet/coin_details/coin_details.dart index 2def3288d4..8e18ca4897 100644 --- a/lib/views/wallet/coin_details/coin_details.dart +++ b/lib/views/wallet/coin_details/coin_details.dart @@ -9,6 +9,7 @@ import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; import 'package:web_dex/analytics/events/portfolio_events.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_details_info.dart'; import 'package:web_dex/views/wallet/coin_details/coin_page_type.dart'; import 'package:web_dex/views/wallet/coin_details/rewards/kmd_reward_claim_success.dart'; @@ -61,9 +62,13 @@ class _CoinDetailsState extends State { @override Widget build(BuildContext context) { return BlocProvider( - create: (ctx) => - TransactionHistoryBloc(sdk: ctx.read()) - ..add(TransactionHistorySubscribe(coin: widget.coin)), + create: (ctx) { + final bloc = TransactionHistoryBloc(sdk: ctx.read()); + if (hasTxHistorySupport(widget.coin)) { + bloc.add(TransactionHistorySubscribe(coin: widget.coin)); + } + return bloc; + }, child: BlocBuilder( builder: (context, state) { return GestureDetector( diff --git a/lib/views/wallet/coin_details/coin_details_info/charts/portfolio_growth_chart.dart b/lib/views/wallet/coin_details/coin_details_info/charts/portfolio_growth_chart.dart index caece38db6..08c1dc0813 100644 --- a/lib/views/wallet/coin_details/coin_details_info/charts/portfolio_growth_chart.dart +++ b/lib/views/wallet/coin_details/coin_details_info/charts/portfolio_growth_chart.dart @@ -35,7 +35,9 @@ class _PortfolioGrowthChartState extends State { bool get _isCoinPage => widget.initialCoins.length == 1; double _calculateTotalValue(List chartData) { - return chartData.isNotEmpty ? chartData.last.y : 0.0; + if (chartData.isEmpty) return 0.0; + final value = chartData.last.y; + return value.isFinite ? value : 0.0; } @override @@ -44,23 +46,26 @@ class _PortfolioGrowthChartState extends State { builder: (BuildContext context, PortfolioGrowthState state) { final List chartData = (state is PortfolioGrowthChartLoadSuccess) - ? state.portfolioGrowth - .map((point) => ChartData(x: point.x, y: point.y)) - .toList() - : []; + ? state.portfolioGrowth + .map((point) => ChartData(x: point.x, y: point.y)) + .toList() + : []; final totalValue = _calculateTotalValue(chartData); - final percentageIncrease = state is PortfolioGrowthChartLoadSuccess + final double rawPercentageIncrease = + state is PortfolioGrowthChartLoadSuccess ? state.percentageIncrease : 0.0; + final double percentageIncrease = rawPercentageIncrease.isFinite + ? rawPercentageIncrease + : 0.0; final (dateAxisLabelCount, dateAxisLabelFormat) = - PriceChartPage.dateAxisLabelCountFormat( - state.selectedPeriod, - ); + PriceChartPage.dateAxisLabelCountFormat(state.selectedPeriod); - final isChartLoading = (state is! PortfolioGrowthChartLoadSuccess && + final isChartLoading = + (state is! PortfolioGrowthChartLoadSuccess && state is! PortfolioGrowthChartUnsupported) || (state is PortfolioGrowthChartLoadSuccess && state.isUpdating); @@ -85,13 +90,12 @@ class _PortfolioGrowthChartState extends State { ), leadingIcon: _singleCoinOrNull == null ? null - : AssetIcon.ofTicker( - _singleCoinOrNull!.abbr, - size: 24, - ), + : AssetIcon.ofTicker(_singleCoinOrNull!.abbr, size: 24), leadingText: Text( - NumberFormat.currency(symbol: '\$', decimalDigits: 2) - .format(totalValue), + NumberFormat.currency( + symbol: '\$', + decimalDigits: 2, + ).format(totalValue), ), availableCoins: widget.initialCoins .map( @@ -114,12 +118,12 @@ class _PortfolioGrowthChartState extends State { final user = context.read().state.currentUser; final walletId = user!.wallet.id; context.read().add( - PortfolioGrowthPeriodChanged( - selectedPeriod: selected, - coins: _selectedCoins, - walletId: walletId, - ), - ); + PortfolioGrowthPeriodChanged( + selectedPeriod: selected, + coins: _selectedCoins, + walletId: walletId, + ), + ); }, ), const Gap(16), @@ -128,7 +132,8 @@ class _PortfolioGrowthChartState extends State { elements: [ ChartDataSeries( data: chartData, - color: (_isSingleCoinSelected + color: + (_isSingleCoinSelected ? getCoinColor(_singleCoinOrNull!.abbr) : null) ?? Theme.of(context).colorScheme.primary, @@ -139,20 +144,18 @@ class _PortfolioGrowthChartState extends State { count: 5, labelBuilder: (value) => NumberFormat.compactSimpleCurrency( - // symbol: '\$', - // USD Locale - locale: 'en_US', - // )..maximumFractionDigits = 2 - ).format(value), + // symbol: '\$', + // USD Locale + locale: 'en_US', + // )..maximumFractionDigits = 2 + ).format(value), ), ChartAxisLabels( isVertical: false, count: dateAxisLabelCount, labelBuilder: (value) { return dateAxisLabelFormat.format( - DateTime.fromMillisecondsSinceEpoch( - value.toInt(), - ), + DateTime.fromMillisecondsSinceEpoch(value.toInt()), ); }, ), @@ -201,13 +204,15 @@ class _PortfolioGrowthChartState extends State { final walletId = currentWallet!.id; context.read().add( - PortfolioGrowthPeriodChanged( - selectedPeriod: - context.read().state.selectedPeriod, - coins: newCoins, - walletId: walletId, - ), - ); + PortfolioGrowthPeriodChanged( + selectedPeriod: context + .read() + .state + .selectedPeriod, + coins: newCoins, + walletId: walletId, + ), + ); setState(() => _selectedCoins = newCoins); } @@ -226,8 +231,9 @@ class _PortfolioGrowthChartTooltip extends StatelessWidget { @override Widget build(BuildContext context) { - final date = - DateTime.fromMillisecondsSinceEpoch(dataPoints.first.x.toInt()); + final date = DateTime.fromMillisecondsSinceEpoch( + dataPoints.first.x.toInt(), + ); final isSingleCoinSelected = coins.length == 1; return ChartTooltipContainer( @@ -263,16 +269,18 @@ class _PortfolioGrowthChartTooltip extends StatelessWidget { Text( formatAmt(data.y), style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), ], ); }) else Text( - NumberFormat.currency(symbol: '\$', decimalDigits: 2) - .format(dataPoints.first.y), + NumberFormat.currency( + symbol: '\$', + decimalDigits: 2, + ).format(dataPoints.first.y), style: Theme.of(context).textTheme.bodyMedium, ), ], diff --git a/lib/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart b/lib/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart index 331204adb5..8e95d57bbf 100644 --- a/lib/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart +++ b/lib/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart @@ -41,20 +41,54 @@ class PortfolioProfitLossChartState extends State { String? get walletId => RepositoryProvider.of(context).state.currentUser?.walletId.name; + List _buildChartDataForSelectedPeriod({ + required List sourceData, + required double minChartExtent, + required double maxChartExtent, + }) { + if (sourceData.isEmpty) { + return List.empty(); + } + + ChartData? boundaryPoint; + final filteredChartData = []; + + for (final point in sourceData) { + if (point.x < minChartExtent) { + boundaryPoint = point; + continue; + } + + if (point.x > maxChartExtent) { + break; + } + + filteredChartData.add(point); + } + + if (boundaryPoint != null) { + filteredChartData.insert(0, boundaryPoint); + } + + if (filteredChartData.isNotEmpty) { + filteredChartData.add( + ChartData(x: maxChartExtent, y: filteredChartData.last.y), + ); + } + + return filteredChartData; + } + @override Widget build(BuildContext context) { return BlocBuilder( builder: (BuildContext context, ProfitLossState state) { if (state is ProfitLossLoadFailure) { - return Center( - child: Text(state.error.message), - ); + return Center(child: Text(state.error.message)); } final (dateAxisLabelCount, dateAxisLabelFormat) = - PriceChartPage.dateAxisLabelCountFormat( - state.selectedPeriod, - ); + PriceChartPage.dateAxisLabelCountFormat(state.selectedPeriod); final minChartExtent = DateTime.now() .subtract(state.selectedPeriod) .millisecondsSinceEpoch @@ -64,23 +98,29 @@ class PortfolioProfitLossChartState extends State { final isSuccess = state is PortfolioProfitLossChartLoadSuccess; final isUpdating = state is PortfolioProfitLossChartLoadSuccess && state.isUpdating; - final List chartData = isSuccess + final List sourceData = isSuccess ? state.profitLossChart - .map((point) => ChartData(x: point.x.toDouble(), y: point.y)) - .toList() + .map((point) => ChartData(x: point.x.toDouble(), y: point.y)) + .toList() : List.empty(); + final List chartData = _buildChartDataForSelectedPeriod( + sourceData: sourceData, + minChartExtent: minChartExtent, + maxChartExtent: maxChartExtent, + ); - if (chartData.isNotEmpty) { - chartData.add(ChartData(x: maxChartExtent, y: chartData.last.y)); - } - - final totalValue = isSuccess ? state.totalValue : 0.0; - final percentageIncrease = isSuccess ? state.percentageIncrease : 0.0; - final formattedValue = - '${totalValue >= 0 ? '+' : '-'}${NumberFormat.currency( - symbol: '\$', - decimalDigits: 2, - ).format(totalValue)}'; + final double rawTotalValue = isSuccess ? state.totalValue : 0.0; + final double totalValue = rawTotalValue.isFinite ? rawTotalValue : 0.0; + final double rawPercentageIncrease = isSuccess + ? state.percentageIncrease + : 0.0; + final double percentageIncrease = rawPercentageIncrease.isFinite + ? rawPercentageIncrease + : 0.0; + final bool hasValidTotal = isSuccess && rawTotalValue.isFinite; + final formattedValue = hasValidTotal + ? '${totalValue >= 0 ? '+' : '-'}${NumberFormat.currency(symbol: '\$', decimalDigits: 2).format(totalValue)}' + : '--'; return Card( clipBehavior: Clip.antiAlias, @@ -100,14 +140,12 @@ class PortfolioProfitLossChartState extends State { ), leadingIcon: _singleCoinOrNull == null ? null - : AssetLogo.ofId( - _singleCoinOrNull!.id, - size: 24, - ), + : AssetLogo.ofId(_singleCoinOrNull!.id, size: 24), leadingText: Text(formattedValue), emptySelectAllowed: !_isCoinPage, - availableCoins: - widget.initialCoins.map((coin) => coin.id).toList(), + availableCoins: widget.initialCoins + .map((coin) => coin.id) + .toList(), selectedCoinId: _singleCoinOrNull?.abbr, onCoinSelected: _isCoinPage ? null : _showSpecificCoin, centreAmount: totalValue, @@ -116,45 +154,44 @@ class PortfolioProfitLossChartState extends State { onPeriodChanged: (selected) { if (selected != null) { context.read().add( - ProfitLossPortfolioPeriodChanged( - selectedPeriod: selected, - ), - ); + ProfitLossPortfolioPeriodChanged( + selectedPeriod: selected, + ), + ); } }, ), const Gap(16), Expanded( child: LineChart( - key: const Key('portfolio_profit_loss_chart'), + key: ValueKey( + 'portfolio_profit_loss_chart_${state.selectedPeriod.inMilliseconds}', + ), rangeExtent: const ChartExtent.tight(), elements: [ ChartDataSeries( data: chartData, - color: (_isSingleCoinSelected + color: + (_isSingleCoinSelected ? getCoinColor(_singleCoinOrNull!.abbr) : null) ?? Theme.of(context).colorScheme.primary, ), - ChartGridLines( - isVertical: false, - count: 5, - ), + ChartGridLines(isVertical: false, count: 5), ChartAxisLabels( isVertical: true, count: 5, labelBuilder: (value) => - NumberFormat.compactSimpleCurrency(locale: 'en_US') - .format(value), + NumberFormat.compactSimpleCurrency( + locale: 'en_US', + ).format(value), ), ChartAxisLabels( isVertical: false, count: dateAxisLabelCount, labelBuilder: (value) { return dateAxisLabelFormat.format( - DateTime.fromMillisecondsSinceEpoch( - value.toInt(), - ), + DateTime.fromMillisecondsSinceEpoch(value.toInt()), ); }, ), @@ -201,13 +238,13 @@ class PortfolioProfitLossChartState extends State { final newCoins = coin == null ? widget.initialCoins : [coin]; context.read().add( - ProfitLossPortfolioChartLoadRequested( - coins: newCoins, - fiatCoinId: 'USDT', - selectedPeriod: context.read().state.selectedPeriod, - walletId: walletId!, - ), - ); + ProfitLossPortfolioChartLoadRequested( + coins: newCoins, + fiatCoinId: 'USDT', + selectedPeriod: context.read().state.selectedPeriod, + walletId: walletId!, + ), + ); setState(() => _selectedCoins = newCoins); } @@ -240,11 +277,11 @@ class _PortfolioProfitLossTooltip extends StatelessWidget { Text( '$adjective${NumberFormat.currency(symbol: '\$', decimalDigits: 2).format(portfolioValue.abs())}', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: portfolioValue > 0 - ? Colors.green - : Theme.of(context).colorScheme.error, - fontWeight: FontWeight.w600, - ), + color: portfolioValue > 0 + ? Colors.green + : Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w600, + ), ), ], ), diff --git a/lib/views/wallet/coin_details/coin_details_info/coin_addresses.dart b/lib/views/wallet/coin_details/coin_details_info/coin_addresses.dart index 44e50601fa..def04735b8 100644 --- a/lib/views/wallet/coin_details/coin_details_info/coin_addresses.dart +++ b/lib/views/wallet/coin_details/coin_details_info/coin_addresses.dart @@ -12,9 +12,11 @@ import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; import 'package:web_dex/bloc/coin_addresses/bloc/coin_addresses_bloc.dart'; import 'package:web_dex/bloc/coin_addresses/bloc/coin_addresses_event.dart'; import 'package:web_dex/bloc/coin_addresses/bloc/coin_addresses_state.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; import 'package:web_dex/common/screen.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/constants.dart'; import 'package:web_dex/shared/utils/formatters.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/widgets/coin_type_tag.dart'; @@ -24,7 +26,6 @@ import 'package:web_dex/views/wallet/coin_details/faucet/faucet_button.dart'; import 'package:web_dex/views/wallet/coin_details/receive/trezor_new_address_confirmation.dart'; import 'package:web_dex/views/wallet/common/address_copy_button.dart'; import 'package:web_dex/views/wallet/common/address_icon.dart'; -import 'package:web_dex/views/wallet/common/address_text.dart'; class CoinAddresses extends StatefulWidget { const CoinAddresses({ @@ -42,6 +43,9 @@ class CoinAddresses extends StatefulWidget { class _CoinAddressesState extends State { // No need to store a reference to the bloc since we don't manage its lifecycle + bool _showAllAddresses = false; + + int get _collapsedLimit => isMobile ? 3 : 5; @override void dispose() { @@ -71,6 +75,20 @@ class _CoinAddressesState extends State { } }, builder: (context, state) { + final errorMessage = state.errorMessage?.trim(); + final addresses = state.addresses + .where( + (address) => + !state.hideZeroBalance || + address.balance.spendable != Decimal.zero, + ) + .toList(); + final bool hasMore = addresses.length > _collapsedLimit; + final bool showAll = _showAllAddresses || !hasMore; + final List visibleAddresses = showAll + ? addresses + : addresses.take(_collapsedLimit).toList(); + return SliverToBoxAdapter( child: Column( children: [ @@ -93,13 +111,9 @@ class _CoinAddressesState extends State { state.cantCreateNewAddressReasons, ), const SizedBox(height: 12), - ...state.addresses.asMap().entries.map((entry) { + ...visibleAddresses.asMap().entries.map((entry) { final index = entry.key; final address = entry.value; - if (state.hideZeroBalance && - address.balance.spendable == Decimal.zero) { - return const SizedBox(); - } return AddressCard( address: address, @@ -108,6 +122,22 @@ class _CoinAddressesState extends State { setPageType: widget.setPageType, ); }), + if (hasMore) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: () { + setState(() { + _showAllAddresses = !_showAllAddresses; + }); + }, + child: Text( + _showAllAddresses + ? LocaleKeys.showLessAddresses.tr() + : LocaleKeys.showAllAddresses.tr(), + ), + ), + ), if (state.status == FormStatus.submitting) const Padding( padding: EdgeInsets.symmetric(vertical: 20.0), @@ -121,8 +151,11 @@ class _CoinAddressesState extends State { ), child: Center( child: ErrorDisplay( - message: LocaleKeys.somethingWrong.tr(), - detailedMessage: state.errorMessage, + message: + (errorMessage != null && + errorMessage.isNotEmpty) + ? errorMessage + : LocaleKeys.somethingWrong.tr(), ), ), ), @@ -208,78 +241,38 @@ class AddressCard extends StatelessWidget { margin: const EdgeInsets.only(bottom: 12.0), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.0)), color: theme.custom.dexPageTheme.emptyPlace, - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - vertical: 4.0, - horizontal: 16.0, - ), - leading: isMobile ? null : AddressIcon(address: address.address), - title: isMobile - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - AddressIcon(address: address.address), - const SizedBox(width: 8), - Flexible(child: AddressText(address: address.address)), - const SizedBox(width: 8), - if (coin.id.hasFaucet) - ConstrainedBox( - constraints: BoxConstraints( - minWidth: 80, - maxWidth: isMobile ? 100 : 160, - ), - child: FaucetButton( - coinAbbr: coin.abbr, - address: address, - ), - ), - SwapAddressTag(address: address), - const SizedBox(width: 8), - AddressCopyButton( - address: address.address, - coinAbbr: coin.abbr, - ), - QrButton(coin: coin, address: address), - ], - ), - const SizedBox(height: 12), - _Balance(address: address, coin: coin), - ], + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: isMobile + ? _MobileAddressContent( + address: address, + coin: coin, + onTapAddress: () => + showPubkeyReceiveDialog(context, coin, address), ) - : SizedBox( - width: double.infinity, - child: Row( - children: [ - Flexible(child: AddressText(address: address.address)), - const SizedBox(width: 8), - AddressCopyButton( - address: address.address, - coinAbbr: coin.abbr, - ), - QrButton(coin: coin, address: address), - if (coin.id.hasFaucet) - ConstrainedBox( - constraints: BoxConstraints( - minWidth: 80, - maxWidth: isMobile ? 100 : 160, - ), - child: FaucetButton( - coinAbbr: coin.abbr, - address: address, - ), - ), - SwapAddressTag(address: address), - ], - ), + : _DesktopAddressContent( + address: address, + coin: coin, + onTapAddress: () => + showPubkeyReceiveDialog(context, coin, address), ), - trailing: isMobile ? null : _Balance(address: address, coin: coin), ), ); } } +/// Same receive/QR dialog as [QrButton] and the Receive flow. +void showPubkeyReceiveDialog( + BuildContext context, + Coin coin, + PubkeyInfo address, +) { + showDialog( + context: context, + builder: (context) => PubkeyReceiveDialog(coin: coin, address: address), + ); +} + class _Balance extends StatelessWidget { const _Balance({required this.address, required this.coin}); @@ -288,18 +281,150 @@ class _Balance extends StatelessWidget { @override Widget build(BuildContext context) { + final hideBalances = context.select( + (SettingsBloc bloc) => bloc.state.hideBalances, + ); final balance = address.balance.total.toDouble(); final price = coin.lastKnownUsdPrice(context.sdk); final usdValue = price == null ? null : price * balance; - final fiat = formatUsdValue(usdValue); + final fiat = hideBalances ? maskedBalanceText : formatUsdValue(usdValue); return Text( - '${doubleToString(balance)} ${abbr2Ticker(coin.abbr)} ($fiat)', + hideBalances + ? '$maskedBalanceText ${abbr2Ticker(coin.abbr)} ($fiat)' + : '${doubleToString(balance)} ${abbr2Ticker(coin.abbr)} ($fiat)', style: TextStyle(fontSize: isMobile ? 12 : 14), ); } } +class _MobileAddressContent extends StatelessWidget { + const _MobileAddressContent({ + required this.address, + required this.coin, + required this.onTapAddress, + }); + + final PubkeyInfo address; + final Coin coin; + final VoidCallback onTapAddress; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AddressIcon(address: address.address), + const SizedBox(width: 8), + Expanded( + child: InkWell( + onTap: onTapAddress, + child: TruncatedMiddleText( + address.address, + style: + Theme.of(context).textTheme.bodyMedium ?? + const TextStyle(fontSize: 14), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + AddressCopyButton(address: address.address, coinAbbr: coin.abbr), + QrButton(coin: coin, address: address), + if (coin.id.hasFaucet) + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 80, maxWidth: 160), + child: FaucetButton(coinAbbr: coin.abbr, address: address), + ), + SwapAddressTag(address: address), + ], + ), + const SizedBox(height: 8), + _Balance(address: address, coin: coin), + ], + ); + } +} + +class _DesktopAddressContent extends StatelessWidget { + const _DesktopAddressContent({ + required this.address, + required this.coin, + required this.onTapAddress, + }); + + final PubkeyInfo address; + final Coin coin; + final VoidCallback onTapAddress; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AddressIcon(address: address.address), + const SizedBox(width: 12), + Expanded( + child: InkWell( + onTap: onTapAddress, + child: TruncatedMiddleText( + address.address, + style: + Theme.of(context).textTheme.bodyMedium ?? + const TextStyle(fontSize: 14), + ), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 220, + child: Align( + alignment: Alignment.centerLeft, + child: Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + AddressCopyButton( + address: address.address, + coinAbbr: coin.abbr, + ), + QrButton(coin: coin, address: address), + if (coin.id.hasFaucet) + ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 80, + maxWidth: 160, + ), + child: FaucetButton(coinAbbr: coin.abbr, address: address), + ), + SwapAddressTag(address: address), + ], + ), + ), + ), + const SizedBox(width: 12), + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 140), + child: Align( + alignment: Alignment.centerRight, + child: _Balance(address: address, coin: coin), + ), + ), + ], + ); + } +} + class QrButton extends StatelessWidget { const QrButton({super.key, required this.address, required this.coin}); @@ -316,139 +441,7 @@ class QrButton extends StatelessWidget { splashRadius: 18, icon: const Icon(Icons.qr_code, size: 16), color: Theme.of(context).textTheme.bodyMedium!.color, - onPressed: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - LocaleKeys.receive.tr(), - style: const TextStyle(fontSize: 16), - ), - Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(20), - clipBehavior: Clip.hardEdge, - child: IconButton( - icon: const Icon(Icons.close), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ), - ], - ), - content: SizedBox( - width: 450, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - LocaleKeys.onlySendToThisAddress.tr( - args: [abbr2Ticker(coin.abbr)], - ), - style: const TextStyle(fontSize: 14), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - LocaleKeys.network.tr(), - style: const TextStyle(fontSize: 14), - ), - CoinTypeTag(coin), - ], - ), - ), - QrCode(address: address.address, coinAbbr: coin.abbr), - const SizedBox(height: 16), - // Address row with copy and explorer link - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - // Address text - Expanded( - child: TruncatedMiddleText( - address.address, - style: - Theme.of(context).textTheme.bodySmall ?? - const TextStyle(fontSize: 12), - ), - ), - // Copy button - Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(20), - clipBehavior: Clip.hardEdge, - child: IconButton( - tooltip: LocaleKeys.copyAddressToClipboard.tr( - args: [coin.abbr], - ), - icon: const Icon(Icons.copy_rounded, size: 20), - onPressed: () => copyToClipBoard( - context, - address.address, - LocaleKeys.copiedAddressToClipboard.tr( - args: [coin.abbr], - ), - ), - ), - ), - // Explorer link button - Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(20), - clipBehavior: Clip.hardEdge, - child: IconButton( - tooltip: LocaleKeys.viewOnExplorer.tr(), - icon: const Icon(Icons.open_in_new, size: 20), - onPressed: () { - final url = getAddressExplorerUrl( - coin, - address.address, - ); - if (url.isNotEmpty) { - launchURLString(url, inSeparateTab: true); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - LocaleKeys.explorerUnavailable.tr(), - ), - ), - ); - } - }, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - Text( - LocaleKeys.scanTheQrCode.tr(), - style: Theme.of(context).textTheme.bodyLarge, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - ], - ), - ), - ), - ); - }, + onPressed: () => showPubkeyReceiveDialog(context, coin, address), ), ); } @@ -742,7 +735,10 @@ class QrCode extends StatelessWidget { child: QrImageView( data: address, backgroundColor: Theme.of(context).textTheme.bodyMedium!.color!, - foregroundColor: theme.custom.dexPageTheme.emptyPlace, + eyeStyle: QrEyeStyle(color: theme.custom.dexPageTheme.emptyPlace), + dataModuleStyle: QrDataModuleStyle( + color: theme.custom.dexPageTheme.emptyPlace, + ), version: QrVersions.auto, size: 200.0, errorCorrectionLevel: QrErrorCorrectLevel.H, diff --git a/lib/views/wallet/coin_details/coin_details_info/coin_details_balance_confirmation_controller.dart b/lib/views/wallet/coin_details/coin_details_info/coin_details_balance_confirmation_controller.dart new file mode 100644 index 0000000000..6860105077 --- /dev/null +++ b/lib/views/wallet/coin_details/coin_details_info/coin_details_balance_confirmation_controller.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +typedef FetchBalance = Future Function(); + +/// Confirms coin-details balance before the UI renders numeric values. +/// +/// The controller treats initial cached values as unconfirmed and transitions +/// to confirmed state when: +/// 1) a bootstrap `getBalance` call succeeds, or +/// 2) a stream update arrives after at least one bootstrap attempt. +class CoinDetailsBalanceConfirmationController extends ChangeNotifier { + CoinDetailsBalanceConfirmationController({ + required FetchBalance fetchConfirmedBalance, + BalanceInfo? initialBalance, + this.maxStartupRetries = 2, + this.retryBackoffBase = const Duration(milliseconds: 300), + }) : _fetchConfirmedBalance = fetchConfirmedBalance, + _latestBalance = initialBalance; + + final FetchBalance _fetchConfirmedBalance; + final int maxStartupRetries; + final Duration retryBackoffBase; + + BalanceInfo? _latestBalance; + bool _isConfirmed = false; + bool _isBootstrapInFlight = false; + bool _hasCompletedBootstrapAttempt = false; + int _startupRetryAttempts = 0; + bool _isDisposed = false; + + BalanceInfo? get latestBalance => _latestBalance; + bool get isConfirmed => _isConfirmed; + bool get isBootstrapping => _isBootstrapInFlight; + int get startupRetryAttempts => _startupRetryAttempts; + + Future bootstrap() async { + if (_isDisposed || _isConfirmed || _isBootstrapInFlight) return; + + var didSucceed = false; + _isBootstrapInFlight = true; + _notifyListenersIfAlive(); + + try { + final balance = await _fetchConfirmedBalance(); + if (_isDisposed) return; + _latestBalance = balance; + _isConfirmed = true; + didSucceed = true; + } catch (_) { + // Best effort. Startup errors are handled with bounded retries. + } finally { + _hasCompletedBootstrapAttempt = true; + _isBootstrapInFlight = false; + if (!_isDisposed) { + _notifyListenersIfAlive(); + } + } + + if (!didSucceed && !_isDisposed && !_isConfirmed) { + unawaited(_scheduleStartupRetry()); + } + } + + void onStreamBalance(BalanceInfo balance) { + if (_isDisposed) return; + + _latestBalance = balance; + + if (!_isConfirmed && _hasCompletedBootstrapAttempt) { + _isConfirmed = true; + } + + _notifyListenersIfAlive(); + } + + Future onStartupStreamError() async { + await _scheduleStartupRetry(); + } + + Future _scheduleStartupRetry() async { + if (_isDisposed || _isConfirmed) return; + if (_startupRetryAttempts >= maxStartupRetries) return; + + _startupRetryAttempts += 1; + _notifyListenersIfAlive(); + + final delayMs = retryBackoffBase.inMilliseconds * _startupRetryAttempts; + await Future.delayed(Duration(milliseconds: delayMs)); + + if (_isDisposed || _isConfirmed) return; + await bootstrap(); + } + + void _notifyListenersIfAlive() { + if (_isDisposed) return; + notifyListeners(); + } + + @override + void dispose() { + _isDisposed = true; + super.dispose(); + } +} diff --git a/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart b/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart index 1df71d84c1..48a6490f93 100644 --- a/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart +++ b/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart @@ -256,11 +256,7 @@ class CoinDetailsReceiveButton extends StatelessWidget { ); if (selectedAddress != null && context.mounted) { - showDialog( - context: context, - builder: (context) => - PubkeyReceiveDialog(coin: coin, address: selectedAddress), - ); + showPubkeyReceiveDialog(context, coin, selectedAddress); } } @@ -292,68 +288,6 @@ class CoinDetailsReceiveButton extends StatelessWidget { } } -class AddressListItem extends StatelessWidget { - const AddressListItem({super.key, required this.address, required this.coin}); - - final PubkeyInfo address; - final Coin coin; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - ), - ), - child: Center( - child: Icon( - Icons.account_balance_wallet_outlined, - size: 18, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - address.formatted, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.w500, - letterSpacing: 0.5, - ), - ), - const SizedBox(height: 2), - Text( - '${address.balance.spendable} ${coin.displayName} available', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.7), - ), - ), - ], - ), - ), - ], - ), - ); - } -} - class CoinDetailsSendButton extends StatelessWidget { const CoinDetailsSendButton({ required this.isMobile, diff --git a/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart b/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart index ead0862bb8..9b64188c5f 100644 --- a/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart +++ b/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -16,6 +18,7 @@ import 'package:web_dex/bloc/coin_addresses/bloc/coin_addresses_bloc.dart'; import 'package:web_dex/bloc/coin_addresses/bloc/coin_addresses_event.dart'; import 'package:web_dex/bloc/coin_addresses/bloc/coin_addresses_state.dart'; import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; import 'package:web_dex/bloc/taker_form/taker_bloc.dart'; import 'package:web_dex/bloc/taker_form/taker_event.dart'; import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; @@ -25,6 +28,7 @@ import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/main_menu_value.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/shared/constants.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/widgets/coin_fiat_balance.dart'; import 'package:web_dex/shared/widgets/segwit_icon.dart'; @@ -34,6 +38,7 @@ import 'package:web_dex/views/common/pages/page_layout.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/portfolio_growth_chart.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_addresses.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_details_balance_confirmation_controller.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_details_info_fiat.dart'; import 'package:web_dex/views/wallet/coin_details/coin_page_type.dart'; @@ -57,6 +62,9 @@ class CoinDetailsInfo extends StatefulWidget { class _CoinDetailsInfoState extends State with SingleTickerProviderStateMixin { Transaction? _selectedTransaction; + final ScrollController _scrollController = ScrollController(); + final GlobalKey _transactionsSectionKey = GlobalKey(); + final GlobalKey _addressesSectionKey = GlobalKey(); String? get _walletId => RepositoryProvider.of(context).state.currentUser?.walletId.name; @@ -131,6 +139,11 @@ class _CoinDetailsInfoState extends State selectedTransaction: _selectedTransaction, setPageType: widget.setPageType, setTransaction: _selectTransaction, + scrollController: _scrollController, + transactionsSectionKey: _transactionsSectionKey, + addressesSectionKey: _addressesSectionKey, + onShowTransactions: _scrollToTransactions, + onShowAddresses: _scrollToAddresses, ); } return _DesktopContent( @@ -138,6 +151,11 @@ class _CoinDetailsInfoState extends State selectedTransaction: _selectedTransaction, setPageType: widget.setPageType, setTransaction: _selectTransaction, + scrollController: _scrollController, + transactionsSectionKey: _transactionsSectionKey, + addressesSectionKey: _addressesSectionKey, + onShowTransactions: _scrollToTransactions, + onShowAddresses: _scrollToAddresses, ); } @@ -163,6 +181,25 @@ class _CoinDetailsInfoState extends State }); } + Future _scrollToSection(GlobalKey sectionKey) async { + final targetContext = sectionKey.currentContext; + if (targetContext == null) { + return; + } + + await Scrollable.ensureVisible( + targetContext, + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, + ); + } + + Future _scrollToTransactions() => + _scrollToSection(_transactionsSectionKey); + + Future _scrollToAddresses() => _scrollToSection(_addressesSectionKey); + void _onBackButtonPressed() { if (_haveTransaction) { _selectTransaction(null); @@ -181,6 +218,7 @@ class _CoinDetailsInfoState extends State @override void dispose() { _coinAddressesBloc.close().ignore(); + _scrollController.dispose(); super.dispose(); } } @@ -191,12 +229,22 @@ class _DesktopContent extends StatelessWidget { required this.selectedTransaction, required this.setPageType, required this.setTransaction, + required this.scrollController, + required this.transactionsSectionKey, + required this.addressesSectionKey, + required this.onShowTransactions, + required this.onShowAddresses, }); final Coin coin; final Transaction? selectedTransaction; final void Function(CoinPageType) setPageType; final Function(Transaction?) setTransaction; + final ScrollController scrollController; + final GlobalKey transactionsSectionKey; + final GlobalKey addressesSectionKey; + final Future Function() onShowTransactions; + final Future Function() onShowAddresses; @override Widget build(BuildContext context) { @@ -207,32 +255,55 @@ class _DesktopContent extends StatelessWidget { color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(18.0), ), - child: CustomScrollView( - slivers: [ - if (selectedTransaction == null) - SliverToBoxAdapter( - child: _DesktopCoinDetails(coin: coin, setPageType: setPageType), + child: DexScrollbar( + scrollController: scrollController, + isMobile: isMobile, + child: CustomScrollView( + controller: scrollController, + slivers: [ + if (selectedTransaction == null) + SliverToBoxAdapter( + child: _DesktopCoinDetails( + coin: coin, + setPageType: setPageType, + onShowTransactions: onShowTransactions, + onShowAddresses: onShowAddresses, + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 20)), + TransactionTable( + key: transactionsSectionKey, + coin: coin, + selectedTransaction: selectedTransaction, + setTransaction: setTransaction, ), - const SliverToBoxAdapter(child: SizedBox(height: 20)), - if (selectedTransaction == null) - CoinAddresses(coin: coin, setPageType: setPageType), - const SliverToBoxAdapter(child: SizedBox(height: 20)), - TransactionTable( - coin: coin, - selectedTransaction: selectedTransaction, - setTransaction: setTransaction, - ), - ], + if (selectedTransaction == null) ...[ + const SliverToBoxAdapter(child: SizedBox(height: 20)), + CoinAddresses( + key: addressesSectionKey, + coin: coin, + setPageType: setPageType, + ), + ], + ], + ), ), ); } } class _DesktopCoinDetails extends StatelessWidget { - const _DesktopCoinDetails({required this.coin, required this.setPageType}); + const _DesktopCoinDetails({ + required this.coin, + required this.setPageType, + required this.onShowTransactions, + required this.onShowAddresses, + }); final Coin coin; final void Function(CoinPageType) setPageType; + final Future Function() onShowTransactions; + final Future Function() onShowAddresses; @override Widget build(BuildContext context) { @@ -272,6 +343,12 @@ class _DesktopCoinDetails extends StatelessWidget { coin: coin, ), ), + const Gap(12), + _SectionAnchorChips( + isMobile: false, + onShowTransactions: onShowTransactions, + onShowAddresses: onShowAddresses, + ), const Gap(16), _CoinDetailsMarketMetricsTabBar(coin: coin), ], @@ -286,35 +363,57 @@ class _MobileContent extends StatelessWidget { required this.selectedTransaction, required this.setPageType, required this.setTransaction, + required this.scrollController, + required this.transactionsSectionKey, + required this.addressesSectionKey, + required this.onShowTransactions, + required this.onShowAddresses, }); final Coin coin; final Transaction? selectedTransaction; final void Function(CoinPageType) setPageType; final Function(Transaction?) setTransaction; + final ScrollController scrollController; + final GlobalKey transactionsSectionKey; + final GlobalKey addressesSectionKey; + final Future Function() onShowTransactions; + final Future Function() onShowAddresses; @override Widget build(BuildContext context) { - return CustomScrollView( - slivers: [ - if (selectedTransaction == null) - SliverToBoxAdapter( - child: _CoinDetailsInfoHeader( + return DexScrollbar( + scrollController: scrollController, + isMobile: isMobile, + child: CustomScrollView( + controller: scrollController, + slivers: [ + if (selectedTransaction == null) + SliverToBoxAdapter( + child: _CoinDetailsInfoHeader( + coin: coin, + setPageType: setPageType, + context: context, + onShowTransactions: onShowTransactions, + onShowAddresses: onShowAddresses, + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 20)), + if (selectedTransaction == null) + CoinAddresses( + key: addressesSectionKey, coin: coin, setPageType: setPageType, - context: context, ), + const SliverToBoxAdapter(child: SizedBox(height: 20)), + TransactionTable( + key: transactionsSectionKey, + coin: coin, + selectedTransaction: selectedTransaction, + setTransaction: setTransaction, ), - const SliverToBoxAdapter(child: SizedBox(height: 20)), - if (selectedTransaction == null) - CoinAddresses(coin: coin, setPageType: setPageType), - const SliverToBoxAdapter(child: SizedBox(height: 20)), - TransactionTable( - coin: coin, - selectedTransaction: selectedTransaction, - setTransaction: setTransaction, - ), - ], + ], + ), ); } } @@ -324,11 +423,15 @@ class _CoinDetailsInfoHeader extends StatelessWidget { required this.coin, required this.setPageType, required this.context, + required this.onShowTransactions, + required this.onShowAddresses, }); final Coin coin; final void Function(CoinPageType p1) setPageType; final BuildContext context; + final Future Function() onShowTransactions; + final Future Function() onShowAddresses; @override Widget build(BuildContext context) { @@ -363,6 +466,12 @@ class _CoinDetailsInfoHeader extends StatelessWidget { coin: coin, ), ), + _SectionAnchorChips( + isMobile: true, + onShowTransactions: onShowTransactions, + onShowAddresses: onShowAddresses, + ), + const SizedBox(height: 12), _CoinDetailsMarketMetricsTabBar(coin: coin), ], ), @@ -370,6 +479,52 @@ class _CoinDetailsInfoHeader extends StatelessWidget { } } +class _SectionAnchorChips extends StatelessWidget { + const _SectionAnchorChips({ + required this.isMobile, + required this.onShowTransactions, + required this.onShowAddresses, + }); + + final bool isMobile; + final Future Function() onShowTransactions; + final Future Function() onShowAddresses; + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + final chipBackground = themeData.colorScheme.surfaceContainerHighest; + final chipLabelStyle = themeData.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + ); + + return Align( + alignment: isMobile ? Alignment.center : Alignment.centerLeft, + child: Wrap( + alignment: isMobile ? WrapAlignment.center : WrapAlignment.start, + spacing: 8, + runSpacing: 8, + children: [ + ActionChip( + avatar: const Icon(Icons.receipt_long_outlined, size: 18), + label: Text(LocaleKeys.transactions.tr(), style: chipLabelStyle), + backgroundColor: chipBackground, + side: BorderSide.none, + onPressed: onShowTransactions, + ), + ActionChip( + avatar: const Icon(Icons.account_balance_wallet_outlined, size: 18), + label: Text(LocaleKeys.addresses.tr(), style: chipLabelStyle), + backgroundColor: chipBackground, + side: BorderSide.none, + onPressed: onShowAddresses, + ), + ], + ), + ); + } +} + class _CoinDetailsMarketMetricsTabBar extends StatefulWidget { const _CoinDetailsMarketMetricsTabBar({required this.coin}); @@ -505,20 +660,20 @@ class _CoinDetailsMarketMetricsTabBarState ), ), SizedBox( - height: 340, + height: isMobile ? 340 : 260, child: TabBarView( controller: _tabController, children: [ if (isPortfolioGrowthSupported) SizedBox( width: double.infinity, - height: 340, + height: isMobile ? 340 : 260, child: PortfolioGrowthChart(initialCoins: [widget.coin]), ), if (isProfitLossSupported) SizedBox( width: double.infinity, - height: 340, + height: isMobile ? 340 : 260, child: PortfolioProfitLossChart(initialCoins: [widget.coin]), ), ], @@ -529,15 +684,81 @@ class _CoinDetailsMarketMetricsTabBarState } } -class _Balance extends StatelessWidget { +class _Balance extends StatefulWidget { const _Balance({required this.coin}); final Coin coin; + @override + State<_Balance> createState() => _BalanceState(); +} + +class CoinDetailsBalanceContent extends StatelessWidget { + const CoinDetailsBalanceContent({ + required this.coin, + required this.hideBalances, + required this.isConfirmed, + required this.latestBalance, + this.fiatBalance, + super.key, + }); + + final Coin coin; + final bool hideBalances; + final bool isConfirmed; + final BalanceInfo? latestBalance; + final Widget? fiatBalance; + + Widget _buildGhostValue(ThemeData themeData) { + final style = themeData.textTheme.titleMedium?.copyWith( + fontSize: isMobile ? 25 : 22, + fontWeight: FontWeight.w700, + color: theme.custom.headerFloatBoxColor, + height: 1.1, + ); + + return Row( + mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: isMobile + ? MainAxisAlignment.center + : MainAxisAlignment.start, + children: [ + Container( + key: const Key('coin-details-balance'), + width: isMobile ? 120 : 132, + height: isMobile ? 30 : 24, + decoration: BoxDecoration( + color: (style?.color ?? themeData.colorScheme.onSurface).withValues( + alpha: 0.22, + ), + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(width: 8), + Text( + Coin.normalizeAbbr(coin.abbr), + style: themeData.textTheme.titleSmall!.copyWith( + fontSize: isMobile ? 25 : 20, + fontWeight: FontWeight.w500, + color: theme.custom.headerFloatBoxColor.withValues(alpha: 0.75), + height: 1.1, + ), + ), + ], + ); + } + @override Widget build(BuildContext context) { - final ThemeData themeData = Theme.of(context); - final balance = coin.balance(context.sdk); - final value = balance == null ? null : doubleToString(balance); + final themeData = Theme.of(context); + final showGhost = !hideBalances && !isConfirmed; + final balance = latestBalance?.spendable.toDouble(); + final value = hideBalances + ? maskedBalanceText + : showGhost + ? '' + : balance == null + ? kBalancePlaceholder + : doubleToString(balance); return Column( crossAxisAlignment: isMobile @@ -557,44 +778,117 @@ class _Balance extends StatelessWidget { ), ), Flexible( - child: Row( - mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min, - mainAxisAlignment: isMobile - ? MainAxisAlignment.center - : MainAxisAlignment.start, - children: [ - Flexible( - child: AutoScrollText( - key: const Key('coin-details-balance'), - text: value ?? '', - isSelectable: true, - style: themeData.textTheme.titleMedium!.copyWith( - fontSize: isMobile ? 25 : 22, - fontWeight: FontWeight.w700, - color: theme.custom.headerFloatBoxColor, - height: 1.1, - ), - ), - ), - const SizedBox(width: 5), - Text( - Coin.normalizeAbbr(coin.abbr), - style: themeData.textTheme.titleSmall!.copyWith( - fontSize: isMobile ? 25 : 20, - fontWeight: FontWeight.w500, - color: theme.custom.headerFloatBoxColor, - height: 1.1, + child: showGhost + ? _buildGhostValue(themeData) + : Row( + mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: isMobile + ? MainAxisAlignment.center + : MainAxisAlignment.start, + children: [ + Flexible( + child: AutoScrollText( + key: const Key('coin-details-balance'), + text: value, + isSelectable: true, + style: themeData.textTheme.titleMedium!.copyWith( + fontSize: isMobile ? 25 : 22, + fontWeight: FontWeight.w700, + color: theme.custom.headerFloatBoxColor, + height: 1.1, + ), + ), + ), + const SizedBox(width: 5), + Text( + Coin.normalizeAbbr(coin.abbr), + style: themeData.textTheme.titleSmall!.copyWith( + fontSize: isMobile ? 25 : 20, + fontWeight: FontWeight.w500, + color: theme.custom.headerFloatBoxColor, + height: 1.1, + ), + ), + ], ), - ), - ], - ), ), - if (!isMobile) _FiatBalance(coin: coin), + if (!isMobile && !showGhost) fiatBalance ?? _FiatBalance(coin: coin), ], ); } } +class _BalanceState extends State<_Balance> { + static const int _maxStartupRetries = 2; + + late CoinDetailsBalanceConfirmationController _confirmationController; + StreamSubscription? _balanceSubscription; + + @override + void initState() { + super.initState(); + _initBindings(); + } + + @override + void didUpdateWidget(covariant _Balance oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.coin.id != widget.coin.id) { + _tearDownBindings(); + _initBindings(); + } + } + + void _initBindings() { + final sdk = context.sdk; + _confirmationController = CoinDetailsBalanceConfirmationController( + initialBalance: sdk.balances.lastKnown(widget.coin.id), + fetchConfirmedBalance: () => sdk.balances.getBalance(widget.coin.id), + maxStartupRetries: _maxStartupRetries, + ); + + _balanceSubscription = sdk.balances + .watchBalance(widget.coin.id) + .listen( + _confirmationController.onStreamBalance, + onError: (Object _, StackTrace __) { + unawaited(_confirmationController.onStartupStreamError()); + }, + ); + + unawaited(_confirmationController.bootstrap()); + } + + void _tearDownBindings() { + _balanceSubscription?.cancel(); + _balanceSubscription = null; + _confirmationController.dispose(); + } + + @override + void dispose() { + _tearDownBindings(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final hideBalances = context.select( + (SettingsBloc bloc) => bloc.state.hideBalances, + ); + + return ListenableBuilder( + listenable: _confirmationController, + builder: (context, _) => CoinDetailsBalanceContent( + coin: widget.coin, + hideBalances: hideBalances, + isConfirmed: _confirmationController.isConfirmed, + latestBalance: _confirmationController.latestBalance, + ), + ); + } +} + class _FiatBalance extends StatelessWidget { const _FiatBalance({required this.coin}); final Coin coin; diff --git a/lib/views/wallet/coin_details/coin_details_info/contract_address_button.dart b/lib/views/wallet/coin_details/coin_details_info/contract_address_button.dart index d3f022ab24..1fbb7f1743 100644 --- a/lib/views/wallet/coin_details/coin_details_info/contract_address_button.dart +++ b/lib/views/wallet/coin_details/coin_details_info/contract_address_button.dart @@ -15,18 +15,15 @@ class ContractAddressButton extends StatelessWidget { @override Widget build(BuildContext context) { + final contractAddress = coin.protocolData?.contractAddress ?? ''; + final url = getAddressExplorerUrl(coin, contractAddress); + return Material( color: Theme.of(context).textTheme.bodyMedium?.color?.withAlpha(5), borderRadius: BorderRadius.circular(7), child: InkWell( borderRadius: BorderRadius.circular(7), - onTap: coin.explorerUrl.isEmpty - ? null - : () { - launchURLString( - '${coin.explorerUrl}address/${coin.protocolData?.contractAddress ?? ''}', - ); - }, + onTap: url.isEmpty ? null : () => launchURLString(url), child: isMobile ? _ContractAddressMobile(coin) : _ContractAddressDesktop(coin), @@ -97,11 +94,7 @@ class _ContractAddressDesktop extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.only( - left: 13.0, - right: 13.0, - bottom: 5, - ), + padding: const EdgeInsets.only(left: 13.0, right: 13.0, bottom: 5), child: _ContractAddressValue(coin), ), ], @@ -119,19 +112,14 @@ class _ContractAddressValue extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.max, children: [ - AssetIcon.ofTicker( - coin.protocolData?.platform ?? '', - size: 12, - ), - const SizedBox( - width: 3, - ), + AssetIcon.ofTicker(coin.protocolData?.platform ?? '', size: 12), + const SizedBox(width: 3), Text( '${coin.protocolData?.platform ?? ''} ', - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(fontWeight: FontWeight.w500, fontSize: 11), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 11, + ), ), Flexible( child: TruncatedMiddleText( @@ -177,14 +165,12 @@ class _ContractAddressTitle extends StatelessWidget { return Text( LocaleKeys.contractAddress.tr(), style: Theme.of(context).textTheme.titleSmall!.copyWith( - fontSize: 9, - fontWeight: FontWeight.w500, - color: Theme.of(context) - .textTheme - .bodyMedium - ?.color - ?.withValues(alpha: .45), - ), + fontSize: 9, + fontWeight: FontWeight.w500, + color: Theme.of( + context, + ).textTheme.bodyMedium?.color?.withValues(alpha: .45), + ), ); } } diff --git a/lib/views/wallet/coin_details/faucet/faucet_view.dart b/lib/views/wallet/coin_details/faucet/faucet_view.dart index 2f305f7dd2..c6e87a4adf 100644 --- a/lib/views/wallet/coin_details/faucet/faucet_view.dart +++ b/lib/views/wallet/coin_details/faucet/faucet_view.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -36,10 +38,7 @@ class FaucetView extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ _DialogHeader(title: title(state), onClose: onClose), - _StatesOfPage( - state: state, - onClose: onClose, - ), + _StatesOfPage(state: state, onClose: onClose), ], ); }, @@ -77,10 +76,7 @@ class _DialogHeader extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 40), - child: Text( - title, - style: Theme.of(context).textTheme.titleLarge, - ), + child: Text(title, style: Theme.of(context).textTheme.titleLarge), ), Positioned( right: -8, @@ -128,18 +124,61 @@ class _StatesOfPage extends StatelessWidget { } } -class _Loading extends StatelessWidget { +class _Loading extends StatefulWidget { const _Loading(); + @override + State<_Loading> createState() => _LoadingState(); +} + +class _LoadingState extends State<_Loading> { + static const _dotInterval = Duration(milliseconds: 450); + Timer? _timer; + int _dotCount = 0; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(_dotInterval, (_) { + if (!mounted) return; + setState(() { + _dotCount = (_dotCount + 1) % 4; + }); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return const Column( - crossAxisAlignment: CrossAxisAlignment.start, + final dots = '.' * _dotCount; + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - SizedBox(height: 28), - Center(child: UiSpinner()), - SizedBox(height: 28), + const SizedBox(height: 24), + const Center(child: UiSpinner()), + const SizedBox(height: 16), + Text( + '${LocaleKeys.faucetLoadingMessage.tr()}$dots', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + LocaleKeys.faucetLoadingSubtitle.tr(), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), ], ); } @@ -169,8 +208,9 @@ class _FaucetResult extends StatelessWidget { child: Container( margin: const EdgeInsets.symmetric(vertical: 15.0), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(40), - border: Border.all(color: color, width: 4)), + borderRadius: BorderRadius.circular(40), + border: Border.all(color: color, width: 4), + ), child: Icon(icon, size: 66, color: color), ), ), diff --git a/lib/views/wallet/coin_details/rewards/kmd_rewards_info.dart b/lib/views/wallet/coin_details/rewards/kmd_rewards_info.dart index 9093895b0f..48d4fc2dab 100644 --- a/lib/views/wallet/coin_details/rewards/kmd_rewards_info.dart +++ b/lib/views/wallet/coin_details/rewards/kmd_rewards_info.dart @@ -170,9 +170,7 @@ class _KmdRewardsInfoState extends State { ?.withValues(alpha: 0.4), ), ), - const SizedBox( - height: 30.0, - ), + const SizedBox(height: 30.0), UiBorderButton( width: 160, height: 38, @@ -204,19 +202,16 @@ class _KmdRewardsInfoState extends State { } Widget _buildMessage() { - final String message = - _successMessage.isEmpty ? _errorMessage : _successMessage; + final String message = _successMessage.isEmpty + ? _errorMessage + : _successMessage; return message.isEmpty ? const SizedBox.shrink() : Container( margin: const EdgeInsets.only(top: 20), padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all( - color: _messageColor, - ), - ), + decoration: BoxDecoration(border: Border.all(color: _messageColor)), child: SelectableText( message, style: TextStyle(color: _messageColor), @@ -380,8 +375,10 @@ class _KmdRewardsInfoState extends State { alignment: const Alignment(-1, 0), child: Text( LocaleKeys.status.tr(), - style: - const TextStyle(fontWeight: FontWeight.w700, fontSize: 12), + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 12, + ), ), ), ), @@ -418,16 +415,17 @@ class _KmdRewardsInfoState extends State { }); context.read().logEvent( - RewardClaimInitiatedEventData( - asset: widget.coin.abbr, - expectedRewardAmount: _totalReward ?? 0, - ), - ); + RewardClaimInitiatedEventData( + asset: widget.coin.abbr, + expectedRewardAmount: _totalReward ?? 0, + ), + ); final coinsRepository = RepositoryProvider.of(context); final kmdRewardsBloc = RepositoryProvider.of(context); - final BlocResponse response = - await kmdRewardsBloc.claim(context); + final BlocResponse response = await kmdRewardsBloc.claim( + context, + ); final BaseError? error = response.error; if (error != null) { setState(() { @@ -435,11 +433,11 @@ class _KmdRewardsInfoState extends State { _errorMessage = error.message; }); context.read().logEvent( - RewardClaimFailureEventData( - asset: widget.coin.abbr, - failReason: error.message, - ), - ); + RewardClaimFailureEventData( + asset: widget.coin.abbr, + failReason: error.message, + ), + ); return; } @@ -447,20 +445,24 @@ class _KmdRewardsInfoState extends State { context.read().add(CoinsBalancesRefreshed()); await _updateInfoUntilSuccessOrTimeOut(30000); - final String reward = - doubleToString(double.tryParse(response.result!) ?? 0); - final double? usdPrice = - coinsRepository.getUsdPriceByAmount(response.result!, 'KMD'); + final String reward = doubleToString( + double.tryParse(response.result!) ?? 0, + ); + final rewardAmount = double.tryParse(response.result!) ?? 0; + final double? usdPrice = coinsRepository.getUsdPriceForAmount( + rewardAmount, + 'KMD', + ); final String formattedUsdPrice = cutTrailingZeros(formatAmt(usdPrice ?? 0)); setState(() { _isClaiming = false; }); context.read().logEvent( - RewardClaimSuccessEventData( - asset: widget.coin.abbr, - rewardAmount: double.tryParse(response.result!) ?? 0, - ), - ); + RewardClaimSuccessEventData( + asset: widget.coin.abbr, + rewardAmount: rewardAmount, + ), + ); widget.onSuccess(reward, formattedUsdPrice); } @@ -479,8 +481,9 @@ class _KmdRewardsInfoState extends State { Future _updateInfoUntilSuccessOrTimeOut(int timeOut) async { _updateTimer ??= DateTime.now().millisecondsSinceEpoch; - final List prevRewards = - List.from(_rewards ?? []); + final List prevRewards = List.from( + _rewards ?? [], + ); await _updateRewardsInfo(); @@ -502,8 +505,10 @@ class _KmdRewardsInfoState extends State { final kmdRewardsBloc = RepositoryProvider.of(context); final double? total = await kmdRewardsBloc.getTotal(context); final List currentRewards = await kmdRewardsBloc.getInfo(); - final double? totalUsd = - coinsRepository.getUsdPriceByAmount((total ?? 0).toString(), 'KMD'); + final double? totalUsd = coinsRepository.getUsdPriceForAmount( + total ?? 0, + 'KMD', + ); if (!mounted) return; setState(() { diff --git a/lib/views/wallet/coin_details/transactions/transaction_details.dart b/lib/views/wallet/coin_details/transactions/transaction_details.dart index 43ecc5fda2..cce8900c23 100644 --- a/lib/views/wallet/coin_details/transactions/transaction_details.dart +++ b/lib/views/wallet/coin_details/transactions/transaction_details.dart @@ -12,19 +12,22 @@ import 'package:web_dex/model/coin.dart'; import 'package:web_dex/shared/utils/formatters.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/widgets/copied_text.dart'; -import 'package:web_dex/views/wallet/common/address_copy_button.dart'; class TransactionDetails extends StatelessWidget { const TransactionDetails({ - Key? key, required this.transaction, required this.onClose, required this.coin, - }) : super(key: key); + this.usdPriceResolver, + this.onLaunchExplorer, + super.key, + }); final Transaction transaction; final void Function() onClose; final Coin coin; + final double? Function(num amount, String coinAbbr)? usdPriceResolver; + final void Function(String url)? onLaunchExplorer; @override Widget build(BuildContext context) { @@ -96,13 +99,13 @@ class TransactionDetails extends StatelessWidget { _buildSimpleData( context, title: LocaleKeys.confirmations.tr(), - value: transaction.confirmations.toString(), + value: _confirmationsLabel(context), hasBackground: true, ), _buildSimpleData( context, title: LocaleKeys.blockHeight.tr(), - value: transaction.blockHeight.toString(), + value: _blockHeightLabel(), ), _buildSimpleData( context, @@ -140,77 +143,38 @@ class TransactionDetails extends StatelessWidget { ); } - Widget _buildAddress( - BuildContext context, { - required String title, - required String address, - }) { - return Container( - margin: const EdgeInsets.only(bottom: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Title with fixed flex - Expanded( - flex: 2, - child: Text( - title, - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(fontSize: 14), - ), - ), - // Address and copy button - Expanded( - flex: 5, - child: Row( - children: [ - Expanded( - child: AutoScrollText( - text: address, - style: const TextStyle(fontSize: 14), - ), - ), - const SizedBox(width: 8), - AddressCopyButton(address: address), - ], - ), - ), - ], - ), - ); + String _confirmationsLabel(BuildContext context) { + final confirmations = transaction.confirmations; + if (confirmations > 0) { + return confirmations.toString(); + } + + if (transaction.blockHeight > 0) { + return '0'; + } + + return LocaleKeys.inProgress.tr(); } - Widget _buildAddresses(bool isMobile, BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.only(bottom: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildAddress( - context, - title: LocaleKeys.from.tr(), - address: transaction.from.first, - ), - _buildAddress( - context, - title: LocaleKeys.to.tr(), - address: transaction.to.first, - ), - ], - ), - ); + String _blockHeightLabel() { + if (transaction.blockHeight > 0) { + return transaction.blockHeight.toString(); + } + return LocaleKeys.unknown.tr(); } Widget _buildBalanceChanges(BuildContext context) { final String formatted = formatDexAmt(transaction.amount.toDouble().abs()); final String sign = transaction.amount.toDouble() > 0 ? '+' : '-'; - final coinsBloc = RepositoryProvider.of(context); - final double? usd = coinsBloc.getUsdPriceByAmount( - formatted, - transaction.assetId.id, - ); + final double? usd = + usdPriceResolver?.call( + transaction.amount.toDouble().abs(), + transaction.assetId.id, + ) ?? + RepositoryProvider.of(context).getUsdPriceForAmount( + transaction.amount.toDouble().abs(), + transaction.assetId.id, + ); final String formattedUsd = formatAmt(usd ?? 0); final String value = '$sign $formatted ${Coin.normalizeAbbr(transaction.assetId.id)} (\$$formattedUsd)'; @@ -241,7 +205,12 @@ class TransactionDetails extends StatelessWidget { color: theme.custom.defaultGradientButtonTextColor, ), onPressed: () { - launchURLString(getTxExplorerUrl(coin, transaction.txHash ?? '')); + final url = getTxExplorerUrl(coin, transaction.txHash ?? ''); + if (onLaunchExplorer != null) { + onLaunchExplorer!(url); + return; + } + launchURLString(url); }, text: LocaleKeys.viewOnExplorer.tr(), ), @@ -262,18 +231,33 @@ class TransactionDetails extends StatelessWidget { } Widget _buildFee(BuildContext context) { - final coinsRepository = RepositoryProvider.of(context); + final String title = LocaleKeys.fees.tr(); - final String formattedFee = transaction.fee?.formatTotal() ?? ''; - final double? usd = coinsRepository.getUsdPriceByAmount( - formattedFee, - _feeCoin, - ); - final String formattedUsd = formatAmt(usd ?? 0); + final String value; + final TextStyle? valueStyle; - final String title = LocaleKeys.fees.tr(); - final String value = - '- ${Coin.normalizeAbbr(_feeCoin)} $formattedFee (\$$formattedUsd)'; + if (transaction.fee == null) { + value = '\u2014'; + valueStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + ); + } else { + final fee = transaction.fee!; + final String feeAmount = formatDexAmt(fee.totalFee.toDouble()); + final double? usd = + usdPriceResolver?.call(fee.totalFee.toDouble(), _feeCoin) ?? + RepositoryProvider.of( + context, + ).getUsdPriceForAmount(fee.totalFee.toDouble(), _feeCoin); + final String formattedUsd = formatAmt(usd ?? 0); + value = '- ${Coin.normalizeAbbr(_feeCoin)} $feeAmount (\$$formattedUsd)'; + valueStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + color: theme.custom.decreaseColor, + ); + } return Padding( padding: const EdgeInsets.only(bottom: 15.0), @@ -295,14 +279,7 @@ class TransactionDetails extends StatelessWidget { child: Container( constraints: const BoxConstraints(maxHeight: 35), alignment: Alignment.centerLeft, - child: SelectableText( - value, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontSize: 14, - fontWeight: FontWeight.w500, - color: theme.custom.decreaseColor, - ), - ), + child: SelectableText(value, style: valueStyle), ), ), ], diff --git a/lib/views/wallet/coin_details/transactions/transaction_list_item.dart b/lib/views/wallet/coin_details/transactions/transaction_list_item.dart index 70303b94b9..b61ccd81f1 100644 --- a/lib/views/wallet/coin_details/transactions/transaction_list_item.dart +++ b/lib/views/wallet/coin_details/transactions/transaction_list_item.dart @@ -1,4 +1,5 @@ import 'package:app_theme/app_theme.dart'; +import 'package:decimal/decimal.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -11,7 +12,6 @@ import 'package:web_dex/shared/ui/custom_tooltip.dart'; import 'package:web_dex/shared/utils/formatters.dart'; import 'package:web_dex/views/wallet/common/address_copy_button.dart'; import 'package:web_dex/views/wallet/common/address_icon.dart'; -import 'package:web_dex/views/wallet/common/address_text.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; class TransactionListRow extends StatefulWidget { @@ -35,7 +35,30 @@ class _TransactionListRowState extends State { return _isReceived ? Icons.arrow_circle_down : Icons.arrow_circle_up; } - bool get _isReceived => widget.transaction.amount.toDouble() > 0; + Decimal get _displayAmount { + final tx = widget.transaction; + final netChange = tx.amount; + if (netChange != Decimal.zero) { + return netChange; + } + + final received = tx.balanceChanges.receivedByMe; + final spent = tx.balanceChanges.spentByMe; + if (received != Decimal.zero || spent != Decimal.zero) { + if (received >= spent) { + return received; + } + return -spent; + } + + if (tx.balanceChanges.totalAmount != Decimal.zero) { + return tx.balanceChanges.totalAmount; + } + + return Decimal.zero; + } + + bool get _isReceived => _displayAmount > Decimal.zero; String get _sign { return _isReceived ? '+' : '-'; @@ -79,16 +102,12 @@ class _TransactionListRowState extends State { Widget _buildAmountChangesMobile(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildBalanceChanges(), - _buildUsdChanges(), - ], + children: [_buildBalanceChanges(), _buildUsdChanges()], ); } Widget _buildBalanceChanges() { - final String formatted = - formatDexAmt(widget.transaction.amount.toDouble().abs()); + final String formatted = formatDexAmt(_displayAmount.toDouble().abs()); return Row( children: [ @@ -178,9 +197,7 @@ class _TransactionListRowState extends State { flex: 5, child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildBalanceChangesMobile(context), - ], + children: [_buildBalanceChangesMobile(context)], ), ), Expanded( @@ -220,10 +237,7 @@ class _TransactionListRowState extends State { width: 60, child: Text( _isReceived ? LocaleKeys.receive.tr() : LocaleKeys.send.tr(), - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 14, - ), + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14), ), ), Expanded(flex: 4, child: _buildBalanceChanges()), @@ -266,8 +280,8 @@ class _TransactionListRowState extends State { Widget _buildUsdChanges() { final coinsBloc = context.read(); - final double? usdChanges = coinsBloc.state.getUsdPriceByAmount( - widget.transaction.amount.toString(), + final double? usdChanges = coinsBloc.state.getUsdPriceForAmount( + _displayAmount.toDouble(), widget.coinAbbr, ); return AutoScrollText( diff --git a/lib/views/wallet/coin_details/transactions/transaction_table.dart b/lib/views/wallet/coin_details/transactions/transaction_table.dart index 9be7d35a0c..204d92d188 100644 --- a/lib/views/wallet/coin_details/transactions/transaction_table.dart +++ b/lib/views/wallet/coin_details/transactions/transaction_table.dart @@ -2,14 +2,15 @@ import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui/komodo_ui.dart' show showAddressSearch; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/coin_addresses/bloc/coin_addresses_bloc.dart'; import 'package:web_dex/bloc/transaction_history/transaction_history_bloc.dart'; import 'package:web_dex/bloc/transaction_history/transaction_history_state.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/shared/utils/utils.dart'; -import 'package:web_dex/shared/widgets/launch_native_explorer_button.dart'; import 'package:web_dex/views/wallet/coin_details/transactions/transaction_details.dart'; import 'package:web_dex/views/wallet/coin_details/transactions/transaction_list.dart'; @@ -165,14 +166,56 @@ class _IguanaCoinWithoutTxHistorySupport extends StatelessWidget { : super(key: key); final Coin coin; + Future _openExplorer(BuildContext context) async { + final addressesBloc = context.read(); + final addresses = addressesBloc.state.addresses; + if (addresses.isEmpty) { + return; + } + + final PubkeyInfo? selected = addresses.length > 1 + ? await showAddressSearch( + context, + addresses: addresses, + assetNameLabel: coin.abbr, + ) + : addresses.first; + + if (selected == null || !context.mounted) { + return; + } + + final url = getNativeExplorerUrlByCoin(coin, selected.address); + if (url.isEmpty) { + return; + } + launchURLString(url); + } + @override Widget build(BuildContext context) { + final explorerEnabled = context.select((bloc) { + final addresses = bloc.state.addresses; + if (addresses.isEmpty) { + return false; + } + return getNativeExplorerUrlByCoin( + coin, + addresses.first.address, + ).isNotEmpty; + }); + return Column( children: [ Text(LocaleKeys.noTxSupportHidden.tr(), textAlign: TextAlign.center), Padding( padding: const EdgeInsets.only(top: 10.0), - child: LaunchNativeExplorerButton(coin: coin), + child: UiPrimaryButton( + width: 160, + height: 30, + onPressed: explorerEnabled ? () => _openExplorer(context) : null, + text: LocaleKeys.viewOnExplorer.tr(), + ), ), ], ); diff --git a/lib/views/wallet/coin_details/withdraw_form/pages/failed_page.dart b/lib/views/wallet/coin_details/withdraw_form/pages/failed_page.dart index 4f47e28438..8dda0ddd48 100644 --- a/lib/views/wallet/coin_details/withdraw_form/pages/failed_page.dart +++ b/lib/views/wallet/coin_details/withdraw_form/pages/failed_page.dart @@ -43,11 +43,12 @@ class _SendErrorText extends StatelessWidget { @override Widget build(BuildContext context) { return Text( - LocaleKeys.tryAgain.tr(), + LocaleKeys.errorTryAgainSupportHint.tr(), style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontSize: 14, - color: Theme.of(context).colorScheme.error, - ), + fontSize: 14, + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, ); } } @@ -79,11 +80,9 @@ class _SendErrorBody extends StatelessWidget { // TODO: Confirm this is the correct error selector: (state) => state.transactionError, builder: (BuildContext context, error) { - final iconColor = Theme.of(context) - .textTheme - .bodyMedium - ?.color - ?.withValues(alpha: .7); + final iconColor = Theme.of( + context, + ).textTheme.bodyMedium?.color?.withValues(alpha: .7); return Material( color: theme.custom.buttonColorDefault, @@ -103,11 +102,7 @@ class _SendErrorBody extends StatelessWidget { children: [ Expanded(child: _MultilineText(error?.error ?? '')), const SizedBox(width: 16), - Icon( - Icons.copy_rounded, - color: iconColor, - size: 22, - ), + Icon(Icons.copy_rounded, color: iconColor, size: 22), ], ), ), diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart index 717fbf92df..1bbfa2683f 100644 --- a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart @@ -158,15 +158,19 @@ class CustomFeeToggle extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - return SwitchListTile( - title: Text(LocaleKeys.customFeeToggleTitle.tr()), - value: state.isCustomFee, - onChanged: (value) { - context.read().add( - WithdrawFormCustomFeeEnabled(value), - ); - }, - contentPadding: EdgeInsets.zero, + return Row( + children: [ + UiSwitcher( + value: state.isCustomFee, + onChanged: (value) { + context.read().add( + WithdrawFormCustomFeeEnabled(value), + ); + }, + ), + const SizedBox(width: 10), + Expanded(child: Text(LocaleKeys.customFeeToggleTitle.tr())), + ], ); }, ); @@ -478,12 +482,23 @@ class FailurePage extends StatelessWidget { style: Theme.of(context).textTheme.bodyMedium, ), ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + LocaleKeys.errorTryAgainSupportHint.tr(), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), const SizedBox(height: 24), OutlinedButton( onPressed: () => context.read().add( const WithdrawFormCancelled(), ), - child: Text(LocaleKeys.tryAgain.tr()), + child: Text(LocaleKeys.tryAgainButton.tr()), ), ], ); @@ -499,15 +514,29 @@ class IbcTransferField extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - return SwitchListTile( - title: Text(LocaleKeys.ibcTransferFieldTitle.tr()), - subtitle: Text(LocaleKeys.ibcTransferFieldSubtitle.tr()), - value: state.isIbcTransfer, - onChanged: (value) { - context.read().add( - WithdrawFormIbcTransferEnabled(value), - ); - }, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + UiSwitcher( + value: state.isIbcTransfer, + onChanged: (value) { + context.read().add( + WithdrawFormIbcTransferEnabled(value), + ); + }, + ), + const SizedBox(width: 10), + Expanded(child: Text(LocaleKeys.ibcTransferFieldTitle.tr())), + ], + ), + const SizedBox(height: 4), + Text( + LocaleKeys.ibcTransferFieldSubtitle.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + ], ); }, ); diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_memo.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_memo.dart index 778841d971..d09503379b 100644 --- a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_memo.dart +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_memo.dart @@ -16,6 +16,19 @@ class WithdrawMemoField extends StatelessWidget { @override Widget build(BuildContext context) { + final TextStyle memoStyle = + Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamilyFallback: const [ + 'Noto Sans', + 'Roboto', + 'Arial', + 'sans-serif', + ], + ) ?? + const TextStyle( + fontSize: 14, + fontFamilyFallback: ['Noto Sans', 'Roboto', 'Arial', 'sans-serif'], + ); return UiTextFormField( key: const Key('withdraw-form-memo-field'), initialValue: memo, @@ -29,6 +42,7 @@ class WithdrawMemoField extends StatelessWidget { counterText: '', hintText: LocaleKeys.memoOptional.tr(), hintTextStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + style: memoStyle, ); } } diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart index 4a75d966a9..6684a547fa 100644 --- a/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart @@ -2,7 +2,6 @@ import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:komodo_ui/komodo_ui.dart'; import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; import 'package:web_dex/common/screen.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; @@ -57,15 +56,17 @@ class SendCompleteForm extends StatelessWidget { color: theme.custom.headerFloatBoxColor, ), ), - const SizedBox(height: 5), - SelectableText( - '\$${state.usdAmountPrice ?? 0}', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: theme.custom.headerFloatBoxColor, + if (state.usdAmountPrice != null) ...[ + const SizedBox(height: 5), + SelectableText( + '\$${state.usdAmountPrice}', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: theme.custom.headerFloatBoxColor, + ), ), - ), + ], ], ), if (state.hasTransactionError) @@ -75,7 +76,7 @@ class SendCompleteForm extends StatelessWidget { ), if (state.result?.txHash != null) _TransactionHash( - feeValue: feeValue!.formatTotal(), + feeValue: feeValue!.totalFee.toString(), feeCoin: feeValue.coin, txHash: state.result!.txHash, usdFeePrice: state.usdFeePrice, @@ -101,9 +102,7 @@ class _SendCompleteError extends StatelessWidget { child: Text( error.message, textAlign: TextAlign.left, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), + style: TextStyle(color: Theme.of(context).colorScheme.error), ), ); } @@ -134,7 +133,7 @@ class _TransactionHash extends StatelessWidget { title: '${LocaleKeys.fee.tr()}:', value: '${truncateDecimal(feeValue, decimalRange)} ${Coin.normalizeAbbr(feeCoin)}', - usdPrice: usdFeePrice ?? 0, + usdPrice: usdFeePrice, isWarningShown: isFeePriceExpensive, ), const SizedBox(height: 21), diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form.dart index f4a70a082c..b4f04cc363 100644 --- a/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form.dart +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form.dart @@ -52,13 +52,13 @@ class SendConfirmForm extends StatelessWidget { SendConfirmItem( title: '${LocaleKeys.amount.tr()}:', value: amountString, - usdPrice: state.usdAmountPrice ?? 0, + usdPrice: state.usdAmountPrice, ), const SizedBox(height: 26), SendConfirmItem( title: '${LocaleKeys.fee.tr()}:', value: feeString ?? '', - usdPrice: state.usdFeePrice ?? 0, + usdPrice: state.usdFeePrice, isWarningShown: isFeePriceExpensive, ), if (state.memo != null) diff --git a/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart b/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart index 6e8d1cf110..a142efb476 100644 --- a/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart +++ b/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart @@ -1,5 +1,6 @@ import 'dart:async' show Timer; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -8,9 +9,14 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui/komodo_ui.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/analytics/events/transaction_events.dart'; +import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; +import 'package:web_dex/common/screen.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; +import 'package:web_dex/bloc/transaction_history/transaction_history_bloc.dart'; +import 'package:web_dex/bloc/transaction_history/transaction_history_event.dart'; import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; @@ -20,19 +26,26 @@ import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/utils/extensions/kdf_user_extensions.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/widgets/asset_amount_with_fiat.dart'; -import 'package:web_dex/shared/widgets/copied_text.dart' show CopiedTextV2; +import 'package:web_dex/shared/widgets/copied_text.dart' + show CopiedText, CopiedTextV2; import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart'; import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_memo.dart'; import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/trezor_withdraw_progress_dialog.dart'; import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/withdraw_form_header.dart'; -import 'package:decimal/decimal.dart'; -import 'package:web_dex/views/wallet/coin_details/transactions/transaction_details.dart'; bool _isMemoSupportedProtocol(Asset asset) { final protocol = asset.protocol; return protocol is TendermintProtocol || protocol is ZhtlcProtocol; } +AssetId _resolveFeeAssetId(BuildContext context, Asset asset, FeeInfo fee) { + if (fee.coin.isEmpty || fee.coin == asset.id.id) { + return asset.id; + } + + return context.sdk.getSdkAsset(fee.coin).id; +} + class WithdrawForm extends StatefulWidget { final Asset asset; final VoidCallback onSuccess; @@ -54,6 +67,7 @@ class _WithdrawFormState extends State { late final _sdk = context.read(); bool _suppressPreviewError = false; late final _mm2Api = context.read(); + Timer? _transactionRefreshTimer; @override void initState() { @@ -70,6 +84,7 @@ class _WithdrawFormState extends State { @override void dispose() { + _transactionRefreshTimer?.cancel(); _formBloc.close(); super.dispose(); } @@ -81,7 +96,9 @@ class _WithdrawFormState extends State { child: MultiBlocListener( listeners: [ BlocListener( - listenWhen: (prev, curr) => prev.previewError != curr.previewError && curr.previewError != null, + listenWhen: (prev, curr) => + prev.previewError != curr.previewError && + curr.previewError != null, listener: (context, state) async { // If a preview failed and the user entered essentially their entire // spendable balance (but didn't select Max), offer to deduct the fee @@ -104,8 +121,14 @@ class _WithdrawFormState extends State { return diff <= epsilon; } - if (spendable != null && entered != null && amountsMatchWithTolerance(entered, spendable)) { - if (mounted) setState(() { _suppressPreviewError = true; }); + if (spendable != null && + entered != null && + amountsMatchWithTolerance(entered, spendable)) { + if (mounted) { + setState(() { + _suppressPreviewError = true; + }); + } final bloc = context.read(); final agreed = await showDialog( context: context, @@ -127,7 +150,11 @@ class _WithdrawFormState extends State { ), ); - if (mounted) setState(() { _suppressPreviewError = false; }); + if (mounted) { + setState(() { + _suppressPreviewError = false; + }); + } if (agreed == true) { bloc.add(const WithdrawFormMaxAmountEnabled(true)); @@ -150,6 +177,23 @@ class _WithdrawFormState extends State { hdType: walletType, ), ); + + final coin = context + .read() + .state + .coins + .values + .firstWhereOrNull((coin) => coin.id == state.asset.id); + if (coin == null) return; + + _transactionRefreshTimer?.cancel(); + _transactionRefreshTimer = Timer(const Duration(seconds: 2), () { + if (!mounted) return; + if (!hasTxHistorySupport(coin)) return; + context.read().add( + TransactionHistorySubscribe(coin: coin), + ); + }); }, ), BlocListener( @@ -252,7 +296,9 @@ class WithdrawFormContent extends StatelessWidget { Widget _buildStep(WithdrawFormStep step) { switch (step) { case WithdrawFormStep.fill: - return WithdrawFormFillSection(suppressPreviewError: suppressPreviewError); + return WithdrawFormFillSection( + suppressPreviewError: suppressPreviewError, + ); case WithdrawFormStep.confirm: return const WithdrawFormConfirmSection(); case WithdrawFormStep.success: @@ -373,192 +419,488 @@ class ZhtlcPreviewDelayNote extends StatelessWidget { } class WithdrawPreviewDetails extends StatelessWidget { - final WithdrawalPreview preview; - final double widthThreshold; - final double minPadding; - final double maxPadding; + const WithdrawPreviewDetails({required this.state, super.key}); + + final WithdrawFormState state; + + @override + Widget build(BuildContext context) { + final preview = state.preview!; + + return LayoutBuilder( + builder: (context, constraints) { + final useWideLayout = constraints.maxWidth >= 560; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _WithdrawSectionCard( + child: _WithdrawPreviewSummary( + state: state, + preview: preview, + useWideLayout: useWideLayout, + ), + ), + const SizedBox(height: 16), + _WithdrawSectionCard( + child: _WithdrawPreviewDestination( + state: state, + preview: preview, + useWideLayout: useWideLayout, + ), + ), + if (preview.fee is FeeInfoTron) ...[ + const SizedBox(height: 16), + _WithdrawTronDetailsCard(fee: preview.fee as FeeInfoTron), + ], + ], + ); + }, + ); + } +} - const WithdrawPreviewDetails({ +class _WithdrawPreviewSummary extends StatelessWidget { + const _WithdrawPreviewSummary({ + required this.state, required this.preview, - super.key, - this.widthThreshold = 400, - this.minPadding = 2, - this.maxPadding = 16, + required this.useWideLayout, }); - double _calculatePadding(double width) { - if (width >= widthThreshold) { - return maxPadding; - } + final WithdrawFormState state; + final WithdrawalPreview preview; + final bool useWideLayout; - // Scale padding linearly based on width below threshold - final ratio = width / widthThreshold; - final scaledPadding = minPadding + (maxPadding - minPadding) * ratio; + Color _warningBackground(BuildContext context) { + final theme = Theme.of(context); + return Colors.amber.withValues( + alpha: theme.brightness == Brightness.dark ? 0.22 : 0.16, + ); + } - return scaledPadding.clamp(minPadding, maxPadding); + Color _warningForeground(BuildContext context) { + final theme = Theme.of(context); + return theme.brightness == Brightness.dark + ? Colors.amber.shade200 + : Colors.amber.shade900; } @override Widget build(BuildContext context) { - final sdk = context.sdk; - - final assets = sdk.getSdkAsset(preview.coin); - final feeAssets = sdk.getSdkAsset(preview.fee.coin); - - return LayoutBuilder( - builder: (context, constraints) { - final padding = _calculatePadding(constraints.maxWidth); - final useRowLayout = constraints.maxWidth >= widthThreshold; + final theme = Theme.of(context); + final feeAssetId = _resolveFeeAssetId(context, state.asset, preview.fee); + final labelStyle = theme.textTheme.labelLarge?.copyWith( + color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.72), + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ); + final amountStyle = theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + height: 1.1, + ); + final feeStyle = theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + height: 1.15, + ); - return Card( - child: Padding( - padding: EdgeInsets.all(padding), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (useRowLayout) - _buildRow( - LocaleKeys.amount.tr(), - AssetAmountWithFiat( - assetId: assets.id, - // netchange for withdrawals is expected to be negative - // so we display the absolute value here to avoid - // confusion with the negative sign - amount: preview.balanceChanges.netChange.abs(), - isAutoScrollEnabled: true, - ), - ) - else ...[ - Text( - LocaleKeys.amount.tr(), - style: Theme.of(context).textTheme.labelLarge, - ), + final leftContent = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + AssetLogo.ofId(state.asset.id, size: 42), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(LocaleKeys.youSend.tr(), style: labelStyle), const SizedBox(height: 4), - AssetAmountWithFiat( - assetId: assets.id, - amount: preview.balanceChanges.netChange, - isAutoScrollEnabled: true, - ), - ], - const SizedBox(height: 16), - if (useRowLayout) - _buildRow( - LocaleKeys.fee.tr(), - AssetAmountWithFiat( - assetId: feeAssets.id, - amount: preview.fee.totalFee, - isAutoScrollEnabled: true, - ), - ) - else ...[ Text( - LocaleKeys.fee.tr(), - style: Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(height: 4), - AssetAmountWithFiat( - assetId: feeAssets.id, - amount: preview.fee.totalFee, - isAutoScrollEnabled: true, + state.asset.id.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), ], - const SizedBox(height: 16), - if (useRowLayout) - _buildRow( - LocaleKeys.recipientAddress.tr(), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - for (final recipient in preview.to) - CopiedTextV2(copiedValue: recipient, fontSize: 14), - ], + ), + ), + ], + ), + const SizedBox(height: 20), + AssetAmountWithFiat( + assetId: state.asset.id, + amount: preview.balanceChanges.netChange.abs(), + style: amountStyle, + isAutoScrollEnabled: false, + ), + ], + ); + + final rightContent = Container( + width: useWideLayout ? null : double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(LocaleKeys.fee.tr(), style: labelStyle)), + if (state.isFeePriceExpensive) + Chip( + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + label: Text( + LocaleKeys.withdrawHighFee.tr(), + style: theme.textTheme.labelSmall?.copyWith( + color: _warningForeground(context), + fontWeight: FontWeight.w700, ), - ) - else ...[ - Text( - LocaleKeys.recipientAddress.tr(), - style: Theme.of(context).textTheme.labelLarge, ), - const SizedBox(height: 4), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - for (final recipient in preview.to) - CopiedTextV2(copiedValue: recipient, fontSize: 14), - ], + backgroundColor: _warningBackground(context), + side: BorderSide.none, + ), + ], + ), + const SizedBox(height: 12), + AssetAmountWithFiat( + assetId: feeAssetId, + amount: preview.fee.totalFee, + style: feeStyle, + isAutoScrollEnabled: false, + ), + ], + ), + ); + + if (!useWideLayout) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [leftContent, const SizedBox(height: 16), rightContent], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 7, child: leftContent), + const SizedBox(width: 16), + Expanded(flex: 4, child: rightContent), + ], + ); + } +} + +class _WithdrawPreviewDestination extends StatelessWidget { + const _WithdrawPreviewDestination({ + required this.state, + required this.preview, + required this.useWideLayout, + }); + + final WithdrawFormState state; + final WithdrawalPreview preview; + final bool useWideLayout; + + Widget _buildAddressCard( + BuildContext context, { + required IconData icon, + required String label, + required Widget child, + }) { + final theme = Theme.of(context); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: theme.colorScheme.primary.withValues(alpha: 0.04), + border: Border.all(color: theme.dividerColor.withValues(alpha: 0.35)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18), + const SizedBox(width: 8), + Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.textTheme.bodySmall?.color?.withValues( + alpha: 0.72, ), - ], - if (preview.memo?.isNotEmpty ?? false) ...[ - const SizedBox(height: 16), - if (useRowLayout) - _buildRow( - LocaleKeys.memo.tr(), - Text( - preview.memo!, - textAlign: TextAlign.right, - softWrap: true, - overflow: TextOverflow.visible, - ), - ) - else ...[ - Text( - LocaleKeys.memo.tr(), - style: Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(height: 4), - Text( - preview.memo!, - textAlign: TextAlign.left, - softWrap: true, - overflow: TextOverflow.visible, - ), - ], - ], - ], + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 12), + child, + ], + ), + ); + } + + Widget _buildSourceAddress(BuildContext context) { + final sourceAddress = state.selectedSourceAddress?.address; + final theme = Theme.of(context); + + if (sourceAddress == null || sourceAddress.isEmpty) { + return Text( + state.asset.id.name, + style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), + ); + } + + return CopiedTextV2( + copiedValue: sourceAddress, + fontSize: 13, + iconSize: 14, + backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.08), + textColor: theme.textTheme.bodyLarge?.color, + ); + } + + Widget _buildRecipientAddresses(BuildContext context) { + final theme = Theme.of(context); + + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final recipient in preview.to) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: CopiedTextV2( + copiedValue: recipient, + fontSize: 13, + iconSize: 14, + backgroundColor: theme.colorScheme.primary.withValues( + alpha: 0.08, + ), + textColor: theme.textTheme.bodyLarge?.color, ), ), - ); - }, + ], ); } - Widget _buildRow(String label, Widget value) { - return Row( + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final destinationTitle = Text( + LocaleKeys.withdrawDestination.tr(), + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ); + final routeIcon = Icon( + useWideLayout ? Icons.arrow_forward_rounded : Icons.south_rounded, + color: theme.colorScheme.primary, + size: 24, + ); + + final sourceCard = _buildAddressCard( + context, + icon: Icons.account_balance_wallet_outlined, + label: LocaleKeys.from.tr(), + child: _buildSourceAddress(context), + ); + final recipientCard = _buildAddressCard( + context, + icon: Icons.place_outlined, + label: LocaleKeys.to.tr(), + child: _buildRecipientAddresses(context), + ); + + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(flex: 2, child: Text(label)), - const SizedBox(width: 12), - Expanded( - flex: 3, - child: Align(alignment: Alignment.centerRight, child: value), - ), + destinationTitle, + const SizedBox(height: 16), + if (useWideLayout) + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: sourceCard), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: routeIcon, + ), + Expanded(child: recipientCard), + ], + ) + else ...[ + sourceCard, + const SizedBox(height: 12), + Center(child: routeIcon), + const SizedBox(height: 12), + recipientCard, + ], + if (preview.memo?.isNotEmpty ?? false) ...[ + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer.withValues( + alpha: 0.35, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.memo.tr(), + style: theme.textTheme.labelLarge?.copyWith( + color: theme.textTheme.bodySmall?.color?.withValues( + alpha: 0.72, + ), + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + SelectableText(preview.memo!, style: theme.textTheme.bodyLarge), + ], + ), + ), + ], ], ); } } -class WithdrawResultDetails extends StatelessWidget { - final WithdrawalResult result; +class _WithdrawTronDetailsCard extends StatelessWidget { + const _WithdrawTronDetailsCard({required this.fee}); + + final FeeInfoTron fee; + + String _formatDecimal(Decimal value, {int precision = 8}) { + return value.toStringAsFixed(precision).replaceAll(RegExp(r'\.?0+$'), ''); + } + + Widget _buildDetailRow( + BuildContext context, { + required String label, + required String value, + TextStyle? valueStyle, + }) { + final theme = Theme.of(context); - const WithdrawResultDetails({required this.result, super.key}); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 4, + child: Text( + value, + textAlign: TextAlign.right, + style: valueStyle ?? theme.textTheme.bodyMedium, + ), + ), + ], + ), + ); + } @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final totalFee = fee.totalFee; + final paidInCoin = LocaleKeys.withdrawTronFeePaidIn.tr(args: [fee.coin]); + final bandwidthSource = fee.bandwidthFee > Decimal.zero + ? paidInCoin + : LocaleKeys.withdrawTronBandwidthCovered.tr(); + final energySource = fee.energyUsed == 0 + ? LocaleKeys.withdrawTronResourceNotUsed.tr() + : fee.energyFee > Decimal.zero + ? paidInCoin + : LocaleKeys.withdrawTronEnergyCovered.tr(); + final chargeSummary = totalFee > Decimal.zero + ? LocaleKeys.withdrawTronFeeSummaryCharged.tr( + args: [_formatDecimal(totalFee), fee.coin], + ) + : LocaleKeys.withdrawTronFeeSummaryCovered.tr(args: [fee.coin]); + return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + margin: EdgeInsets.zero, + child: Theme( + data: theme.copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + title: Text( + LocaleKeys.withdrawNetworkDetails.tr(), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(chargeSummary, style: theme.textTheme.bodySmall), + ), children: [ - SelectableText( - LocaleKeys.transactionHash.tr(), - style: Theme.of(context).textTheme.bodySmall, + _buildDetailRow( + context, + label: LocaleKeys.withdrawTronBandwidthUsed.tr(), + value: '${fee.bandwidthUsed}', + ), + _buildDetailRow( + context, + label: LocaleKeys.withdrawTronBandwidthFee.tr(), + value: '${_formatDecimal(fee.bandwidthFee)} ${fee.coin}', + ), + _buildDetailRow( + context, + label: LocaleKeys.withdrawTronBandwidthSource.tr(), + value: bandwidthSource, + valueStyle: theme.textTheme.bodySmall, + ), + _buildDetailRow( + context, + label: LocaleKeys.withdrawTronEnergyUsed.tr(), + value: '${fee.energyUsed}', + ), + _buildDetailRow( + context, + label: LocaleKeys.withdrawTronEnergyFee.tr(), + value: '${_formatDecimal(fee.energyFee)} ${fee.coin}', + ), + if (fee.accountCreationFee != null) + _buildDetailRow( + context, + label: LocaleKeys.withdrawTronAccountActivationFee.tr(), + value: '${_formatDecimal(fee.accountCreationFee!)} ${fee.coin}', + ), + _buildDetailRow( + context, + label: LocaleKeys.withdrawTronEnergySource.tr(), + value: energySource, + valueStyle: theme.textTheme.bodySmall, + ), + _buildDetailRow( + context, + label: LocaleKeys.withdrawTronFeeSummary.tr(), + value: chargeSummary, + valueStyle: theme.textTheme.bodySmall, ), - const SizedBox(height: 4), - SelectableText(result.txHash), - // Add more result details as needed ], ), ), @@ -566,15 +908,33 @@ class WithdrawResultDetails extends StatelessWidget { } } +class _WithdrawSectionCard extends StatelessWidget { + const _WithdrawSectionCard({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Card( + margin: EdgeInsets.zero, + child: Padding(padding: const EdgeInsets.all(20), child: child), + ); + } +} + class WithdrawFormFillSection extends StatelessWidget { final bool suppressPreviewError; - const WithdrawFormFillSection({required this.suppressPreviewError, super.key}); + const WithdrawFormFillSection({ + required this.suppressPreviewError, + super.key, + }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { + final isEditingLocked = state.isSending; final isSourceInputEnabled = // Enabled if the asset has multiple source addresses or if there is // no selected address and pubkeys are available. @@ -585,111 +945,136 @@ class WithdrawFormFillSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SourceAddressField( - asset: state.asset, - pubkeys: state.pubkeys, - selectedAddress: state.selectedSourceAddress, - isLoading: state.pubkeys?.isEmpty ?? true, - onChanged: isSourceInputEnabled - ? (address) => address == null - ? null - : context.read().add( - WithdrawFormSourceChanged(address), - ) - : null, - ), - const SizedBox(height: 16), - RecipientAddressWithNotification( - address: state.recipientAddress, - isMixedAddress: state.isMixedCaseAddress, - onChanged: (value) => context.read().add( - WithdrawFormRecipientChanged(value), - ), - onQrScanned: (value) => context.read().add( - WithdrawFormRecipientChanged(value), - ), - errorText: state.recipientAddressError == null - ? null - : () => state.recipientAddressError?.message, - ), - const SizedBox(height: 16), - if (state.asset.protocol is TendermintProtocol) ...[ - const IbcTransferField(), - if (state.isIbcTransfer) ...[ - const SizedBox(height: 16), - const IbcChannelField(), - ], - const SizedBox(height: 16), - ], - WithdrawAmountField( - asset: state.asset, - amount: state.amount, - isMaxAmount: state.isMaxAmount, - onChanged: (value) => context.read().add( - WithdrawFormAmountChanged(value), - ), - onMaxToggled: (value) => context.read().add( - WithdrawFormMaxAmountEnabled(value), - ), - amountError: state.amountError?.message, - ), - if (state.isCustomFeeSupported) ...[ - const SizedBox(height: 16), - Row( + IgnorePointer( + key: const Key('withdraw-form-fill-input-lock'), + ignoring: isEditingLocked, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Checkbox( - value: state.isCustomFee, - onChanged: (enabled) => context + SourceAddressField( + asset: state.asset, + pubkeys: state.pubkeys, + selectedAddress: state.selectedSourceAddress, + isLoading: state.pubkeys?.isEmpty ?? true, + onChanged: isSourceInputEnabled + ? (address) => address == null + ? null + : context.read().add( + WithdrawFormSourceChanged(address), + ) + : null, + ), + const SizedBox(height: 16), + RecipientAddressWithNotification( + address: state.recipientAddress, + isMixedAddress: state.isMixedCaseAddress, + onChanged: (value) => context.read().add( + WithdrawFormRecipientChanged(value), + ), + onQrScanned: (value) => context .read() - .add(WithdrawFormCustomFeeEnabled(enabled ?? false)), + .add(WithdrawFormRecipientChanged(value)), + errorText: state.recipientAddressError == null + ? null + : () => state.recipientAddressError?.message, ), - Text(LocaleKeys.customNetworkFee.tr()), - ], - ), - if (state.isCustomFee && state.customFee != null) ...[ - const SizedBox(height: 8), - - FeeInfoInput( - asset: state.asset, - selectedFee: state.customFee!, - isCustomFee: true, // indicates user can edit it - onFeeSelected: (newFee) { - context.read().add( - WithdrawFormCustomFeeChanged(newFee!), - ); - }, - ), - - // If the bloc has an error for custom fees: - if (state.customFeeError != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - state.customFeeError!.message, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - fontSize: 12, - ), + const SizedBox(height: 16), + if (state.asset.protocol is TendermintProtocol) ...[ + const IbcTransferField(), + if (state.isIbcTransfer) ...[ + const SizedBox(height: 16), + const IbcChannelField(), + ], + const SizedBox(height: 16), + ], + WithdrawAmountField( + asset: state.asset, + amount: state.amount, + isMaxAmount: state.isMaxAmount, + onChanged: (value) => context.read().add( + WithdrawFormAmountChanged(value), ), + onMaxToggled: (value) => context + .read() + .add(WithdrawFormMaxAmountEnabled(value)), + amountError: state.amountError?.message, ), - ], - ], - const SizedBox(height: 16), - if (_isMemoSupportedProtocol(state.asset)) ...[ - WithdrawMemoField( - memo: state.memo, - onChanged: (value) => context.read().add( - WithdrawFormMemoChanged(value), - ), + if (state.isPriorityFeeSupported) ...[ + const SizedBox(height: 16), + WithdrawalPrioritySelector( + feeOptions: state.feeOptions, + selectedPriority: state.selectedFeePriority, + onPriorityChanged: (priority) { + context.read().add( + WithdrawFormFeePriorityChanged(priority), + ); + }, + onCustomFeeSelected: () { + context.read().add( + const WithdrawFormCustomFeeEnabled(true), + ); + }, + ), + ] else if (state.isCustomFeeSupported) ...[ + const SizedBox(height: 16), + Row( + children: [ + Checkbox( + value: state.isCustomFee, + onChanged: (enabled) => + context.read().add( + WithdrawFormCustomFeeEnabled(enabled ?? false), + ), + ), + Text(LocaleKeys.customNetworkFee.tr()), + ], + ), + ], + if (state.isCustomFeeSupported && + state.isCustomFee && + state.customFee != null) ...[ + const SizedBox(height: 8), + FeeInfoInput( + asset: state.asset, + selectedFee: state.customFee!, + isCustomFee: true, // indicates user can edit it + onFeeSelected: (newFee) { + context.read().add( + WithdrawFormCustomFeeChanged(newFee!), + ); + }, + ), + if (state.customFeeError != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + state.customFeeError!.message, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ), + ), + ], + const SizedBox(height: 16), + if (_isMemoSupportedProtocol(state.asset)) ...[ + WithdrawMemoField( + memo: state.memo, + onChanged: (value) => context + .read() + .add(WithdrawFormMemoChanged(value)), + ), + ], + ], ), - ], + ), const SizedBox(height: 24), // TODO! Refactor to use Formz and replace with the appropriate // error state value. if (state.hasPreviewError && !suppressPreviewError) ErrorDisplay( - message: LocaleKeys.withdrawPreviewError.tr(), - detailedMessage: state.previewError!.message, + message: state.previewError!.message, + detailedMessage: state.previewError!.technicalDetails, ), const SizedBox(height: 16), PreviewWithdrawButton( @@ -729,6 +1114,154 @@ class WithdrawFormFillSection extends StatelessWidget { class WithdrawFormConfirmSection extends StatelessWidget { const WithdrawFormConfirmSection({super.key}); + Color _warningBackground(BuildContext context) { + final theme = Theme.of(context); + return Colors.amber.withValues( + alpha: theme.brightness == Brightness.dark ? 0.22 : 0.16, + ); + } + + Color _warningForeground(BuildContext context) { + final theme = Theme.of(context); + return theme.brightness == Brightness.dark + ? Colors.amber.shade200 + : Colors.amber.shade900; + } + + Widget? _buildStatusBanner(BuildContext context, WithdrawFormState state) { + if (!state.isTronAsset && + !state.isPreviewRefreshing && + state.confirmStepError == null) { + return null; + } + + final theme = Theme.of(context); + late final Color backgroundColor; + late final Color foregroundColor; + late final IconData icon; + late final String message; + final showSpinner = state.isPreviewRefreshing; + + if (state.isPreviewRefreshing) { + backgroundColor = theme.colorScheme.secondaryContainer; + foregroundColor = theme.colorScheme.onSecondaryContainer; + icon = Icons.refresh_rounded; + message = LocaleKeys.withdrawPreviewRefreshing.tr(); + } else if (state.confirmStepError != null || state.isPreviewExpired) { + backgroundColor = theme.colorScheme.errorContainer; + foregroundColor = theme.colorScheme.onErrorContainer; + icon = Icons.warning_amber_rounded; + message = + state.confirmStepError?.message ?? + LocaleKeys.withdrawTronPreviewExpired.tr(); + } else if (state.previewSecondsRemaining != null) { + final isExpiringSoon = state.previewSecondsRemaining! <= 10; + backgroundColor = isExpiringSoon + ? _warningBackground(context) + : theme.colorScheme.primaryContainer; + foregroundColor = isExpiringSoon + ? _warningForeground(context) + : theme.colorScheme.onPrimaryContainer; + icon = Icons.schedule_rounded; + message = LocaleKeys.withdrawPreviewExpiresIn.tr( + args: [state.previewSecondsRemaining.toString()], + ); + } else { + return null; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (showSpinner) + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: foregroundColor, + ), + ) + else + Icon(icon, color: foregroundColor), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + Widget _buildActions( + BuildContext context, { + required WithdrawFormState state, + required bool hasExpiredPreviewAction, + required bool isSubmitDisabled, + }) { + final backButton = OutlinedButton( + onPressed: state.isSending || state.isPreviewRefreshing + ? null + : () => context.read().add( + const WithdrawFormStepReverted(), + ), + child: Text(LocaleKeys.back.tr()), + ); + final primaryButton = FilledButton( + onPressed: hasExpiredPreviewAction + ? () { + context.read().add( + const WithdrawFormTronPreviewRefreshRequested(), + ); + } + : isSubmitDisabled + ? null + : () { + context.read().add( + const WithdrawFormSubmitted(), + ); + }, + child: state.isSending || state.isPreviewRefreshing + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + hasExpiredPreviewAction + ? LocaleKeys.withdrawTronPreviewRegenerate.tr() + : LocaleKeys.send.tr(), + ), + ); + + if (isMobile) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [primaryButton, const SizedBox(height: 12), backButton], + ); + } + + return Row( + children: [ + Expanded(child: backButton), + const SizedBox(width: 16), + Expanded(child: primaryButton), + ], + ); + } + @override Widget build(BuildContext context) { return BlocBuilder( @@ -737,41 +1270,32 @@ class WithdrawFormConfirmSection extends StatelessWidget { return const Center(child: CircularProgressIndicator()); } + final hasExpiredPreviewAction = + state.isTronAsset && + !state.isPreviewRefreshing && + (state.isPreviewExpired || state.hasConfirmStepError); + final isSubmitDisabled = + state.isSending || + state.isPreviewRefreshing || + (state.isTronAsset && + (state.previewSecondsRemaining == null || + state.previewSecondsRemaining == 0)); + final statusBanner = _buildStatusBanner(context, state); + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - WithdrawPreviewDetails(preview: state.preview!), + WithdrawPreviewDetails(state: state), + if (statusBanner != null) ...[ + const SizedBox(height: 16), + statusBanner, + ], const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => context.read().add( - const WithdrawFormCancelled(), - ), - child: Text(LocaleKeys.back.tr()), - ), - ), - const SizedBox(width: 16), - Expanded( - child: FilledButton( - onPressed: state.isSending - ? null - : () { - context.read().add( - const WithdrawFormSubmitted(), - ); - }, - child: state.isSending - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(LocaleKeys.send.tr()), - ), - ), - ], + _buildActions( + context, + state: state, + hasExpiredPreviewAction: hasExpiredPreviewAction, + isSubmitDisabled: isSubmitDisabled, ), ], ); @@ -789,29 +1313,13 @@ class WithdrawFormSuccessSection extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - // Build a temporary Transaction model matching history view expectations final result = state.result!; - final tx = Transaction( - id: result.txHash, - internalId: result.txHash, - assetId: state.asset.id, - balanceChanges: result.balanceChanges, - // Show as unconfirmed initially - timestamp: DateTime.fromMillisecondsSinceEpoch(0), - confirmations: 0, - blockHeight: 0, - from: state.selectedSourceAddress != null - ? [state.selectedSourceAddress!.address] - : [], - to: [result.toAddress], - txHash: result.txHash, - fee: result.fee, - memo: state.memo, - ); - return TransactionDetails( - transaction: tx, - coin: state.asset.toCoin(), + return WithdrawSuccessReceipt( + asset: state.asset, + result: result, + sourceAddress: state.selectedSourceAddress?.address, + memo: state.memo, onClose: onDone, ); }, @@ -819,75 +1327,271 @@ class WithdrawFormSuccessSection extends StatelessWidget { } } -class WithdrawResultCard extends StatelessWidget { - final WithdrawalResult result; - final Asset asset; - - const WithdrawResultCard({ - required this.result, +class WithdrawSuccessReceipt extends StatelessWidget { + const WithdrawSuccessReceipt({ required this.asset, + required this.result, + required this.onClose, + this.sourceAddress, + this.memo, super.key, }); - @override - Widget build(BuildContext context) { - final maybeTxEplorer = asset.protocol.explorerTxUrl(result.txHash); - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHashSection(context), - const Divider(height: 32), - _buildNetworkSection(context), - if (maybeTxEplorer != null) ...[ - const SizedBox(height: 16), - OutlinedButton.icon( - onPressed: () => openUrl(maybeTxEplorer), - icon: const Icon(Icons.open_in_new), - label: Text(LocaleKeys.viewOnExplorer.tr()), - ), - ], - ], - ), - ), + final Asset asset; + final WithdrawalResult result; + final String? sourceAddress; + final String? memo; + final VoidCallback onClose; + + Widget _buildActions(BuildContext context, Uri? explorerUrl) { + final doneButton = explorerUrl == null + ? FilledButton(onPressed: onClose, child: Text(LocaleKeys.done.tr())) + : OutlinedButton(onPressed: onClose, child: Text(LocaleKeys.done.tr())); + + if (explorerUrl == null) { + return SizedBox(width: double.infinity, child: doneButton); + } + + final explorerButton = FilledButton.icon( + onPressed: () => openUrl(explorerUrl), + icon: const Icon(Icons.open_in_new_rounded), + label: Text(LocaleKeys.viewOnExplorer.tr()), ); - } - Widget _buildHashSection(BuildContext context) { - final theme = Theme.of(context); + if (isMobile) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [explorerButton, const SizedBox(height: 12), doneButton], + ); + } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Row( children: [ - Text( - LocaleKeys.transactionHash.tr(), - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 8), - SelectableText( - result.txHash, - style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'Mono'), - ), + Expanded(child: explorerButton), + const SizedBox(width: 16), + Expanded(child: doneButton), ], ); } - Widget _buildNetworkSection(BuildContext context) { + Widget _buildDetailItem( + BuildContext context, { + required String label, + required Widget child, + }) { final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.72), + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + child, + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final explorerUrl = asset.protocol.explorerTxUrl(result.txHash); + final feeAssetId = _resolveFeeAssetId(context, asset, result.fee); + return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(LocaleKeys.network.tr(), style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - Row( - children: [ - AssetLogo.ofId(asset.id), - const SizedBox(width: 8), - Text(asset.id.name, style: theme.textTheme.bodyLarge), - ], + _WithdrawSectionCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.check_circle_rounded, + size: 64, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + LocaleKeys.successPageHeadline.tr(), + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + AssetLogo.ofId(asset.id, size: 52), + const SizedBox(height: 12), + Center( + child: AssetAmountWithFiat( + assetId: asset.id, + amount: result.amount, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + height: 1.1, + ), + isAutoScrollEnabled: false, + ), + ), + const SizedBox(height: 8), + Text( + asset.id.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 20), + Align( + alignment: Alignment.centerLeft, + child: Text( + LocaleKeys.recipientAddress.tr(), + style: theme.textTheme.labelLarge?.copyWith( + color: theme.textTheme.bodySmall?.color?.withValues( + alpha: 0.72, + ), + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 10), + Align( + alignment: Alignment.centerLeft, + child: CopiedTextV2( + copiedValue: result.toAddress, + fontSize: 13, + iconSize: 14, + backgroundColor: theme.colorScheme.primary.withValues( + alpha: 0.08, + ), + textColor: theme.textTheme.bodyLarge?.color, + ), + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: Chip( + padding: EdgeInsets.zero, + avatar: Icon( + Icons.schedule_rounded, + size: 18, + color: theme.colorScheme.onPrimaryContainer, + ), + label: Text( + LocaleKeys.withdrawAwaitingConfirmations.tr(), + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w700, + ), + ), + backgroundColor: theme.colorScheme.primaryContainer, + side: BorderSide.none, + ), + ), + const SizedBox(height: 24), + _buildActions(context, explorerUrl), + ], + ), + ), + const SizedBox(height: 16), + Card( + margin: EdgeInsets.zero, + child: Theme( + data: theme.copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + title: Text( + LocaleKeys.technicalDetails.tr(), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + children: [ + _buildDetailItem( + context, + label: LocaleKeys.transactionHash.tr(), + child: CopiedText( + copiedValue: result.txHash, + isTruncated: true, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + ), + ), + if (sourceAddress?.isNotEmpty ?? false) + _buildDetailItem( + context, + label: LocaleKeys.from.tr(), + child: CopiedText( + copiedValue: sourceAddress!, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + ), + ), + _buildDetailItem( + context, + label: LocaleKeys.to.tr(), + child: CopiedText( + copiedValue: result.toAddress, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + ), + ), + _buildDetailItem( + context, + label: LocaleKeys.fee.tr(), + child: AssetAmountWithFiat( + assetId: feeAssetId, + amount: result.fee.totalFee, + isAutoScrollEnabled: false, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (memo?.isNotEmpty ?? false) + _buildDetailItem( + context, + label: LocaleKeys.memo.tr(), + child: SelectableText( + memo!, + style: theme.textTheme.bodyLarge, + ), + ), + _buildDetailItem( + context, + label: LocaleKeys.network.tr(), + child: Row( + children: [ + AssetLogo.ofId(asset.id, size: 28), + const SizedBox(width: 10), + Text( + asset.id.name, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), ), ], ); @@ -897,19 +1601,52 @@ class WithdrawResultCard extends StatelessWidget { class WithdrawFormFailedSection extends StatelessWidget { const WithdrawFormFailedSection({super.key}); + static Future _openSupportContact() async { + try { + await openUrl(discordInviteUrl); + } catch (_) { + // Avoid surfacing launch failures as another error state. + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); return BlocBuilder( builder: (context, state) { + final supportLink = TextButton( + onPressed: _openSupportContact, + child: Text(LocaleKeys.support.tr()), + ); + + final backButton = OutlinedButton( + onPressed: () => context.read().add( + const WithdrawFormStepReverted(), + ), + child: Text(LocaleKeys.back.tr()), + ); + + final tryAgainButton = FilledButton( + onPressed: () => + context.read().add(const WithdrawFormReset()), + child: Text(LocaleKeys.tryAgainButton.tr()), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Icon(Icons.error_outline, size: 64, color: theme.colorScheme.error), + Center( + child: Icon( + Icons.error_outline, + size: 64, + color: theme.colorScheme.error, + ), + ), const SizedBox(height: 24), Text( LocaleKeys.transactionFailed.tr(), - style: theme.textTheme.headlineMedium?.copyWith( + style: theme.textTheme.headlineSmall?.copyWith( color: theme.colorScheme.error, ), textAlign: TextAlign.center, @@ -917,25 +1654,36 @@ class WithdrawFormFailedSection extends StatelessWidget { const SizedBox(height: 24), if (state.transactionError != null) WithdrawErrorCard(error: state.transactionError!), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton( - onPressed: () => context.read().add( - const WithdrawFormStepReverted(), - ), - child: Text(LocaleKeys.back.tr()), - ), - const SizedBox(width: 16), - FilledButton( - onPressed: () => context.read().add( - const WithdrawFormReset(), - ), - child: Text(LocaleKeys.tryAgain.tr()), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + LocaleKeys.errorTryAgainSupportHint.tr(), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - ], + textAlign: TextAlign.center, + ), ), + const SizedBox(height: 24), + if (isMobile) ...[ + backButton, + const SizedBox(height: 12), + tryAgainButton, + const SizedBox(height: 8), + Center(child: supportLink), + ] else ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: backButton), + const SizedBox(width: 16), + Expanded(child: tryAgainButton), + ], + ), + const SizedBox(height: 12), + Center(child: supportLink), + ], ], ); }, @@ -952,6 +1700,12 @@ class WithdrawErrorCard extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); + final rawDetails = error is TextError + ? (error as TextError).technicalDetails + : null; + final hasDistinctDetails = + rawDetails != null && rawDetails != error.message; + return Card( child: Padding( padding: const EdgeInsets.all(16), @@ -964,17 +1718,20 @@ class WithdrawErrorCard extends StatelessWidget { ), const SizedBox(height: 8), SelectableText(error.message, style: theme.textTheme.bodyMedium), - if (error is TextError) ...[ + if (hasDistinctDetails) ...[ const SizedBox(height: 16), const Divider(), const SizedBox(height: 16), ExpansionTile( title: Text(LocaleKeys.technicalDetails.tr()), children: [ - SelectableText( - (error as TextError).error, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'Mono', + Padding( + padding: const EdgeInsets.all(12), + child: SelectableText( + rawDetails, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'Mono', + ), ), ), ], diff --git a/lib/views/wallet/coins_manager/coins_manager_controls.dart b/lib/views/wallet/coins_manager/coins_manager_controls.dart index ca3112f68f..e6b149cb8a 100644 --- a/lib/views/wallet/coins_manager/coins_manager_controls.dart +++ b/lib/views/wallet/coins_manager/coins_manager_controls.dart @@ -21,74 +21,123 @@ class CoinsManagerFilters extends StatefulWidget { class _CoinsManagerFiltersState extends State { late final Debouncer _debouncer; + late final TextEditingController _searchController; + late final FocusNode _searchFocusNode; @override void initState() { super.initState(); _debouncer = Debouncer(duration: const Duration(milliseconds: 100)); + _searchController = TextEditingController( + text: context.read().state.searchPhrase, + ); + _searchFocusNode = FocusNode(); } @override void dispose() { _debouncer.dispose(); + _searchController.dispose(); + _searchFocusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - if (widget.isMobile) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSearchField(context), - const SizedBox(height: 8), - const CustomTokenImportButton(), - Padding( - padding: const EdgeInsets.only(top: 14.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + final bool isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; + return BlocListener( + listenWhen: (previous, current) => + previous.searchPhrase != current.searchPhrase, + listener: (context, state) => _syncSearchField(state.searchPhrase), + child: widget.isMobile + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSearchField(context), + AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: isKeyboardVisible + ? const SizedBox.shrink() + : Column( + key: const Key('coins-manager-mobile-import'), + children: const [ + SizedBox(height: 8), + CustomTokenImportButton(), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(top: 14.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Padding( + padding: EdgeInsets.only(left: 20.0), + child: CoinsManagerSelectAllButton(), + ), + const Spacer(), + CoinsManagerFiltersDropdown(), + ], + ), + ), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - const Padding( - padding: EdgeInsets.only(left: 20.0), - child: CoinsManagerSelectAllButton(), + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + constraints: const BoxConstraints(maxWidth: 240), + height: 45, + child: _buildSearchField(context), + ), + const SizedBox(width: 20), + Container( + constraints: const BoxConstraints(maxWidth: 240), + height: 45, + child: const CustomTokenImportButton(), + ), + const Spacer(), + CoinsManagerFiltersDropdown(), + ], ), - const Spacer(), - CoinsManagerFiltersDropdown(), ], ), - ), - ], - ); + ); + } + + void _syncSearchField(String searchPhrase) { + if (_searchController.text == searchPhrase) { + return; } - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - constraints: const BoxConstraints(maxWidth: 240), - height: 45, - child: _buildSearchField(context), - ), - const SizedBox(width: 20), - Container( - constraints: const BoxConstraints(maxWidth: 240), - height: 45, - child: const CustomTokenImportButton(), - ), - const Spacer(), - CoinsManagerFiltersDropdown(), - ], - ), - ], + + _searchController.value = _searchController.value.copyWith( + text: searchPhrase, + selection: TextSelection.collapsed(offset: searchPhrase.length), + composing: TextRange.empty, + ); + } + + void _dispatchSearchUpdate() { + if (!mounted) { + return; + } + + context.read().add( + CoinsManagerSearchUpdate(text: _searchController.text), ); } Widget _buildSearchField(BuildContext context) { return UiTextFormField( key: const Key('coins-manager-search-field'), + controller: _searchController, + focusNode: _searchFocusNode, fillColor: widget.isMobile ? theme.custom.coinsManagerTheme.searchFieldMobileBackgroundColor : null, @@ -100,13 +149,7 @@ class _CoinsManagerFiltersState extends State { inputFormatters: [LengthLimitingTextInputFormatter(40)], hintText: LocaleKeys.searchAssets.tr(), hintTextStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), - onChanged: (String? text) => _debouncer.run(() { - if (mounted) { - context.read().add( - CoinsManagerSearchUpdate(text: text ?? ''), - ); - } - }), + onChanged: (_) => _debouncer.run(_dispatchSearchUpdate), ); } } diff --git a/lib/views/wallet/coins_manager/coins_manager_helpers.dart b/lib/views/wallet/coins_manager/coins_manager_helpers.dart index 94b0174954..b9d61aff75 100644 --- a/lib/views/wallet/coins_manager/coins_manager_helpers.dart +++ b/lib/views/wallet/coins_manager/coins_manager_helpers.dart @@ -5,52 +5,54 @@ import 'package:web_dex/shared/utils/utils.dart'; List sortByName(List coins, SortDirection sortDirection) { if (sortDirection == SortDirection.none) return coins; - if (sortDirection == SortDirection.increase) { - coins.sort((a, b) => a.name.compareTo(b.name)); - return coins; - } else { - coins.sort((a, b) => b.name.compareTo(a.name)); - return coins; - } + coins.sort((a, b) { + final parentCompare = _compareParentFirst(a, b); + if (parentCompare != 0) return parentCompare; + return sortDirection == SortDirection.increase + ? a.name.compareTo(b.name) + : b.name.compareTo(a.name); + }); + return coins; } List sortByProtocol(List coins, SortDirection sortDirection) { if (sortDirection == SortDirection.none) return coins; - if (sortDirection == SortDirection.increase) { - coins - .sort((a, b) => a.typeNameWithTestnet.compareTo(b.typeNameWithTestnet)); - return coins; - } else { - coins - .sort((a, b) => b.typeNameWithTestnet.compareTo(a.typeNameWithTestnet)); - return coins; - } + coins.sort((a, b) { + final parentCompare = _compareParentFirst(a, b); + if (parentCompare != 0) return parentCompare; + return sortDirection == SortDirection.increase + ? a.typeNameWithTestnet.compareTo(b.typeNameWithTestnet) + : b.typeNameWithTestnet.compareTo(a.typeNameWithTestnet); + }); + return coins; } List sortByLastKnownUsdBalance( - List coins, SortDirection sortDirection, KomodoDefiSdk sdk) { + List coins, + SortDirection sortDirection, + KomodoDefiSdk sdk, +) { if (sortDirection == SortDirection.none) return coins; - if (sortDirection == SortDirection.increase) { - coins.sort((a, b) { - final aBalance = a.lastKnownUsdBalance(sdk) ?? 0.0; - final bBalance = b.lastKnownUsdBalance(sdk) ?? 0.0; - if (aBalance == bBalance) return 0; - return aBalance.compareTo(bBalance); - }); - return coins; - } else { - coins.sort((a, b) { - final aBalance = a.lastKnownUsdBalance(sdk) ?? 0.0; - final bBalance = b.lastKnownUsdBalance(sdk) ?? 0.0; - if (aBalance == bBalance) return 0; - return bBalance.compareTo(aBalance); - }); - return coins; - } + coins.sort((a, b) { + final parentCompare = _compareParentFirst(a, b); + if (parentCompare != 0) return parentCompare; + final aBalance = a.lastKnownUsdBalance(sdk) ?? 0.0; + final bBalance = b.lastKnownUsdBalance(sdk) ?? 0.0; + if (aBalance == bBalance) { + return a.name.compareTo(b.name); + } + return sortDirection == SortDirection.increase + ? aBalance.compareTo(bBalance) + : bBalance.compareTo(aBalance); + }); + return coins; } List sortByUsdBalance( - List coins, SortDirection sortDirection, KomodoDefiSdk sdk) { + List coins, + SortDirection sortDirection, + KomodoDefiSdk sdk, +) { if (sortDirection == SortDirection.none) return coins; final List<({Coin coin, double balance})> coinsWithBalances = List.generate( @@ -58,11 +60,20 @@ List sortByUsdBalance( (i) => (coin: coins[i], balance: coins[i].lastKnownUsdBalance(sdk) ?? 0.0), ); - if (sortDirection == SortDirection.increase) { - coinsWithBalances.sort((a, b) => a.balance.compareTo(b.balance)); - } else { - coinsWithBalances.sort((a, b) => b.balance.compareTo(a.balance)); - } + coinsWithBalances.sort((a, b) { + final parentCompare = _compareParentFirst(a.coin, b.coin); + if (parentCompare != 0) return parentCompare; + return sortDirection == SortDirection.increase + ? a.balance.compareTo(b.balance) + : b.balance.compareTo(a.balance); + }); return coinsWithBalances.map((e) => e.coin).toList(); } + +int _compareParentFirst(Coin a, Coin b) { + final bool aIsParent = a.parentCoin == null; + final bool bIsParent = b.parentCoin == null; + if (aIsParent != bIsParent) return aIsParent ? -1 : 1; + return 0; +} diff --git a/lib/views/wallet/coins_manager/coins_manager_list_item.dart b/lib/views/wallet/coins_manager/coins_manager_list_item.dart index faf4091a06..bbc1e2c927 100644 --- a/lib/views/wallet/coins_manager/coins_manager_list_item.dart +++ b/lib/views/wallet/coins_manager/coins_manager_list_item.dart @@ -1,7 +1,10 @@ import 'package:app_theme/app_theme.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; +import 'package:web_dex/shared/constants.dart'; import 'package:web_dex/shared/utils/formatters.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; @@ -68,6 +71,9 @@ class _CoinsManagerListItemDesktop extends StatelessWidget { @override Widget build(BuildContext context) { + final hideBalances = context.select( + (SettingsBloc bloc) => bloc.state.hideBalances, + ); final balance = coin.balance(context.sdk) ?? 0.0; final bool isZeroBalance = balance == 0.0; final Color balanceColor = isZeroBalance @@ -89,9 +95,8 @@ class _CoinsManagerListItemDesktop extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.only(right: 20.0), - child: Switch( + child: UiSwitcher( value: isSelected, - splashRadius: 18, onChanged: (_) => onSelect(), ), ), @@ -105,8 +110,10 @@ class _CoinsManagerListItemDesktop extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Container( - padding: - const EdgeInsets.symmetric(vertical: 4, horizontal: 10), + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 10, + ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), color: protocolColor, @@ -122,7 +129,9 @@ class _CoinsManagerListItemDesktop extends StatelessWidget { fontSize: 12, fontWeight: FontWeight.w600, color: theme - .custom.coinsManagerTheme.listItemProtocolTextColor, + .custom + .coinsManagerTheme + .listItemProtocolTextColor, ), ), ), @@ -137,7 +146,9 @@ class _CoinsManagerListItemDesktop extends StatelessWidget { children: [ Flexible( child: AutoScrollText( - text: isZeroBalance + text: hideBalances + ? maskedBalanceText + : isZeroBalance ? formatAmt(balance) : formatDexAmt(coin.balance), style: TextStyle( @@ -164,14 +175,6 @@ class _CoinsManagerListItemDesktop extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - '(', - style: TextStyle( - color: balanceColor, - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), Flexible( child: CoinFiatBalance( coin, @@ -183,14 +186,6 @@ class _CoinsManagerListItemDesktop extends StatelessWidget { ), ), ), - Text( - ')', - style: TextStyle( - color: balanceColor, - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), ], ), ), @@ -224,6 +219,9 @@ class _CoinsManagerListItemMobile extends StatelessWidget { @override Widget build(BuildContext context) { + final hideBalances = context.select( + (SettingsBloc bloc) => bloc.state.hideBalances, + ); final balance = coin.balance(context.sdk) ?? 0.0; final bool isZeroBalance = balance == 0.0; final Color balanceColor = isZeroBalance @@ -252,14 +250,18 @@ class _CoinsManagerListItemMobile extends StatelessWidget { onChanged: (_) => onSelect(), ), const SizedBox(width: 8), - Expanded(child: CoinItem(coin: coin, size: CoinItemSize.large)), + Expanded( + child: CoinItem(coin: coin, size: CoinItemSize.large), + ), if (!isAddAssets) Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ AutoScrollText( - text: isZeroBalance + text: hideBalances + ? maskedBalanceText + : isZeroBalance ? formatAmt(balance) : formatDexAmt(balance), style: TextStyle( @@ -272,14 +274,6 @@ class _CoinsManagerListItemMobile extends StatelessWidget { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, children: [ - Text( - '(', - style: TextStyle( - color: balanceColor, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), Flexible( child: CoinFiatBalance( coin, @@ -291,14 +285,6 @@ class _CoinsManagerListItemMobile extends StatelessWidget { ), ), ), - Text( - ')', - style: TextStyle( - color: balanceColor, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), ], ), ], diff --git a/lib/views/wallet/coins_manager/coins_manager_list_wrapper.dart b/lib/views/wallet/coins_manager/coins_manager_list_wrapper.dart index 77dea559ab..e8743d88b1 100644 --- a/lib/views/wallet/coins_manager/coins_manager_list_wrapper.dart +++ b/lib/views/wallet/coins_manager/coins_manager_list_wrapper.dart @@ -53,38 +53,46 @@ class _CoinsManagerListWrapperState extends State { child: BlocBuilder( builder: (BuildContext context, CoinsManagerState state) { final bool isAddAssets = state.action == CoinsManagerAction.add; - - return Column( - mainAxisSize: MainAxisSize.max, - children: [ - CoinsManagerFilters(isMobile: isMobile), - if (!isMobile) - Padding( - padding: const EdgeInsets.only(top: 20), - child: CoinsManagerListHeader( - sortData: state.sortData, - isAddAssets: isAddAssets, - onSortChange: _onSortChange, + final double bottomInset = isMobile + ? MediaQuery.of(context).viewInsets.bottom + : 0.0; + + return AnimatedPadding( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + padding: EdgeInsets.only(bottom: bottomInset), + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + CoinsManagerFilters(isMobile: isMobile), + if (!isMobile) + Padding( + padding: const EdgeInsets.only(top: 20), + child: CoinsManagerListHeader( + sortData: state.sortData, + isAddAssets: isAddAssets, + onSortChange: _onSortChange, + ), ), - ), - SizedBox(height: isMobile ? 4.0 : 14.0), - const CoinsManagerSelectedTypesList(), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: CoinsManagerList( - coinList: state.coins, - isAddAssets: isAddAssets, - onCoinSelect: _onCoinSelect, + SizedBox(height: isMobile ? 4.0 : 14.0), + const CoinsManagerSelectedTypesList(), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: CoinsManagerList( + coinList: state.coins, + isAddAssets: isAddAssets, + onCoinSelect: _onCoinSelect, + ), ), - ), - const SizedBox(height: 12), - ], + const SizedBox(height: 12), + ], + ), ), - ), - ], + ], + ), ); }, ), @@ -95,23 +103,22 @@ class _CoinsManagerListWrapperState extends State { context.read().add(CoinsManagerSortChanged(sortData)); } - void _onRemovalStateChanged( - BuildContext context, - CoinsManagerState state, - ) { + void _onRemovalStateChanged(BuildContext context, CoinsManagerState state) { final removalState = state.removalState; if (removalState == null) return; final bloc = context.read(); final coin = removalState.coin; - final childCoinTickers = - removalState.childCoins.map((c) => c.abbr).toList(); + final childCoinTickers = removalState.childCoins + .map((c) => c.abbr) + .toList(); final requiresParentConfirmation = coin.parentCoin == null && childCoinTickers.isNotEmpty; if (removalState.hasActiveSwap) { - _informationPopup.text = - LocaleKeys.coinDisableSpan1.tr(args: [removalState.coin.abbr]); + _informationPopup.text = LocaleKeys.coinDisableSpan1.tr( + args: [removalState.coin.abbr], + ); _informationPopup.show(); bloc.add(const CoinsManagerCoinRemovalCancelled()); return; @@ -160,10 +167,7 @@ class _CoinsManagerListWrapperState extends State { } } - void _onErrorMessageChanged( - BuildContext context, - CoinsManagerState state, - ) { + void _onErrorMessageChanged(BuildContext context, CoinsManagerState state) { final errorMessage = state.errorMessage; if (errorMessage != null) { _informationPopup.text = errorMessage; diff --git a/lib/views/wallet/coins_manager/coins_manager_select_all_button.dart b/lib/views/wallet/coins_manager/coins_manager_select_all_button.dart index d030b04163..c38177a0e1 100644 --- a/lib/views/wallet/coins_manager/coins_manager_select_all_button.dart +++ b/lib/views/wallet/coins_manager/coins_manager_select_all_button.dart @@ -3,9 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:web_dex/bloc/coins_manager/coins_manager_bloc.dart'; class CoinsManagerSelectAllButton extends StatelessWidget { - const CoinsManagerSelectAllButton({ - Key? key, - }) : super(key: key); + const CoinsManagerSelectAllButton({Key? key}) : super(key: key); @override Widget build(BuildContext context) { @@ -13,17 +11,15 @@ class CoinsManagerSelectAllButton extends StatelessWidget { final bool isSelectedAllEnabled = bloc.state.isSelectedAllCoinsEnabled; final ThemeData theme = Theme.of(context); return Checkbox( - value: true, + value: isSelectedAllEnabled, splashRadius: 18, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), side: isSelectedAllEnabled ? null - : WidgetStateBorderSide.resolveWith((states) => BorderSide( - width: 2.0, - color: theme.colorScheme.primary, - )), + : WidgetStateBorderSide.resolveWith( + (states) => + BorderSide(width: 2.0, color: theme.colorScheme.primary), + ), checkColor: isSelectedAllEnabled ? null : theme.colorScheme.primary, fillColor: isSelectedAllEnabled ? null diff --git a/lib/views/wallet/wallet_page/charts/coin_prices_chart.dart b/lib/views/wallet/wallet_page/charts/coin_prices_chart.dart index f01fc079d9..6ca978ba2b 100644 --- a/lib/views/wallet/wallet_page/charts/coin_prices_chart.dart +++ b/lib/views/wallet/wallet_page/charts/coin_prices_chart.dart @@ -25,14 +25,31 @@ class PriceChartPage extends StatelessWidget { return Card( clipBehavior: Clip.antiAlias, elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Container( height: 340, padding: const EdgeInsets.all(16), child: BlocBuilder( builder: (context, state) { + final lastPoint = state.data.firstOrNull?.data.lastOrNull; + final double? lastValue = lastPoint?.usdValue; + final bool hasValidValue = lastValue != null && lastValue.isFinite; + final double safeLastValue = hasValidValue ? lastValue : 0.0; + final double safePercentageIncrease = + (state + .data + .firstOrNull + ?.info + .selectedPeriodIncreasePercentage ?? + 0) + .isFinite + ? (state + .data + .firstOrNull + ?.info + .selectedPeriodIncreasePercentage ?? + 0) + : 0.0; return Column( children: [ MarketChartHeaderControls( @@ -44,30 +61,27 @@ class PriceChartPage extends StatelessWidget { size: 22, ), leadingText: Text( - NumberFormat.currency(symbol: '\$', decimalDigits: 4) - .format( - state.data.firstOrNull?.data.lastOrNull?.usdValue ?? 0, - ), + hasValidValue + ? NumberFormat.currency( + symbol: '\$', + decimalDigits: 4, + ).format(safeLastValue) + : '--', ), availableCoins: state.availableCoins.keys.toList(), selectedCoinId: state.data.firstOrNull?.info.ticker, onCoinSelected: (coinId) { context.read().add( - PriceChartCoinsSelected( - coinId == null ? [] : [coinId], - ), - ); + PriceChartCoinsSelected(coinId == null ? [] : [coinId]), + ); }, - centreAmount: - state.data.firstOrNull?.data.lastOrNull?.usdValue ?? 0, - percentageIncrease: state.data.firstOrNull?.info - .selectedPeriodIncreasePercentage ?? - 0, + centreAmount: safeLastValue, + percentageIncrease: safePercentageIncrease, selectedPeriod: state.selectedPeriod, onPeriodChanged: (newPeriod) { context.read().add( - PriceChartPeriodChanged(newPeriod!), - ); + PriceChartPeriodChanged(newPeriod!), + ); }, customCoinItemBuilder: (coinId) { final coin = state.availableCoins[coinId.symbol.common]; @@ -99,8 +113,9 @@ class PriceChartPage extends StatelessWidget { child: LineChart( key: const Key('price_chart'), domainExtent: const ChartExtent.tight(), - rangeExtent: - const ChartExtent.tight(paddingPortion: 0.1), + rangeExtent: const ChartExtent.tight( + paddingPortion: 0.1, + ), elements: [ ChartAxisLabels( isVertical: true, @@ -128,7 +143,8 @@ class PriceChartPage extends StatelessWidget { y: e.usdValue, ); }).toList(), - color: getCoinColor( + color: + getCoinColor( coinsData.elementAt(i).info.ticker, ) ?? Theme.of(context).colorScheme.primary, @@ -139,7 +155,7 @@ class PriceChartPage extends StatelessWidget { backgroundColor: Colors.transparent, tooltipBuilder: (context, dataPoints, dataColors) { final Map - dataPointCoinMap = { + dataPointCoinMap = { for (var i = 0; i < dataPoints.length; i++) PriceChartSeriesPoint( usdValue: dataPoints[i].y, @@ -219,7 +235,6 @@ class PriceChartPage extends StatelessWidget { format = DateFormat("d"); // e.g. 1 return (count, format); } - // Otherwise if it's more than 3 days, but less than 1 week, show a label // for each day with the short day name. else if (period.inDays > 3) { diff --git a/lib/views/wallet/wallet_page/charts/price_chart_tooltip.dart b/lib/views/wallet/wallet_page/charts/price_chart_tooltip.dart index b9a652d16e..a41b2f6e0f 100644 --- a/lib/views/wallet/wallet_page/charts/price_chart_tooltip.dart +++ b/lib/views/wallet/wallet_page/charts/price_chart_tooltip.dart @@ -7,17 +7,21 @@ import 'package:web_dex/bloc/cex_market_data/price_chart/models/price_chart_data class PriceChartTooltip extends StatelessWidget { final Map dataPointCoinMap; - PriceChartTooltip({ - Key? key, - required this.dataPointCoinMap, - }) : super(key: key); + PriceChartTooltip({Key? key, required this.dataPointCoinMap}) + : super(key: key); - late final double? commonX = dataPointCoinMap.keys.every((element) => - element.unixTimestamp == dataPointCoinMap.keys.first.unixTimestamp) + late final double? commonX = + dataPointCoinMap.keys.every( + (element) => + element.unixTimestamp == dataPointCoinMap.keys.first.unixTimestamp, + ) ? dataPointCoinMap.keys.first.unixTimestamp : null; String valueToString(double value) { + if (!value.isFinite) { + return '--'; + } if (value.abs() > 1000) { return '\$${value.toStringAsFixed(2)}'; } else { @@ -35,13 +39,15 @@ class PriceChartTooltip extends StatelessWidget { children: [ if (commonX != null) ...[ Text( - // TODO! Dynamic based on selected period. Try share logic - // with parent widget. + // TODO! Dynamic based on selected period. Try share logic + // with parent widget. - // For 1M, use format with example of "June 12, 2023" - DateFormat('MMMM d, y').format( - DateTime.fromMillisecondsSinceEpoch(commonX!.toInt())), - style: Theme.of(context).textTheme.labelMedium), + // For 1M, use format with example of "June 12, 2023" + DateFormat( + 'MMMM d, y', + ).format(DateTime.fromMillisecondsSinceEpoch(commonX!.toInt())), + style: Theme.of(context).textTheme.labelMedium, + ), const SizedBox(height: 4), ], if (isMultipleCoins) @@ -55,10 +61,9 @@ class PriceChartTooltip extends StatelessWidget { const SizedBox(width: 4), Text( '${coin.name}: ${valueToString(data.usdValue)}', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: Colors.white), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), ), ], ); diff --git a/lib/views/wallet/wallet_page/common/asset_ticker_group_sort.dart b/lib/views/wallet/wallet_page/common/asset_ticker_group_sort.dart new file mode 100644 index 0000000000..8276654a73 --- /dev/null +++ b/lib/views/wallet/wallet_page/common/asset_ticker_group_sort.dart @@ -0,0 +1,14 @@ +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// Sorts assets that share a display ticker so parent-chain entries (no +/// [AssetId.parentId]) appear before wrapped tokens, then by [AssetId.id]. +/// +/// Used by grouped ticker UIs so the primary row represents the native asset. +void sortAssetIdsWithinTickerGroup(List assets) { + assets.sort((a, b) { + final bool aIsParent = a.parentId == null; + final bool bIsParent = b.parentId == null; + if (aIsParent != bIsParent) return aIsParent ? -1 : 1; + return a.id.compareTo(b.id); + }); +} diff --git a/lib/views/wallet/wallet_page/common/assets_list.dart b/lib/views/wallet/wallet_page/common/assets_list.dart index a0110ec301..da2e40d694 100644 --- a/lib/views/wallet/wallet_page/common/assets_list.dart +++ b/lib/views/wallet/wallet_page/common/assets_list.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/views/wallet/wallet_page/common/asset_list_item.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/asset_ticker_group_sort.dart'; import 'package:web_dex/views/wallet/wallet_page/common/grouped_asset_ticker_item.dart'; /// A widget that displays a list of assets. @@ -39,23 +40,20 @@ class AssetsList extends StatelessWidget { final filteredAssets = _filterAssets(); return SliverList( - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - final asset = filteredAssets[index]; - final Color backgroundColor = index.isEven - ? Theme.of(context).colorScheme.surface - : Theme.of(context).colorScheme.onSurface; - - return AssetListItem( - assetId: asset, - backgroundColor: backgroundColor, - onTap: onAssetItemTap, - onStatisticsTap: onStatisticsTap, - priceChangePercentage24h: priceChangePercentages[asset.id], - ); - }, - childCount: filteredAssets.length, - ), + delegate: SliverChildBuilderDelegate((BuildContext context, int index) { + final asset = filteredAssets[index]; + final Color backgroundColor = index.isEven + ? Theme.of(context).colorScheme.surface + : Theme.of(context).colorScheme.onSurface; + + return AssetListItem( + assetId: asset, + backgroundColor: backgroundColor, + onTap: onAssetItemTap, + onStatisticsTap: onStatisticsTap, + priceChangePercentage24h: priceChangePercentages[asset.id], + ); + }, childCount: filteredAssets.length), ); } @@ -96,27 +94,31 @@ class AssetsList extends StatelessWidget { groupedAssets.putIfAbsent(symbol, () => []).add(asset); } + for (final entry in groupedAssets.entries) { + sortAssetIdsWithinTickerGroup(entry.value); + } + // Sort groups: priority tickers first (in order from list), then others (alphabetically) final groups = groupedAssets.entries.toList(); groups.sort((a, b) { final String tickerA = a.key; final String tickerB = b.key; - + final int indexA = unauthenticatedUserPriorityTickers.indexOf(tickerA); final int indexB = unauthenticatedUserPriorityTickers.indexOf(tickerB); - + final bool aIsPriority = indexA != -1; final bool bIsPriority = indexB != -1; - + // Priority tickers come first if (aIsPriority && !bIsPriority) return -1; if (!aIsPriority && bIsPriority) return 1; - + // If both are priority, sort by their order in the priority list if (aIsPriority && bIsPriority) { return indexA.compareTo(indexB); } - + // If both are not priority, sort alphabetically return tickerA.compareTo(tickerB); }); diff --git a/lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart b/lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart index 0568febf1d..dc156aed00 100644 --- a/lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart +++ b/lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart @@ -9,9 +9,11 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui/komodo_ui.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; import 'package:web_dex/common/screen.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/constants.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/widgets/coin_balance.dart'; import 'package:web_dex/shared/widgets/coin_fiat_balance.dart'; @@ -28,6 +30,7 @@ class ExpandableCoinListItem extends StatefulWidget { final bool isSelected; final Color? backgroundColor; final VoidCallback? onTap; + final VoidCallback? onStatisticsTap; const ExpandableCoinListItem({ super.key, @@ -35,6 +38,7 @@ class ExpandableCoinListItem extends StatefulWidget { required this.pubkeys, required this.isSelected, this.onTap, + this.onStatisticsTap, this.backgroundColor, }); @@ -73,6 +77,9 @@ class _ExpandableCoinListItemState extends State { @override Widget build(BuildContext context) { final hasAddresses = widget.pubkeys?.keys.isNotEmpty ?? false; + final hideBalances = context.select( + (SettingsBloc bloc) => bloc.state.hideBalances, + ); final sortedAddresses = hasAddresses ? (List.of( widget.pubkeys!.keys, @@ -87,6 +94,7 @@ class _ExpandableCoinListItemState extends State { isSwapAddress: pubkey == sortedAddresses.first, onTap: widget.onTap, onCopy: () => copyToClipBoard(context, pubkey.address), + hideBalances: hideBalances, ), ) .toList() @@ -119,7 +127,7 @@ class _ExpandableCoinListItemState extends State { expansionControlPosition: ExpansionControlPosition.leading, emptyChildrenBehavior: EmptyChildrenBehavior.disable, isDense: true, - title: _buildTitle(context), + title: _buildTitle(context, hideBalances), maintainState: true, childrenDivider: const Divider(height: 1, indent: 16, endIndent: 16), trailing: CoinMoreActionsButton(coin: widget.coin), @@ -127,17 +135,22 @@ class _ExpandableCoinListItemState extends State { ); } - Widget _buildTitle(BuildContext context) { + Widget _buildTitle(BuildContext context, bool hideBalances) { final theme = Theme.of(context); if (isMobile) { - return _buildMobileTitle(context, theme); + return _buildMobileTitle(context, theme, hideBalances); } else { - return _buildDesktopTitle(context, theme); + return _buildDesktopTitle(context, theme, hideBalances); } } - Widget _buildMobileTitle(BuildContext context, ThemeData theme) { + Widget _buildMobileTitle( + BuildContext context, + ThemeData theme, + bool hideBalances, + ) { + final statsTap = widget.onStatisticsTap; return Container( alignment: Alignment.centerLeft, child: Row( @@ -158,8 +171,9 @@ class _ExpandableCoinListItemState extends State { ), // Crypto balance - using bodySmall for 12px secondary text AutoScrollText( - text: - '${doubleToString(widget.coin.balance(context.sdk) ?? 0)} ${widget.coin.abbr}', + text: hideBalances + ? '$maskedBalanceText ${widget.coin.abbr}' + : '${doubleToString(widget.coin.balance(context.sdk) ?? 0)} ${widget.coin.abbr}', style: theme.textTheme.bodySmall, ), ], @@ -169,52 +183,56 @@ class _ExpandableCoinListItemState extends State { // Right side: Price and trend info Expanded( flex: 7, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // Current balance in USD - using headlineMedium for bold 16px text - if (widget.coin.lastKnownUsdBalance(context.sdk) != null) - Text( - '\$${NumberFormat("#,##0.00").format(widget.coin.lastKnownUsdBalance(context.sdk)!)}', - style: theme.textTheme.headlineMedium, + child: InkWell( + onTap: statsTap, + borderRadius: BorderRadius.circular(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Current balance in USD - using headlineMedium for bold 16px text + _UsdBalanceText( + coin: widget.coin, + textStyle: theme.textTheme.headlineMedium, ), - const SizedBox(height: 2), - // Trend percentage - BlocBuilder( - builder: (context, state) { - final usdBalance = widget.coin.lastKnownUsdBalance( - context.sdk, - ); - if (usdBalance == null) { - return const SizedBox.shrink(); - } + const SizedBox(height: 2), + // Trend percentage + if (!hideBalances) + BlocBuilder( + builder: (context, state) { + final usdBalance = widget.coin.lastKnownUsdBalance( + context.sdk, + ); + if (usdBalance == null) { + return const SizedBox.shrink(); + } - final change24hPercent = usdBalance == 0.0 - ? 0.0 - : state.get24hChangeForAsset(widget.coin.id); - // Calculate the 24h USD change value - final change24hValue = - change24hPercent != null && usdBalance > 0 - ? (change24hPercent * usdBalance / 100) - : 0.0; - final themeCustom = - Theme.of(context).brightness == Brightness.dark - ? Theme.of(context).extension()! - : Theme.of(context).extension()!; - return TrendPercentageText( - percentage: change24hPercent, - value: change24hValue, - upColor: themeCustom.increaseColor, - downColor: themeCustom.decreaseColor, - valueFormatter: (value) => - NumberFormat.currency(symbol: '\$').format(value), - iconSize: 12, - spacing: 2, - textStyle: theme.textTheme.bodySmall, - ); - }, - ), - ], + final change24hPercent = usdBalance == 0.0 + ? 0.0 + : state.get24hChangeForAsset(widget.coin.id); + // Calculate the 24h USD change value + final change24hValue = + change24hPercent != null && usdBalance > 0 + ? (change24hPercent * usdBalance / 100) + : 0.0; + final themeCustom = + Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).extension()! + : Theme.of(context).extension()!; + return TrendPercentageText( + percentage: change24hPercent, + value: change24hValue, + upColor: themeCustom.increaseColor, + downColor: themeCustom.decreaseColor, + valueFormatter: (value) => + NumberFormat.currency(symbol: '\$').format(value), + iconSize: 12, + spacing: 2, + textStyle: theme.textTheme.bodySmall, + ); + }, + ), + ], + ), ), ), ], @@ -222,7 +240,12 @@ class _ExpandableCoinListItemState extends State { ); } - Widget _buildDesktopTitle(BuildContext context, ThemeData theme) { + Widget _buildDesktopTitle( + BuildContext context, + ThemeData theme, + bool hideBalances, + ) { + final statsTap = widget.onStatisticsTap; return Container( alignment: Alignment.centerLeft, child: Row( @@ -234,37 +257,47 @@ class _ExpandableCoinListItemState extends State { child: CoinItem(coin: widget.coin, size: CoinItemSize.large), ), const Spacer(), - CoinBalance(coin: widget.coin), - BlocBuilder( - builder: (context, state) { - final usdBalance = widget.coin.lastKnownUsdBalance(context.sdk); - if (usdBalance == null) { - return const SizedBox.shrink(); - } + InkWell( + onTap: statsTap, + borderRadius: BorderRadius.circular(8), + child: CoinBalance(coin: widget.coin), + ), + if (!hideBalances) + BlocBuilder( + builder: (context, state) { + final usdBalance = widget.coin.lastKnownUsdBalance(context.sdk); + if (usdBalance == null) { + return const SizedBox.shrink(); + } - final change24hPercent = usdBalance == 0.0 - ? 0.0 - : state.get24hChangeForAsset(widget.coin.id); + final change24hPercent = usdBalance == 0.0 + ? 0.0 + : state.get24hChangeForAsset(widget.coin.id); - // Calculate the 24h USD change value - final change24hValue = change24hPercent != null && usdBalance > 0 - ? (change24hPercent * usdBalance / 100) - : 0.0; + // Calculate the 24h USD change value + final change24hValue = + change24hPercent != null && usdBalance > 0 + ? (change24hPercent * usdBalance / 100) + : 0.0; - final themeCustom = - Theme.of(context).brightness == Brightness.dark - ? Theme.of(context).extension()! - : Theme.of(context).extension()!; - return TrendPercentageText( - percentage: change24hPercent, - value: change24hValue, - upColor: themeCustom.increaseColor, - downColor: themeCustom.decreaseColor, - valueFormatter: (value) => - NumberFormat.currency(symbol: '\$').format(value), - ); - }, - ), + final themeCustom = + Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).extension()! + : Theme.of(context).extension()!; + return InkWell( + onTap: statsTap, + borderRadius: BorderRadius.circular(8), + child: TrendPercentageText( + percentage: change24hPercent, + value: change24hValue, + upColor: themeCustom.increaseColor, + downColor: themeCustom.decreaseColor, + valueFormatter: (value) => + NumberFormat.currency(symbol: '\$').format(value), + ), + ); + }, + ), // const Spacer(), ], ), @@ -272,10 +305,46 @@ class _ExpandableCoinListItemState extends State { } } +class _UsdBalanceText extends StatelessWidget { + const _UsdBalanceText({required this.coin, this.textStyle}); + + final Coin coin; + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + final hideBalances = context.select( + (SettingsBloc bloc) => bloc.state.hideBalances, + ); + if (hideBalances) { + return Text('\$$maskedBalanceText', style: textStyle); + } + + final balanceStream = context.sdk.balances.watchBalance(coin.id); + return BlocSelector( + selector: (state) => state.getPriceForAsset(coin.id)?.price?.toDouble(), + builder: (context, price) { + return StreamBuilder( + stream: balanceStream, + builder: (context, snapshot) { + final balance = snapshot.data?.spendable.toDouble(); + if (balance == null || price == null) { + return Text('--', style: textStyle); + } + final formatted = NumberFormat("#,##0.00").format(price * balance); + return Text('\$$formatted', style: textStyle); + }, + ); + }, + ); + } +} + class _AddressRow extends StatelessWidget { final PubkeyInfo pubkey; final Coin coin; final bool isSwapAddress; + final bool hideBalances; final VoidCallback? onTap; final VoidCallback? onCopy; @@ -283,6 +352,7 @@ class _AddressRow extends StatelessWidget { required this.pubkey, required this.coin, required this.isSwapAddress, + required this.hideBalances, required this.onTap, this.onCopy, }); @@ -344,7 +414,9 @@ class _AddressRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - '${doubleToString(pubkey.balance.spendable.toDouble())} ${coin.abbr}', + hideBalances + ? '$maskedBalanceText ${coin.abbr}' + : '${doubleToString(pubkey.balance.spendable.toDouble())} ${coin.abbr}', style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, ), diff --git a/lib/views/wallet/wallet_page/common/expandable_private_key_list.dart b/lib/views/wallet/wallet_page/common/expandable_private_key_list.dart index 8bc1b72126..d4b51ba908 100644 --- a/lib/views/wallet/wallet_page/common/expandable_private_key_list.dart +++ b/lib/views/wallet/wallet_page/common/expandable_private_key_list.dart @@ -95,6 +95,14 @@ class PrivateKeyAssetSection extends StatefulWidget { class _PrivateKeyAssetSectionState extends State { bool _isExpanded = false; + bool get _isSingleAddressAsset => + widget.assetId.subClass == CoinSubClass.tendermint || + widget.assetId.subClass == CoinSubClass.tendermintToken; + + List get _displayKeys => _isSingleAddressAsset + ? widget.privateKeys.take(1).toList() + : widget.privateKeys; + @override void initState() { super.initState(); @@ -122,43 +130,49 @@ class _PrivateKeyAssetSectionState extends State { @override Widget build(BuildContext context) { - final children = widget.privateKeys - .map( - (privateKey) => PrivateKeyListItem( - assetId: widget.assetId, - privateKey: privateKey, - ), - ) - .toList(); - // Match the styling from ExpandableCoinListItem final horizontalPadding = 16.0; final verticalPadding = isMobile ? 16.0 : 22.0; - return CollapsibleCard( - key: PageStorageKey('private_key_${widget.assetId.id}'), - borderRadius: BorderRadius.circular(12), - headerPadding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - vertical: verticalPadding, - ), - childrenMargin: EdgeInsets.symmetric( - horizontal: horizontalPadding, - vertical: verticalPadding, - ), - childrenDecoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(12), - ), - initiallyExpanded: _isExpanded, - onExpansionChanged: _handleExpansionChanged, - expansionControlPosition: ExpansionControlPosition.leading, - emptyChildrenBehavior: EmptyChildrenBehavior.disable, - isDense: true, - title: _buildTitle(context), - maintainState: true, - childrenDivider: const Divider(height: 1, indent: 16, endIndent: 16), - children: children, + return BlocSelector( + selector: (state) => state.showPrivateKeys, + builder: (context, showPrivateKeys) { + final children = _displayKeys + .map( + (privateKey) => PrivateKeyListItem( + assetId: widget.assetId, + privateKey: privateKey, + showPrivateKeys: showPrivateKeys, + ), + ) + .toList(); + + return CollapsibleCard( + key: PageStorageKey('private_key_${widget.assetId.id}'), + borderRadius: BorderRadius.circular(12), + headerPadding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: verticalPadding, + ), + childrenMargin: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: verticalPadding, + ), + childrenDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(12), + ), + initiallyExpanded: _isExpanded, + onExpansionChanged: _handleExpansionChanged, + expansionControlPosition: ExpansionControlPosition.leading, + emptyChildrenBehavior: EmptyChildrenBehavior.disable, + isDense: true, + title: _buildTitle(context), + maintainState: true, + childrenDivider: const Divider(height: 1, indent: 16, endIndent: 16), + children: children, + ); + }, ); } @@ -173,6 +187,7 @@ class _PrivateKeyAssetSectionState extends State { } Widget _buildMobileTitle(BuildContext context, ThemeData theme) { + final displayCount = _displayKeys.length; return Container( alignment: Alignment.centerLeft, child: Row( @@ -188,7 +203,7 @@ class _PrivateKeyAssetSectionState extends State { Text(widget.assetId.id, style: theme.textTheme.headlineMedium), // Key count - using bodySmall for 12px secondary text Text( - '${widget.privateKeys.length} key${widget.privateKeys.length > 1 ? 's' : ''}', + '$displayCount key${displayCount > 1 ? 's' : ''}', style: theme.textTheme.bodySmall, ), ], @@ -199,6 +214,7 @@ class _PrivateKeyAssetSectionState extends State { } Widget _buildDesktopTitle(BuildContext context, ThemeData theme) { + final displayCount = _displayKeys.length; return Container( alignment: Alignment.centerLeft, child: Row( @@ -220,7 +236,7 @@ class _PrivateKeyAssetSectionState extends State { style: theme.textTheme.titleMedium, ), Text( - '${widget.privateKeys.length} key${widget.privateKeys.length > 1 ? 's' : ''}', + '$displayCount key${displayCount > 1 ? 's' : ''}', style: theme.textTheme.bodySmall, ), ], @@ -236,163 +252,215 @@ class _PrivateKeyAssetSectionState extends State { } /// Widget for displaying a single private key with controls. -class PrivateKeyListItem extends StatelessWidget { +class PrivateKeyListItem extends StatefulWidget { const PrivateKeyListItem({ super.key, required this.assetId, required this.privateKey, + required this.showPrivateKeys, }); final AssetId assetId; final PrivateKey privateKey; + final bool showPrivateKeys; @override - Widget build(BuildContext context) { - return BlocSelector( - selector: (state) => state.showPrivateKeys, - builder: (context, showPrivateKeys) { - final theme = Theme.of(context); + State createState() => _PrivateKeyListItemState(); +} - final subtitleStyle = theme.textTheme.bodySmall?.copyWith( - color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.7), - ); +class _PrivateKeyListItemState extends State { + bool _isPrivateKeyVisible = false; - return ClipRRect( - borderRadius: BorderRadius.circular(12), - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 16, - ), - leading: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), + @override + void didUpdateWidget(covariant PrivateKeyListItem oldWidget) { + super.didUpdateWidget(oldWidget); + if (!widget.showPrivateKeys && _isPrivateKeyVisible) { + setState(() => _isPrivateKeyVisible = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final subtitleStyle = theme.textTheme.bodySmall?.copyWith( + color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.7), + ); + final labelStyle = theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + fontWeight: FontWeight.w600, + ); + final valueStyle = theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + ); + final labelWidth = isMobile ? 90.0 : 120.0; + final canReveal = widget.showPrivateKeys; + final isVisible = canReveal && _isPrivateKeyVisible; + final derivationPath = widget.privateKey.hdInfo?.derivationPath; + + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + leading: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.key, size: 16, color: theme.colorScheme.primary), + ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (derivationPath != null) ...[ + _buildDetailRow( + label: 'Path', + labelStyle: labelStyle, + labelWidth: labelWidth, + value: AutoScrollText( + text: derivationPath, + style: subtitleStyle?.copyWith(fontFamily: 'monospace'), + ), ), - child: Icon( - Icons.key, - size: 16, - color: theme.colorScheme.primary, + const SizedBox(height: 6), + ], + _buildDetailRow( + label: LocaleKeys.address.tr(), + labelStyle: labelStyle, + labelWidth: labelWidth, + value: AutoScrollText( + text: widget.privateKey.publicKeyAddress, + style: subtitleStyle, ), + actions: [ + IconButton( + iconSize: 16, + icon: const Icon(Icons.copy), + onPressed: () { + copyToClipBoard( + context, + widget.privateKey.publicKeyAddress, + ); + }, + visualDensity: VisualDensity.compact, + tooltip: 'Copy address', + ), + ], ), - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (privateKey.hdInfo?.derivationPath != null) ...[ - Text( - 'Path: ${privateKey.hdInfo!.derivationPath}', - style: subtitleStyle, - ), - const SizedBox(height: 4), - ], - Row( - children: [ - Expanded( - child: AutoScrollText( - text: privateKey.publicKeyAddress, - style: subtitleStyle, - ), - ), - const SizedBox(width: 8), - Material( - color: Colors.transparent, - child: IconButton( - iconSize: 16, - icon: const Icon(Icons.copy), - onPressed: () { - copyToClipBoard(context, privateKey.publicKeyAddress); - }, - visualDensity: VisualDensity.compact, - tooltip: 'Copy address', - ), - ), - ], + if (widget.privateKey.publicKeySecp256k1.isNotEmpty) ...[ + const SizedBox(height: 6), + _buildDetailRow( + label: LocaleKeys.pubkey.tr(), + labelStyle: labelStyle, + labelWidth: labelWidth, + value: AutoScrollText( + text: widget.privateKey.publicKeySecp256k1, + style: subtitleStyle?.copyWith(fontFamily: 'monospace'), ), - if (privateKey.publicKeySecp256k1.isNotEmpty) ...[ - const SizedBox(height: 4), - Row( - children: [ - Text( - '${LocaleKeys.pubkey.tr()}: ', - style: subtitleStyle?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - Expanded( - child: AutoScrollText( - text: privateKey.publicKeySecp256k1, - style: subtitleStyle?.copyWith( - fontFamily: 'monospace', - ), - ), - ), - const SizedBox(width: 8), - Material( - color: Colors.transparent, - child: IconButton( - iconSize: 16, - icon: const Icon(Icons.copy), - onPressed: () { - copyToClipBoard( - context, - privateKey.publicKeySecp256k1, - ); - }, - visualDensity: VisualDensity.compact, - tooltip: 'Copy pubkey', - ), - ), - ], + actions: [ + IconButton( + iconSize: 16, + icon: const Icon(Icons.copy), + onPressed: () { + copyToClipBoard( + context, + widget.privateKey.publicKeySecp256k1, + ); + }, + visualDensity: VisualDensity.compact, + tooltip: 'Copy pubkey', ), ], - - Row( - children: [ - Expanded( - child: AutoScrollText( - text: showPrivateKeys - ? privateKey.privateKey - : '*' * privateKey.privateKey.length, - style: theme.textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - ), - ), - ), - const SizedBox(width: 8), - Material( - color: Colors.transparent, - child: IconButton( - iconSize: 16, - icon: const Icon(Icons.copy), - onPressed: () { - copyToClipBoard(context, privateKey.privateKey); + ), + ], + const SizedBox(height: 6), + _buildDetailRow( + label: 'Private key', + labelStyle: labelStyle, + labelWidth: labelWidth, + value: AutoScrollText( + text: isVisible + ? widget.privateKey.privateKey + : '*' * widget.privateKey.privateKey.length, + style: valueStyle, + ), + actions: [ + IconButton( + iconSize: 16, + icon: Icon( + isVisible + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + onPressed: canReveal + ? () { + setState( + () => _isPrivateKeyVisible = !_isPrivateKeyVisible, + ); + if (_isPrivateKeyVisible) { + context.read().add( + const ShowPrivateKeysCopiedEvent(), + ); + } + } + : null, + visualDensity: VisualDensity.compact, + ), + IconButton( + iconSize: 16, + icon: const Icon(Icons.copy), + onPressed: canReveal + ? () { + copyToClipBoard( + context, + widget.privateKey.privateKey, + ); context.read().add( const ShowPrivateKeysCopiedEvent(), ); - }, - visualDensity: VisualDensity.compact, - ), - ), - Material( - color: Colors.transparent, - child: IconButton( - iconSize: 16, - icon: const Icon(Icons.qr_code), - onPressed: () { - _showQrDialog(context); - }, - visualDensity: VisualDensity.compact, - ), - ), - ], + } + : null, + visualDensity: VisualDensity.compact, + ), + IconButton( + iconSize: 16, + icon: const Icon(Icons.qr_code), + onPressed: canReveal ? _showQrDialog : null, + visualDensity: VisualDensity.compact, ), ], ), - ), - ); - }, + ], + ), + ), + ); + } + + Widget _buildDetailRow({ + required String label, + required TextStyle? labelStyle, + required double labelWidth, + required Widget value, + List actions = const [], + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: labelWidth, + child: Text(label, style: labelStyle), + ), + const SizedBox(width: 8), + Expanded(child: value), + if (actions.isNotEmpty) ...[ + const SizedBox(width: 8), + Row(mainAxisSize: MainAxisSize.min, children: actions), + ], + ], ); } @@ -400,7 +468,7 @@ class PrivateKeyListItem extends StatelessWidget { /// /// **Security Note**: Only shown when private keys are visible and /// user explicitly requests it. - void _showQrDialog(BuildContext context) { + void _showQrDialog() { showDialog( context: context, builder: (context) { @@ -420,7 +488,7 @@ class PrivateKeyListItem extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - assetId.id, + widget.assetId.id, style: Theme.of(context).textTheme.titleMedium, ), IconButton( @@ -431,18 +499,18 @@ class PrivateKeyListItem extends StatelessWidget { ), ], ), - if (privateKey.hdInfo?.derivationPath != null) ...[ + if (widget.privateKey.hdInfo?.derivationPath != null) ...[ Text( - 'Path: ${privateKey.hdInfo!.derivationPath}', + 'Path: ${widget.privateKey.hdInfo!.derivationPath}', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 8), ], const SizedBox(height: 16), - QRCodeAddress(currentAddress: privateKey.privateKey), + QRCodeAddress(currentAddress: widget.privateKey.privateKey), const SizedBox(height: 16), SelectableText( - privateKey.privateKey, + widget.privateKey.privateKey, textAlign: TextAlign.center, style: Theme.of( context, diff --git a/lib/views/wallet/wallet_page/common/grouped_assets_list.dart b/lib/views/wallet/wallet_page/common/grouped_assets_list.dart index 0bbd3648a7..340d04af82 100644 --- a/lib/views/wallet/wallet_page/common/grouped_assets_list.dart +++ b/lib/views/wallet/wallet_page/common/grouped_assets_list.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/asset_ticker_group_sort.dart'; import 'package:web_dex/views/wallet/wallet_page/common/grouped_asset_ticker_item.dart'; /// A widget that displays a list of assets grouped by their ticker symbols. @@ -66,6 +67,10 @@ class GroupedAssetsList extends StatelessWidget { groupedAssets.putIfAbsent(ticker, () => []).add(asset); } + for (final entry in groupedAssets.entries) { + sortAssetIdsWithinTickerGroup(entry.value); + } + return groupedAssets; } @@ -77,8 +82,9 @@ class GroupedAssetsList extends StatelessWidget { return assets.where((asset) { final searchLower = searchPhrase.toLowerCase(); - final isFound = - asset.toJson().toJsonString().toLowerCase().contains(searchLower); + final isFound = asset.toJson().toJsonString().toLowerCase().contains( + searchLower, + ); return isFound; }); diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart index a88e70d342..8df42a6fed 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart @@ -3,8 +3,7 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:komodo_defi_types/komodo_defi_types.dart' - show ActivationStep, AssetId; +import 'package:komodo_defi_types/komodo_defi_types.dart' show AssetId; import 'package:komodo_ui/komodo_ui.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; @@ -90,6 +89,12 @@ class _ZhtlcActivationStatusBarState extends State { }); } + Future _cancelActivation(AssetId assetId) async { + await widget.activationService.cancelActivation(assetId); + await widget.activationService.clearActivationStatus(assetId); + await _refreshStatuses(); + } + @override Widget build(BuildContext context) { // Filter out completed statuses older than 5 seconds @@ -193,16 +198,18 @@ class _ZhtlcActivationStatusBarState extends State { assetId, startTime, progressPercentage, - currentStep, + _, statusMessage, ) { return _ActivationStatusDetails( assetId: assetId, progressPercentage: progressPercentage?.toDouble() ?? 0, - currentStep: currentStep!, statusMessage: statusMessage ?? LocaleKeys.inProgress.tr(), + onCancel: () { + unawaited(_cancelActivation(assetId)); + }, ); }, ), @@ -221,14 +228,14 @@ class _ActivationStatusDetails extends StatelessWidget { const _ActivationStatusDetails({ required this.assetId, required this.progressPercentage, - required this.currentStep, required this.statusMessage, + required this.onCancel, }); final AssetId assetId; final double progressPercentage; - final ActivationStep currentStep; final String statusMessage; + final VoidCallback onCancel; @override Widget build(BuildContext context) { @@ -257,6 +264,15 @@ class _ActivationStatusDetails extends StatelessWidget { ), ), ), + Tooltip( + message: LocaleKeys.cancel.tr(), + child: IconButton( + visualDensity: VisualDensity.compact, + iconSize: 18, + onPressed: onCancel, + icon: const Icon(Icons.close_rounded), + ), + ), ], ), ); diff --git a/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart b/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart index 97582eaa59..19649522e0 100644 --- a/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart +++ b/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart @@ -11,6 +11,7 @@ import 'package:web_dex/common/screen.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_utils.dart'; +import 'package:web_dex/shared/constants.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/utils/formatters.dart'; import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; @@ -28,12 +29,14 @@ class ActiveCoinsList extends StatelessWidget { required this.searchPhrase, required this.withBalance, required this.onCoinItemTap, + this.onStatisticsTap, this.arrrActivationService, }); final String searchPhrase; final bool withBalance; final Function(Coin) onCoinItemTap; + final void Function(Coin)? onStatisticsTap; final ArrrActivationService? arrrActivationService; @override @@ -99,6 +102,9 @@ class ActiveCoinsList extends StatelessWidget { pubkeys: state.pubkeys[coin.abbr], isSelected: false, onTap: () => onCoinItemTap(coin), + onStatisticsTap: onStatisticsTap == null + ? null + : () => onStatisticsTap!(coin), ), ); }, @@ -111,6 +117,9 @@ class ActiveCoinsList extends StatelessWidget { Iterable _getDisplayedCoins(Iterable coins, KomodoDefiSdk sdk) => filterCoinsByPhrase(coins, searchPhrase).where((Coin coin) { + if (!coin.isActive && !coin.isActivating) { + return false; + } if (withBalance) { return (coin.lastKnownBalance(sdk)?.total ?? Decimal.zero) > Decimal.zero; @@ -215,6 +224,9 @@ class AddressBalanceCard extends StatelessWidget { @override Widget build(BuildContext context) { + final hideBalances = context.select( + (SettingsBloc bloc) => bloc.state.hideBalances, + ); return Card( margin: const EdgeInsets.all(8), child: Padding( @@ -280,7 +292,9 @@ class AddressBalanceCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${formatBalance(pubkey.balance.spendable.toBigInt())} ${coin.abbr}', + hideBalances + ? '$maskedBalanceText ${coin.abbr}' + : '${formatBalance(pubkey.balance.spendable.toBigInt())} ${coin.abbr}', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, @@ -343,6 +357,13 @@ class _AddressFiatBalance extends StatelessWidget { @override Widget build(BuildContext context) { + final hideBalances = context.select( + (SettingsBloc bloc) => bloc.state.hideBalances, + ); + if (hideBalances) { + return Text(maskedBalanceText, style: style); + } + final sdk = context.sdk; final price = sdk.marketData.priceIfKnown(coin.id); diff --git a/lib/views/wallet/wallet_page/wallet_main/balance_summary_widget.dart b/lib/views/wallet/wallet_page/wallet_main/balance_summary_widget.dart index 6e7b3ec45d..f9f5f919ac 100644 --- a/lib/views/wallet/wallet_page/wallet_main/balance_summary_widget.dart +++ b/lib/views/wallet/wallet_page/wallet_main/balance_summary_widget.dart @@ -3,6 +3,7 @@ import 'package:app_theme/src/light/theme_custom_light.dart'; import 'package:flutter/material.dart'; import 'package:komodo_ui/komodo_ui.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/shared/constants.dart'; /// Balance Summary Widget for mobile view class BalanceSummaryWidget extends StatelessWidget { @@ -11,6 +12,8 @@ class BalanceSummaryWidget extends StatelessWidget { final double? changePercentage; final VoidCallback? onTap; final VoidCallback? onLongPress; + final bool hideBalances; + final VoidCallback? onToggleHideBalances; const BalanceSummaryWidget({ super.key, @@ -19,6 +22,8 @@ class BalanceSummaryWidget extends StatelessWidget { required this.changePercentage, this.onTap, this.onLongPress, + this.hideBalances = false, + this.onToggleHideBalances, }); @override @@ -36,29 +41,54 @@ class BalanceSummaryWidget extends StatelessWidget { gradient: StatisticCard.containerGradient(theme), borderRadius: BorderRadius.circular(16), ), - alignment: Alignment.center, - child: Column( - mainAxisSize: MainAxisSize.min, + child: Stack( + alignment: Alignment.center, children: [ - // Total balance or placeholder - totalBalance != null - ? Text( - '\$${NumberFormat("#,##0.00").format(totalBalance!)}', - style: theme.textTheme.headlineSmall, - ) - : _BalancePlaceholder(), - const SizedBox(height: 12), - // Change indicator using TrendPercentageText or placeholder - totalBalance != null - ? TrendPercentageText( - percentage: changePercentage, - upColor: themeCustom.increaseColor, - downColor: themeCustom.decreaseColor, - value: changeAmount, - valueFormatter: (value) => - NumberFormat.currency(symbol: '\$').format(value), - ) - : _ChangePlaceholder(), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Total balance or placeholder + totalBalance != null + ? Text( + hideBalances + ? '\$${maskedBalanceText}' + : '\$${NumberFormat("#,##0.00").format(totalBalance!)}', + style: theme.textTheme.headlineSmall, + ) + : _BalancePlaceholder(), + const SizedBox(height: 12), + // Change indicator using TrendPercentageText or placeholder + totalBalance != null && !hideBalances + ? TrendPercentageText( + percentage: changePercentage, + upColor: themeCustom.increaseColor, + downColor: themeCustom.decreaseColor, + value: changeAmount, + valueFormatter: (value) => + NumberFormat.currency(symbol: '\$').format(value), + ) + : _ChangePlaceholder(), + ], + ), + if (onToggleHideBalances != null) + Positioned( + top: 8, + right: 8, + child: IconButton( + key: const Key('balance-summary-privacy-toggle'), + iconSize: 20, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 32, minHeight: 32), + icon: Icon( + hideBalances + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + onPressed: onToggleHideBalances, + ), + ), ], ), ), diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart index 57ae92d646..f0b6e6a974 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart @@ -57,11 +57,13 @@ class WalletMain extends StatefulWidget { } class _WalletMainState extends State with TickerProviderStateMixin { - String _searchKey = ''; + final ValueNotifier _searchPhraseNotifier = ValueNotifier(''); PopupDispatcher? _popupDispatcher; StreamSubscription? _walletSubscription; late TabController _tabController; int _activeTabIndex = 0; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); final ScrollController _scrollController = ScrollController(); late final Stopwatch _walletListStopwatch; bool _walletHalfLogged = false; @@ -105,6 +107,9 @@ class _WalletMainState extends State with TickerProviderStateMixin { _walletSubscription?.cancel(); _popupDispatcher?.close(); _popupDispatcher = null; + _searchController.dispose(); + _searchFocusNode.dispose(); + _searchPhraseNotifier.dispose(); _scrollController.dispose(); _tabController.dispose(); super.dispose(); @@ -130,70 +135,77 @@ class _WalletMainState extends State with TickerProviderStateMixin { ? AuthorizeMode.noLogin : AuthorizeMode.logIn; final isLoggedIn = authStateMode == AuthorizeMode.logIn; + final walletType = authState.currentUser?.wallet.config.type; + final showMultiAddressNotice = + kShowHdWalletWarningBanner && + isLoggedIn && + walletType == WalletType.hdwallet; return ZhtlcConfigurationHandler( - child: BlocBuilder( - builder: (context, state) { - final walletCoinsFiltered = state.walletCoins.values.toList(); - - return PageLayout( - noBackground: true, - header: (isMobile && !isLoggedIn) - ? PageHeader(title: LocaleKeys.wallet.tr()) - : null, - padding: EdgeInsets.zero, - // Removed page padding here - content: Expanded( - child: Listener( - onPointerSignal: _onPointerSignal, - child: CustomScrollView( - key: const Key('wallet-page-scroll-view'), - controller: _scrollController, - slivers: [ - // Add a SizedBox at the top of the sliver list for spacing - if (isLoggedIn) ...[ - if (!isMobile) - const SliverToBoxAdapter( - child: SizedBox(height: 32), - ), - SliverToBoxAdapter( - child: WalletOverview( - key: const Key('wallet-overview'), - onPortfolioGrowthPressed: () => - _tabController.animateTo(1), - onPortfolioProfitLossPressed: () => - _tabController.animateTo(2), - onAssetsPressed: () => - _tabController.animateTo(0), - ), + child: PageLayout( + noBackground: true, + header: (isMobile && !isLoggedIn) + ? PageHeader(title: LocaleKeys.wallet.tr()) + : null, + padding: EdgeInsets.zero, + // Removed page padding here + content: Expanded( + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerSignal: _onPointerSignal, + child: DexScrollbar( + scrollController: _scrollController, + isMobile: isMobile, + child: CustomScrollView( + key: const Key('wallet-page-scroll-view'), + controller: _scrollController, + slivers: [ + // Add a SizedBox at the top of the sliver list for spacing + if (isLoggedIn) ...[ + if (!isMobile) + const SliverToBoxAdapter(child: SizedBox(height: 32)), + SliverToBoxAdapter( + child: WalletOverview( + key: const Key('wallet-overview'), + onPortfolioGrowthPressed: () => + _tabController.animateTo(1), + onPortfolioProfitLossPressed: () => + _tabController.animateTo(2), + onAssetsPressed: () => _tabController.animateTo(0), ), - const SliverToBoxAdapter(child: Gap(24)), + ), + const SliverToBoxAdapter(child: Gap(24)), + if (showMultiAddressNotice) ...[ + const SliverToBoxAdapter( + child: _MultiAddressWalletNotice(), + ), + const SliverToBoxAdapter(child: Gap(16)), ], - SliverPersistentHeader( - pinned: true, - delegate: _SliverTabBarDelegate( - TabBar( - controller: _tabController, - tabs: [ - Tab(text: LocaleKeys.assets.tr()), - if (isLoggedIn) - Tab(text: LocaleKeys.portfolioGrowth.tr()) - else - Tab(text: LocaleKeys.statistics.tr()), - if (isLoggedIn) - Tab(text: LocaleKeys.profitAndLoss.tr()), - ], - ), + ], + SliverPersistentHeader( + pinned: true, + delegate: _SliverTabBarDelegate( + TabBar( + controller: _tabController, + tabs: [ + Tab(text: LocaleKeys.assets.tr()), + if (isLoggedIn) + Tab(text: LocaleKeys.portfolioGrowth.tr()) + else + Tab(text: LocaleKeys.statistics.tr()), + if (isLoggedIn) + Tab(text: LocaleKeys.profitAndLoss.tr()), + ], ), ), - if (!isMobile) SliverToBoxAdapter(child: Gap(24)), - ..._buildTabSlivers(authStateMode, walletCoinsFiltered), - ], - ), + ), + if (!isMobile) SliverToBoxAdapter(child: Gap(24)), + ..._buildTabSlivers(authStateMode), + ], ), ), - ); - }, + ), + ), ), ); }, @@ -260,9 +272,12 @@ class _WalletMainState extends State with TickerProviderStateMixin { } void _onSearchChange(String searchKey) { - setState(() { - _searchKey = searchKey.toLowerCase(); - }); + final normalizedSearchKey = searchKey.toLowerCase(); + if (_searchPhraseNotifier.value == normalizedSearchKey) { + return; + } + + _searchPhraseNotifier.value = normalizedSearchKey; } void _onActiveCoinItemTap(Coin coin) { @@ -282,13 +297,15 @@ class _WalletMainState extends State with TickerProviderStateMixin { _tabController.animateTo(1); } - List _buildTabSlivers(AuthorizeMode mode, List walletCoins) { + List _buildTabSlivers(AuthorizeMode mode) { switch (_activeTabIndex) { case 0: return [ SliverPersistentHeader( pinned: true, delegate: _SliverSearchBarDelegate( + searchController: _searchController, + searchFocusNode: _searchFocusNode, withBalance: context .watch() .state @@ -299,39 +316,63 @@ class _WalletMainState extends State with TickerProviderStateMixin { ), ), if (!isMobile) const SliverToBoxAdapter(child: SizedBox(height: 22)), - CoinListView( - mode: mode, - searchPhrase: _searchKey, - withBalance: context - .watch() - .state - .hideZeroBalanceAssets, - onActiveCoinItemTap: _onActiveCoinItemTap, - onAssetItemTap: _onAssetItemTap, - onAssetStatisticsTap: _onAssetStatisticsTap, + ValueListenableBuilder( + valueListenable: _searchPhraseNotifier, + builder: (context, searchPhrase, child) { + return CoinListView( + mode: mode, + searchPhrase: searchPhrase, + withBalance: context + .watch() + .state + .hideZeroBalanceAssets, + onActiveCoinItemTap: _onActiveCoinItemTap, + onAssetItemTap: _onAssetItemTap, + onAssetStatisticsTap: _onAssetStatisticsTap, + ); + }, ), ]; case 1: - return [ - SliverToBoxAdapter( - child: SizedBox( - width: double.infinity, - height: 340, - child: mode == AuthorizeMode.logIn - ? PortfolioGrowthChart(initialCoins: walletCoins) - : const PriceChartPage(), + if (mode != AuthorizeMode.logIn) { + return const [ + SliverToBoxAdapter( + child: SizedBox( + width: double.infinity, + height: 340, + child: PriceChartPage(), + ), ), + ]; + } + return [ + BlocSelector>( + selector: (state) => state.walletCoins.values.toList(), + builder: (context, walletCoins) { + return SliverToBoxAdapter( + child: SizedBox( + width: double.infinity, + height: 340, + child: PortfolioGrowthChart(initialCoins: walletCoins), + ), + ); + }, ), ]; case 2: if (mode != AuthorizeMode.logIn) return []; return [ - SliverToBoxAdapter( - child: SizedBox( - width: double.infinity, - height: 340, - child: PortfolioProfitLossChart(initialCoins: walletCoins), - ), + BlocSelector>( + selector: (state) => state.walletCoins.values.toList(), + builder: (context, walletCoins) { + return SliverToBoxAdapter( + child: SizedBox( + width: double.infinity, + height: 340, + child: PortfolioProfitLossChart(initialCoins: walletCoins), + ), + ); + }, ), ]; default: @@ -377,6 +418,7 @@ class _WalletMainState extends State with TickerProviderStateMixin { width: 320, context: scaffoldKey.currentContext ?? context, barrierColor: isMobile ? Theme.of(context).colorScheme.onSurface : null, + barrierDismissible: false, borderColor: theme.custom.specificButtonBorderColor, popupContent: WalletsManagerWrapper( eventType: WalletsManagerEventType.wallet, @@ -417,45 +459,93 @@ class CoinListView extends StatelessWidget { searchPhrase: searchPhrase, withBalance: withBalance, onCoinItemTap: onActiveCoinItemTap, + onStatisticsTap: (coin) => + onAssetStatisticsTap(coin.assetId, const Duration(days: 1)), arrrActivationService: RepositoryProvider.of( context, ), ); case AuthorizeMode.hiddenLogin: case AuthorizeMode.noLogin: - // Assets are sorted by priority at the group level in AssetsList._groupAssetsByTicker() - return AssetsList( - useGroupedView: true, - assets: context - .read() - .state - .coins - .values - .map((coin) => coin.assetId) - .toList(), - withBalance: false, - searchPhrase: searchPhrase, - onAssetItemTap: (assetId) => onAssetItemTap( - context.read().state.coins.values.firstWhere( - (coin) => coin.assetId == assetId, - ), - ), - onStatisticsTap: onAssetStatisticsTap, + return BlocSelector>( + selector: (state) => state.coins.values.toList(), + builder: (context, coins) { + // Assets are sorted by priority at the group level in AssetsList._groupAssetsByTicker() + return AssetsList( + useGroupedView: true, + assets: coins.map((coin) => coin.assetId).toList(), + withBalance: false, + searchPhrase: searchPhrase, + onAssetItemTap: (assetId) => onAssetItemTap( + coins.firstWhere((coin) => coin.assetId == assetId), + ), + onStatisticsTap: onAssetStatisticsTap, + ); + }, ); } } } +class _MultiAddressWalletNotice extends StatelessWidget { + const _MultiAddressWalletNotice(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final borderColor = theme.colorScheme.primary.withValues(alpha: 0.2); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: theme.colorScheme.primary, size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.multiAddressWalletNoticeTitle.tr(), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + LocaleKeys.multiAddressWalletNoticeDescription.tr(), + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ], + ), + ); + } +} + class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { _SliverSearchBarDelegate({ + required this.searchController, + required this.searchFocusNode, required this.withBalance, required this.onSearchChange, required this.onWithBalanceChange, required this.mode, }); + final TextEditingController searchController; + final FocusNode searchFocusNode; final bool withBalance; - final Function(String) onSearchChange; - final Function(bool) onWithBalanceChange; + final ValueChanged onSearchChange; + final ValueChanged onWithBalanceChange; final AuthorizeMode mode; @override @@ -480,6 +570,8 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { height: (maxExtent - shrinkOffset).clamp(minExtent, maxExtent), child: WalletManageSection( withBalance: withBalance, + searchController: searchController, + searchFocusNode: searchFocusNode, onSearchChange: onSearchChange, onWithBalanceChange: onWithBalanceChange, mode: mode, @@ -491,7 +583,10 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { @override bool shouldRebuild(_SliverSearchBarDelegate oldDelegate) { - return withBalance != oldDelegate.withBalance || mode != oldDelegate.mode; + return withBalance != oldDelegate.withBalance || + mode != oldDelegate.mode || + searchController != oldDelegate.searchController || + searchFocusNode != oldDelegate.searchFocusNode; } } diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart index 6e7b621a5c..8698488dc6 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart @@ -14,6 +14,8 @@ class WalletManageSection extends StatelessWidget { const WalletManageSection({ required this.mode, required this.withBalance, + required this.searchController, + required this.searchFocusNode, required this.onSearchChange, required this.onWithBalanceChange, required this.pinned, @@ -22,8 +24,10 @@ class WalletManageSection extends StatelessWidget { }); final bool withBalance; final AuthorizeMode mode; - final Function(bool) onWithBalanceChange; - final Function(String) onSearchChange; + final TextEditingController searchController; + final FocusNode searchFocusNode; + final ValueChanged onWithBalanceChange; + final ValueChanged onSearchChange; final bool pinned; final double collapseProgress; @@ -49,7 +53,11 @@ class WalletManageSection extends StatelessWidget { Container( alignment: Alignment.centerLeft, constraints: const BoxConstraints(maxWidth: 300), - child: WalletManagerSearchField(onChange: onSearchChange), + child: WalletManagerSearchField( + controller: searchController, + focusNode: searchFocusNode, + onChange: onSearchChange, + ), ), if (isAuthenticated) ...[ // const Spacer(), @@ -86,7 +94,11 @@ class WalletManageSection extends StatelessWidget { Row( children: [ Expanded( - child: WalletManagerSearchField(onChange: onSearchChange), + child: WalletManagerSearchField( + controller: searchController, + focusNode: searchFocusNode, + onChange: onSearchChange, + ), ), ], ), @@ -115,9 +127,9 @@ class WalletManageSection extends StatelessWidget { } void _onAddAssetsPress(BuildContext context) { - context - .read() - .add(const CoinsManagerCoinsListReset(CoinsManagerAction.add)); + context.read().add( + const CoinsManagerCoinsListReset(CoinsManagerAction.add), + ); routingState.walletState.action = coinsManagerRouteAction.addAssets; } } @@ -130,7 +142,7 @@ class CoinsWithBalanceCheckbox extends StatelessWidget { }); final bool withBalance; - final Function(bool) onWithBalanceChange; + final ValueChanged onWithBalanceChange; @override Widget build(BuildContext context) { @@ -142,10 +154,7 @@ class CoinsWithBalanceCheckbox extends StatelessWidget { elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), - side: BorderSide( - color: Theme.of(context).dividerColor, - width: 1, - ), + side: BorderSide(color: Theme.of(context).dividerColor, width: 1), ), child: Container( alignment: Alignment.center, diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_manager_search_field.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_manager_search_field.dart index b5db09602d..f2e7b6b4cc 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_manager_search_field.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_manager_search_field.dart @@ -3,51 +3,37 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; -class WalletManagerSearchField extends StatefulWidget { - const WalletManagerSearchField({required this.onChange}); - final Function(String) onChange; +class WalletManagerSearchField extends StatelessWidget { + const WalletManagerSearchField({ + super.key, + required this.controller, + required this.focusNode, + required this.onChange, + }); - @override - State createState() => - _WalletManagerSearchFieldState(); -} - -class _WalletManagerSearchFieldState extends State { - final TextEditingController _searchController = TextEditingController(); - final FocusNode _focusNode = FocusNode(); - - @override - void initState() { - _searchController.addListener(_onChange); - super.initState(); - } - - @override - void dispose() { - _searchController.removeListener(_onChange); - _focusNode.dispose(); - super.dispose(); - } + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onChange; @override Widget build(BuildContext context) { return TextFormField( key: const Key('wallet-page-search-field'), - controller: _searchController, - focusNode: _focusNode, + controller: controller, + focusNode: focusNode, autocorrect: false, textInputAction: TextInputAction.search, enableInteractiveSelection: true, inputFormatters: [LengthLimitingTextInputFormatter(40)], + onChanged: (value) => onChange(value.trim()), decoration: InputDecoration( filled: true, hintText: LocaleKeys.search.tr(), - prefixIcon: Icon( - Icons.search, - size: 20, + prefixIcon: Icon(Icons.search, size: 20), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 12), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, @@ -55,8 +41,4 @@ class _WalletManagerSearchFieldState extends State { ), ); } - - void _onChange() { - widget.onChange(_searchController.text.trim()); - } } diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_overview.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_overview.dart index 2cd16cfcaa..1fb50f9413 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_overview.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_overview.dart @@ -1,5 +1,4 @@ -import 'package:app_theme/src/dark/theme_custom_dark.dart'; -import 'package:app_theme/src/light/theme_custom_light.dart'; +import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -9,11 +8,13 @@ import 'package:web_dex/analytics/events/portfolio_events.dart'; import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; import 'package:web_dex/bloc/assets_overview/bloc/asset_overview_bloc.dart'; import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart'; -import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_event.dart'; import 'package:web_dex/common/screen.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; -import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/balance_utils.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/views/wallet/wallet_page/wallet_main/balance_summary_widget.dart'; @@ -46,12 +47,16 @@ class WalletOverview extends StatefulWidget { class _WalletOverviewState extends State { bool _logged = false; + static const double _desktopCaptionHeight = 20; @override Widget build(BuildContext context) { final themeCustom = Theme.of(context).brightness == Brightness.dark ? Theme.of(context).extension()! : Theme.of(context).extension()!; + final hideBalances = context.select( + (SettingsBloc bloc) => bloc.state.hideBalances, + ); return BlocBuilder( builder: (context, state) { @@ -70,9 +75,10 @@ class _WalletOverviewState extends State { // Calculate the total balance from the SDK balances and market data // interfaces rather than the PortfolioGrowthBloc - limited coin // coverage and dependent on OHLC API request limits. - final double? totalBalance = _getTotalBalance( - state.walletCoins.values, - context, + final double? totalBalance = computeWalletTotalUsd( + coins: state.walletCoins.values, + coinsState: state, + sdk: context.sdk, ); if (!_logged && stateWithData != null && totalBalance != null) { @@ -106,7 +112,7 @@ class _WalletOverviewState extends State { changeAmount: totalChange24h, changePercentage: percentageChange24h, onTap: widget.onAssetsPressed, - onLongPress: totalBalance != null + onLongPress: totalBalance != null && !hideBalances ? () { final formattedValue = NumberFormat.currency( symbol: '\$', @@ -114,16 +120,35 @@ class _WalletOverviewState extends State { copyToClipBoard(context, formattedValue); } : null, + hideBalances: hideBalances, + onToggleHideBalances: () { + context.read().add( + HideBalancesChanged(hideBalances: !hideBalances), + ); + }, ); }, ), ] else ...[ StatisticCard( key: const Key('overview-current-value'), - caption: Text(LocaleKeys.yourBalance.tr()), + caption: SizedBox( + height: _desktopCaptionHeight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(LocaleKeys.yourBalance.tr()), + const SizedBox(width: 4), + _BalancePrivacyToggleButton(hideBalances: hideBalances), + ], + ), + ), value: totalBalance, + valueText: hideBalances && totalBalance != null + ? '\$$maskedBalanceText' + : null, onTap: widget.onAssetsPressed, - onLongPress: totalBalance != null + onLongPress: totalBalance != null && !hideBalances ? () { final formattedValue = NumberFormat.currency( symbol: '\$', @@ -131,7 +156,7 @@ class _WalletOverviewState extends State { copyToClipBoard(context, formattedValue); } : null, - trendWidget: totalBalance != null + trendWidget: totalBalance != null && !hideBalances ? BlocBuilder( builder: (context, state) { final double totalChange = @@ -159,12 +184,22 @@ class _WalletOverviewState extends State { ], StatisticCard( key: const Key('overview-all-time-investment'), - caption: Text(LocaleKeys.allTimeInvestment.tr()), + caption: SizedBox( + height: _desktopCaptionHeight, + child: Align( + alignment: Alignment.centerLeft, + child: Text(LocaleKeys.allTimeInvestment.tr()), + ), + ), value: totalBalance != null ? (stateWithData?.totalInvestment.value) : null, + valueText: hideBalances && totalBalance != null + ? '\$$maskedBalanceText' + : null, onTap: widget.onPortfolioGrowthPressed, - onLongPress: totalBalance != null && stateWithData != null + onLongPress: + totalBalance != null && stateWithData != null && !hideBalances ? () { final formattedValue = NumberFormat.currency( symbol: '\$', @@ -187,12 +222,22 @@ class _WalletOverviewState extends State { ), StatisticCard( key: const Key('overview-all-time-profit'), - caption: Text(LocaleKeys.allTimeProfit.tr()), + caption: SizedBox( + height: _desktopCaptionHeight, + child: Align( + alignment: Alignment.centerLeft, + child: Text(LocaleKeys.allTimeProfit.tr()), + ), + ), value: totalBalance != null ? (stateWithData?.profitAmount.value) : null, + valueText: hideBalances && totalBalance != null + ? '\$$maskedBalanceText' + : null, onTap: widget.onPortfolioProfitLossPressed, - onLongPress: totalBalance != null && stateWithData != null + onLongPress: + totalBalance != null && stateWithData != null && !hideBalances ? () { final formattedValue = NumberFormat.currency( symbol: '\$', @@ -200,7 +245,8 @@ class _WalletOverviewState extends State { copyToClipBoard(context, formattedValue); } : null, - trendWidget: totalBalance != null && stateWithData != null + trendWidget: + totalBalance != null && stateWithData != null && !hideBalances ? TrendPercentageText( percentage: stateWithData.profitIncreasePercentage, upColor: themeCustom.increaseColor, @@ -215,14 +261,14 @@ class _WalletOverviewState extends State { if (isMobile) { return StatisticsCarousel(cards: statisticCards); - } else { - return Row( - spacing: 24, - children: statisticCards.map((card) { - return Expanded(child: card); - }).toList(), - ); } + + return Row( + spacing: 24, + children: statisticCards.map((card) { + return Expanded(child: card); + }).toList(), + ); }, ); } @@ -233,29 +279,33 @@ class _WalletOverviewState extends State { children: [Padding(padding: EdgeInsets.all(20.0), child: UiSpinner())], ); } +} - // TODO: Migrate these values to a new/existing bloc e.g. PortfolioGrowthBloc - double? _getTotalBalance(Iterable coins, BuildContext context) { - // Check if any coins have USD balance data available - bool hasAnyUsdBalance = coins.any( - (coin) => coin.usdBalance(context.sdk) != null, - ); +class _BalancePrivacyToggleButton extends StatelessWidget { + const _BalancePrivacyToggleButton({required this.hideBalances}); - // If no USD balance data is available, return null to show placeholder - if (!hasAnyUsdBalance) { - return null; - } + final bool hideBalances; - double total = coins.fold( - 0, - (prev, coin) => prev + (coin.usdBalance(context.sdk) ?? 0), + @override + Widget build(BuildContext context) { + return Tooltip( + message: LocaleKeys.hideBalancesTitle.tr(), + child: InkResponse( + key: const Key('wallet-overview-privacy-toggle'), + radius: 16, + onTap: () { + context.read().add( + HideBalancesChanged(hideBalances: !hideBalances), + ); + }, + child: Icon( + hideBalances + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + size: 20, + ), + ), ); - - if (total > 0.01) { - return total; - } - - return total != 0 ? 0.01 : 0; } } diff --git a/lib/views/wallets_manager/wallets_manager_wrapper.dart b/lib/views/wallets_manager/wallets_manager_wrapper.dart index d7ff865464..7a87d791f0 100644 --- a/lib/views/wallets_manager/wallets_manager_wrapper.dart +++ b/lib/views/wallets_manager/wallets_manager_wrapper.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; @@ -13,6 +14,7 @@ class WalletsManagerWrapper extends StatefulWidget { this.selectedWallet, this.initialHdMode = false, this.rememberMe = false, + this.onCancel, super.key = const Key('wallets-manager-wrapper'), }); @@ -21,6 +23,7 @@ class WalletsManagerWrapper extends StatefulWidget { final Wallet? selectedWallet; final bool initialHdMode; final bool rememberMe; + final VoidCallback? onCancel; @override State createState() => _WalletsManagerWrapperState(); @@ -50,6 +53,13 @@ class _WalletsManagerWrapperState extends State { padding: const EdgeInsets.only(top: 30.0), child: WalletsTypeList(onWalletTypeClick: _onWalletTypeClick), ), + Padding( + padding: const EdgeInsets.only(top: 20.0), + child: UiUnderlineTextButton( + text: LocaleKeys.cancel.tr(), + onPressed: _handleCancel, + ), + ), ], ); } @@ -78,4 +88,13 @@ class _WalletsManagerWrapperState extends State { _selectedWalletType = null; }); } + + void _handleCancel() { + final onCancel = widget.onCancel; + if (onCancel != null) { + onCancel(); + return; + } + Navigator.of(context).maybePop(); + } } diff --git a/lib/views/wallets_manager/widgets/custom_seed_dialog.dart b/lib/views/wallets_manager/widgets/custom_seed_dialog.dart index f72cfad128..476a345cb9 100644 --- a/lib/views/wallets_manager/widgets/custom_seed_dialog.dart +++ b/lib/views/wallets_manager/widgets/custom_seed_dialog.dart @@ -17,52 +17,58 @@ Future customSeedDialog(BuildContext context) async { popupManager = PopupDispatcher( context: context, - popupContent: StatefulBuilder(builder: (context, setState) { - return Container( - constraints: isMobile ? null : const BoxConstraints(maxWidth: 360), - child: Column( - children: [ - Text( - LocaleKeys.customSeedWarningText.tr(), - style: const TextStyle(fontSize: 14), - ), - const SizedBox(height: 20), - TextField( - key: const Key('custom-seed-dialog-input'), - autofocus: true, - onChanged: (String text) { - setState(() { - isConfirmed = text.trim().toLowerCase() == - LocaleKeys.customSeedIUnderstand.tr().toLowerCase(); - }); - }, - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Flexible( + barrierDismissible: false, + popupContent: StatefulBuilder( + builder: (context, setState) { + return Container( + constraints: isMobile ? null : const BoxConstraints(maxWidth: 360), + child: Column( + children: [ + Text( + LocaleKeys.customSeedWarningText.tr(), + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 20), + TextField( + key: const Key('custom-seed-dialog-input'), + autofocus: true, + onChanged: (String text) { + setState(() { + isConfirmed = + text.trim().toLowerCase() == + LocaleKeys.customSeedIUnderstand.tr().toLowerCase(); + }); + }, + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( child: UiUnderlineTextButton( - key: const Key('custom-seed-dialog-cancel-button'), - text: LocaleKeys.cancel.tr(), - onPressed: () { - setState(() => isConfirmed = false); - close(); - })), - const SizedBox(width: 12), - Flexible( - child: UiPrimaryButton( - key: const Key('custom-seed-dialog-ok-button'), - text: LocaleKeys.ok.tr(), - onPressed: !isConfirmed ? null : close, + key: const Key('custom-seed-dialog-cancel-button'), + text: LocaleKeys.cancel.tr(), + onPressed: () { + setState(() => isConfirmed = false); + close(); + }, + ), ), - ), - ], - ) - ], - ), - ); - }), + const SizedBox(width: 12), + Flexible( + child: UiPrimaryButton( + key: const Key('custom-seed-dialog-ok-button'), + text: LocaleKeys.ok.tr(), + onPressed: !isConfirmed ? null : close, + ), + ), + ], + ), + ], + ), + ); + }, + ), ); isOpen = true; diff --git a/lib/views/wallets_manager/widgets/hardware_wallets_manager.dart b/lib/views/wallets_manager/widgets/hardware_wallets_manager.dart index b752c62e39..4f18576277 100644 --- a/lib/views/wallets_manager/widgets/hardware_wallets_manager.dart +++ b/lib/views/wallets_manager/widgets/hardware_wallets_manager.dart @@ -8,6 +8,7 @@ import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/main_menu_value.dart'; +import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/router/state/routing_state.dart'; import 'package:web_dex/views/common/hw_wallet_dialog/hw_dialog_init.dart'; import 'package:web_dex/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_error.dart'; @@ -19,16 +20,22 @@ import 'package:web_dex/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; class HardwareWalletsManager extends StatelessWidget { - const HardwareWalletsManager( - {super.key, required this.close, required this.eventType}); + const HardwareWalletsManager({ + super.key, + required this.close, + required this.onSuccess, + required this.eventType, + }); final WalletsManagerEventType eventType; final VoidCallback close; + final void Function(Wallet) onSuccess; @override Widget build(BuildContext context) { return HardwareWalletsManagerView( close: close, + onSuccess: onSuccess, eventType: eventType, ); } @@ -39,9 +46,11 @@ class HardwareWalletsManagerView extends StatefulWidget { super.key, required this.eventType, required this.close, + required this.onSuccess, }); final WalletsManagerEventType eventType; final VoidCallback close; + final void Function(Wallet) onSuccess; @override State createState() => @@ -50,6 +59,8 @@ class HardwareWalletsManagerView extends StatefulWidget { class _HardwareWalletsManagerViewState extends State { + bool _didHandleSuccessfulLogin = false; + @override void initState() { context.read().add(AuthTrezorCancelled()); @@ -61,8 +72,12 @@ class _HardwareWalletsManagerViewState return BlocListener( listener: (context, state) { final status = state.status; - if (status == AuthenticationStatus.completed) { - _successfulTrezorLogin(context, state.currentUser!); + final user = state.currentUser; + if (status == AuthenticationStatus.completed && + user != null && + !_didHandleSuccessfulLogin) { + _didHandleSuccessfulLogin = true; + _successfulTrezorLogin(context, user); } }, child: BlocSelector( @@ -81,19 +96,19 @@ class _HardwareWalletsManagerViewState void _successfulTrezorLogin(BuildContext context, KdfUser kdfUser) { context.read().add(CoinsSessionStarted(kdfUser)); context.read().logEvent( - walletsManagerEventsFactory.createEvent( - widget.eventType, WalletsManagerEventMethod.hardware), - ); + walletsManagerEventsFactory.createEvent( + widget.eventType, + WalletsManagerEventMethod.hardware, + ), + ); routingState.selectedMenu = MainMenuValue.wallet; - widget.close(); + widget.onSuccess(kdfUser.wallet); } } class _HardwareWalletManagerPopupContent extends StatelessWidget { - const _HardwareWalletManagerPopupContent({ - required this.widget, - }); + const _HardwareWalletManagerPopupContent({required this.widget}); final HardwareWalletsManagerView widget; @@ -109,10 +124,7 @@ class _HardwareWalletManagerPopupContent extends StatelessWidget { case AuthenticationStatus.initializing: case AuthenticationStatus.authenticating: - return TrezorDialogInProgress( - initStatus, - onClose: widget.close, - ); + return TrezorDialogInProgress(initStatus, onClose: widget.close); case AuthenticationStatus.pinRequired: return TrezorDialogPinPad( @@ -126,9 +138,9 @@ class _HardwareWalletManagerPopupContent extends StatelessWidget { case AuthenticationStatus.passphraseRequired: return TrezorDialogSelectWallet( onComplete: (String passphrase) { - context - .read() - .add(AuthTrezorPassphraseProvided(passphrase)); + context.read().add( + AuthTrezorPassphraseProvided(passphrase), + ); }, ); diff --git a/lib/views/wallets_manager/widgets/hdwallet_mode_switch.dart b/lib/views/wallets_manager/widgets/hdwallet_mode_switch.dart index e039d19cae..4fc64e7eed 100644 --- a/lib/views/wallets_manager/widgets/hdwallet_mode_switch.dart +++ b/lib/views/wallets_manager/widgets/hdwallet_mode_switch.dart @@ -7,10 +7,10 @@ class HDWalletModeSwitch extends StatelessWidget { final ValueChanged onChanged; const HDWalletModeSwitch({ - Key? key, required this.value, required this.onChanged, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { diff --git a/lib/views/wallets_manager/widgets/wallet_creation.dart b/lib/views/wallets_manager/widgets/wallet_creation.dart index 3d732d1bea..46bec74c36 100644 --- a/lib/views/wallets_manager/widgets/wallet_creation.dart +++ b/lib/views/wallets_manager/widgets/wallet_creation.dart @@ -12,7 +12,6 @@ import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/shared/widgets/disclaimer/eula_tos_checkboxes.dart'; import 'package:web_dex/shared/widgets/quick_login_switch.dart'; import 'package:web_dex/views/wallets_manager/widgets/creation_password_fields.dart'; -import 'package:web_dex/views/wallets_manager/widgets/hdwallet_mode_switch.dart'; import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; class WalletCreation extends StatefulWidget { @@ -45,7 +44,6 @@ class _WalletCreationState extends State { final GlobalKey _formKey = GlobalKey(); bool _eulaAndTosChecked = false; bool _inProgress = false; - bool _isHdMode = true; bool _rememberMe = false; bool _arePasswordsValid = false; @@ -169,13 +167,6 @@ class _WalletCreationState extends State { if (_isCreateButtonEnabled) _onCreate(); }, ), - const SizedBox(height: 16), - HDWalletModeSwitch( - value: _isHdMode, - onChanged: (value) { - setState(() => _isHdMode = value); - }, - ), const SizedBox(height: 20), QuickLoginSwitch( key: const Key('checkbox-one-click-login-signup'), @@ -237,7 +228,7 @@ class _WalletCreationState extends State { widget.onCreate( name: _nameController.text.trim(), password: _passwordController.text, - walletType: _isHdMode ? WalletType.hdwallet : WalletType.iguana, + walletType: WalletType.hdwallet, rememberMe: _rememberMe, ); }); diff --git a/lib/views/wallets_manager/widgets/wallet_import_by_file.dart b/lib/views/wallets_manager/widgets/wallet_import_by_file.dart index 4e537a8707..60ffa0b8ee 100644 --- a/lib/views/wallets_manager/widgets/wallet_import_by_file.dart +++ b/lib/views/wallets_manager/widgets/wallet_import_by_file.dart @@ -17,7 +17,7 @@ import 'package:web_dex/shared/widgets/disclaimer/eula_tos_checkboxes.dart'; import 'package:web_dex/shared/widgets/password_visibility_control.dart'; import 'package:web_dex/shared/widgets/quick_login_switch.dart'; import 'package:web_dex/views/wallets_manager/widgets/custom_seed_checkbox.dart'; -import 'package:web_dex/views/wallets_manager/widgets/hdwallet_mode_switch.dart'; +import 'package:web_dex/views/wallets_manager/widgets/wallet_import_type_dropdown.dart'; import 'package:web_dex/views/wallets_manager/widgets/wallet_rename_dialog.dart'; class WalletFileData { @@ -54,7 +54,8 @@ class _WalletImportByFileState extends State { ); final GlobalKey _formKey = GlobalKey(); bool _isObscured = true; - bool _isHdMode = false; + bool _isHdMode = true; + bool _isHdOptionEnabled = true; bool _eulaAndTosChecked = false; bool _rememberMe = false; bool _allowCustomSeed = false; @@ -145,15 +146,17 @@ class _WalletImportByFileState extends State { ), ), const SizedBox(height: 30), - HDWalletModeSwitch( - value: _isHdMode, - onChanged: (value) { + WalletImportTypeDropdown( + selectedType: _isHdMode + ? WalletType.hdwallet + : WalletType.iguana, + isHdOptionEnabled: _isHdOptionEnabled, + onChanged: (walletType) { setState(() { - _isHdMode = value; - // Reset custom seed usage and hide toggle on HD switch - if (_isHdMode) { - _allowCustomSeed = false; - } + _isHdMode = walletType == WalletType.hdwallet; + _commonError = null; + _allowCustomSeed = false; + _showCustomSeedToggle = false; }); }, ), @@ -257,11 +260,19 @@ class _WalletImportByFileState extends State { if (!isBip39) { if (_isHdMode) { setState(() { - _commonError = LocaleKeys.walletCreationHdBip39SeedError.tr(); + _isHdMode = false; + _isHdOptionEnabled = false; + _allowCustomSeed = false; + _commonError = LocaleKeys.walletCreationBip39SeedError.tr(); _showCustomSeedToggle = true; }); return; } + if (_isHdOptionEnabled) { + setState(() { + _isHdOptionEnabled = false; + }); + } if (!_allowCustomSeed) { setState(() { _commonError = LocaleKeys.walletCreationBip39SeedError.tr(); @@ -274,6 +285,7 @@ class _WalletImportByFileState extends State { walletConfig.seedPhrase = decryptedSeed; String name = widget.fileData.name.replaceFirst(RegExp(r'\.[^.]+$'), ''); + if (!mounted) return; final walletsRepository = RepositoryProvider.of( context, ); @@ -319,8 +331,11 @@ class _WalletImportByFileState extends State { } bool get _shouldShowCustomSeedToggle { + if (_isHdMode) return false; if (_allowCustomSeed) return true; // keep visible once enabled - if (_showCustomSeedToggle) return true; // show after first failure, even in HD + if (_showCustomSeedToggle) { + return true; // show after first non-HD BIP39 failure + } return false; } } diff --git a/lib/views/wallets_manager/widgets/wallet_import_type_dropdown.dart b/lib/views/wallets_manager/widgets/wallet_import_type_dropdown.dart new file mode 100644 index 0000000000..781dfeccff --- /dev/null +++ b/lib/views/wallets_manager/widgets/wallet_import_type_dropdown.dart @@ -0,0 +1,157 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/wallet.dart'; + +class WalletImportTypeDropdown extends StatelessWidget { + const WalletImportTypeDropdown({ + super.key, + required this.selectedType, + required this.onChanged, + this.isHdOptionEnabled = true, + }); + + final WalletType selectedType; + final ValueChanged onChanged; + final bool isHdOptionEnabled; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.selectWalletType.tr(), + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + key: const Key('wallet-import-type-dropdown'), + initialValue: selectedType, + isExpanded: true, + borderRadius: BorderRadius.circular(12), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + selectedItemBuilder: (context) => [ + _buildSelectedItem(context, WalletType.hdwallet), + _buildSelectedItem(context, WalletType.iguana), + ], + items: [ + DropdownMenuItem( + value: WalletType.hdwallet, + enabled: isHdOptionEnabled, + child: _WalletImportTypeMenuItem( + title: 'walletImportTypeHdLabel'.tr(), + description: 'walletImportTypeHdDescription'.tr(), + icon: Icons.account_tree_outlined, + enabled: isHdOptionEnabled, + ), + ), + DropdownMenuItem( + value: WalletType.iguana, + child: _WalletImportTypeMenuItem( + title: 'walletImportTypeLegacyLabel'.tr(), + description: 'walletImportTypeLegacyDescription'.tr(), + icon: Icons.account_balance_wallet_outlined, + ), + ), + ], + onChanged: (WalletType? value) { + if (value == null) return; + if (value == WalletType.hdwallet && !isHdOptionEnabled) return; + onChanged(value); + }, + ), + if (!isHdOptionEnabled) ...[ + const SizedBox(height: 8), + Text( + 'walletImportTypeHdDisabledHint'.tr(), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + ], + ); + } + + Widget _buildSelectedItem(BuildContext context, WalletType type) { + final bool isHdType = type == WalletType.hdwallet; + return Align( + alignment: Alignment.centerLeft, + child: Text( + isHdType + ? 'walletImportTypeHdLabel'.tr() + : 'walletImportTypeLegacyLabel'.tr(), + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } +} + +class _WalletImportTypeMenuItem extends StatelessWidget { + const _WalletImportTypeMenuItem({ + required this.title, + required this.description, + required this.icon, + this.enabled = true, + }); + + final String title; + final String description; + final IconData icon; + final bool enabled; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final titleColor = enabled + ? theme.textTheme.bodyLarge?.color + : theme.disabledColor; + final descriptionColor = enabled + ? theme.textTheme.bodySmall?.color + : theme.disabledColor; + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, size: 20, color: titleColor), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: titleColor, + ), + ), + const SizedBox(height: 2), + Text( + description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: descriptionColor, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/views/wallets_manager/widgets/wallet_list_item.dart b/lib/views/wallets_manager/widgets/wallet_list_item.dart index 7c8ebb4b6c..649913c50f 100644 --- a/lib/views/wallets_manager/widgets/wallet_list_item.dart +++ b/lib/views/wallets_manager/widgets/wallet_list_item.dart @@ -1,60 +1,136 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/model/wallets_manager_models.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; class WalletListItem extends StatelessWidget { - const WalletListItem({Key? key, required this.wallet, required this.onClick}) - : super(key: key); + const WalletListItem({ + super.key, + required this.wallet, + required this.onClick, + }); final Wallet wallet; final void Function(Wallet, WalletsManagerExistWalletAction) onClick; @override Widget build(BuildContext context) { + final theme = Theme.of(context); return UiPrimaryButton( - backgroundColor: Theme.of(context).cardColor, + backgroundColor: theme.cardColor, text: wallet.name, prefix: DecoratedBox( decoration: const BoxDecoration(shape: BoxShape.circle), child: Icon( Icons.person, size: 21, - color: Theme.of(context).textTheme.labelLarge?.color, + color: theme.textTheme.labelLarge?.color, ), ), - height: 40, - // backgroundColor: Theme.of(context).colorScheme.onSurface, + height: 68, onPressed: () => onClick(wallet, WalletsManagerExistWalletAction.logIn), - child: Row( mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ DecoratedBox( decoration: const BoxDecoration(shape: BoxShape.circle), child: Icon( Icons.person, size: 21, - color: Theme.of(context).textTheme.labelLarge?.color, + color: theme.textTheme.labelLarge?.color, ), ), const SizedBox(width: 8), Expanded( - child: Text( - wallet.name, - // style: DefaultTextStyle.of(context).style.copyWith( - // fontWeight: FontWeight.w500, - // fontSize: 14, - // ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + wallet.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + _MetaTag(label: _walletTypeLabel(wallet.config.type)), + if (wallet.config.provenance != WalletProvenance.unknown) + _MetaTag( + label: _walletProvenanceLabel(wallet.config.provenance), + ), + if (wallet.config.createdAt != null) + _MetaTag( + label: _walletCreatedLabel(wallet.config.createdAt!), + ), + ], + ), + ], ), ), IconButton( onPressed: () => onClick(wallet, WalletsManagerExistWalletAction.delete), - icon: const Icon(Icons.close), + icon: const Icon(Icons.close, size: 18), + visualDensity: VisualDensity.compact, + tooltip: LocaleKeys.delete.tr(), ), ], ), ); } + + String _walletTypeLabel(WalletType type) { + return switch (type) { + WalletType.hdwallet => 'HD', + WalletType.iguana => 'Iguana', + WalletType.trezor => 'Trezor', + WalletType.metamask => 'MetaMask', + WalletType.keplr => 'Keplr', + }; + } + + String _walletProvenanceLabel(WalletProvenance provenance) { + return switch (provenance) { + WalletProvenance.generated => 'Generated', + WalletProvenance.imported => 'Imported', + WalletProvenance.unknown => '', + }; + } + + String _walletCreatedLabel(DateTime createdAt) { + return DateFormat('yyyy-MM-dd').format(createdAt); + } +} + +class _MetaTag extends StatelessWidget { + const _MetaTag({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.4), + ), + color: theme.colorScheme.surface.withValues(alpha: 0.35), + ), + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith(fontSize: 11), + ), + ); + } } diff --git a/lib/views/wallets_manager/widgets/wallet_login.dart b/lib/views/wallets_manager/widgets/wallet_login.dart index 21fbc275b3..d80d596d05 100644 --- a/lib/views/wallets_manager/widgets/wallet_login.dart +++ b/lib/views/wallets_manager/widgets/wallet_login.dart @@ -14,6 +14,7 @@ import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/widgets/password_visibility_control.dart'; import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/hd_wallet_mode_preference.dart'; import 'package:web_dex/shared/widgets/quick_login_switch.dart'; import 'package:web_dex/views/wallets_manager/widgets/hdwallet_mode_switch.dart'; import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; @@ -44,12 +45,16 @@ class _WalletLogInState extends State { late bool _isHdMode; bool _isQuickLoginEnabled = false; KdfUser? _user; + bool? _storedHdPreference; @override void initState() { super.initState(); - _isHdMode = widget.initialHdMode; + _isHdMode = + widget.initialHdMode || + widget.wallet.config.type == WalletType.hdwallet; _isQuickLoginEnabled = widget.initialQuickLogin; + _loadHdModePreference(); unawaited(_fetchKdfUser()); } @@ -61,13 +66,25 @@ class _WalletLogInState extends State { ); if (user != null) { + final fallbackHdMode = + widget.initialHdMode || + user.wallet.config.type == WalletType.hdwallet; setState(() { _user = user; - _isHdMode = user.wallet.config.type == WalletType.hdwallet; + _isHdMode = _storedHdPreference ?? fallbackHdMode; }); } } + Future _loadHdModePreference() async { + final storedPreference = await readHdWalletModePreference(widget.wallet.id); + if (!mounted || storedPreference == null) return; + setState(() { + _storedHdPreference = storedPreference; + _isHdMode = storedPreference; + }); + } + @override void dispose() { _passwordController.dispose(); @@ -147,6 +164,9 @@ class _WalletLogInState extends State { value: _isHdMode, onChanged: (value) { setState(() => _isHdMode = value); + unawaited( + storeHdWalletModePreference(widget.wallet.id, value), + ); }, ), const SizedBox(height: 24), @@ -286,14 +306,18 @@ class _PasswordTextFieldState extends State { // Find common prefix int start = 0; - while (start < before.length && start < after.length && before[start] == after[start]) { + while (start < before.length && + start < after.length && + before[start] == after[start]) { start++; } // Find common suffix int endBefore = before.length - 1; int endAfter = after.length - 1; - while (endBefore >= start && endAfter >= start && before[endBefore] == after[endAfter]) { + while (endBefore >= start && + endAfter >= start && + before[endBefore] == after[endAfter]) { endBefore--; endAfter--; } diff --git a/lib/views/wallets_manager/widgets/wallet_rename_dialog.dart b/lib/views/wallets_manager/widgets/wallet_rename_dialog.dart index 1b326a07f0..3a82a9898f 100644 --- a/lib/views/wallets_manager/widgets/wallet_rename_dialog.dart +++ b/lib/views/wallets_manager/widgets/wallet_rename_dialog.dart @@ -20,6 +20,7 @@ Future walletRenameDialog( final result = await AppDialog.show( context: context, width: isMobile ? null : 360, + barrierDismissible: false, child: _WalletRenameContent( controller: controller, walletsRepository: walletsRepository, diff --git a/lib/views/wallets_manager/widgets/wallet_simple_import.dart b/lib/views/wallets_manager/widgets/wallet_simple_import.dart index 92786dffa5..476ce43e1e 100644 --- a/lib/views/wallets_manager/widgets/wallet_simple_import.dart +++ b/lib/views/wallets_manager/widgets/wallet_simple_import.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -18,7 +20,7 @@ import 'package:web_dex/shared/widgets/password_visibility_control.dart'; import 'package:web_dex/shared/widgets/quick_login_switch.dart'; import 'package:web_dex/views/wallets_manager/widgets/creation_password_fields.dart'; import 'package:web_dex/views/wallets_manager/widgets/custom_seed_checkbox.dart'; -import 'package:web_dex/views/wallets_manager/widgets/hdwallet_mode_switch.dart'; +import 'package:web_dex/views/wallets_manager/widgets/wallet_import_type_dropdown.dart'; import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; class WalletSimpleImport extends StatefulWidget { @@ -49,6 +51,7 @@ class WalletSimpleImport extends StatefulWidget { enum WalletSimpleImportSteps { nameAndSeed, password } class _WalletImportWrapperState extends State { + static const int _maxSeedSuggestions = 8; WalletSimpleImportSteps _step = WalletSimpleImportSteps.nameAndSeed; final TextEditingController _nameController = TextEditingController(text: ''); final TextEditingController _seedController = TextEditingController(text: ''); @@ -60,8 +63,12 @@ class _WalletImportWrapperState extends State { bool _eulaAndTosChecked = false; bool _inProgress = false; bool _allowCustomSeed = false; - bool _isHdMode = false; + bool _isHdMode = true; bool _rememberMe = false; + List _bip39Words = const []; + List _seedWordSuggestions = const []; + int _activeWordStart = -1; + int _activeWordEnd = -1; bool get _isButtonEnabled { final isFormValid = _refreshFormValidationState(); @@ -152,13 +159,108 @@ class _WalletImportWrapperState extends State { void initState() { super.initState(); _seedController.addListener(_onSeedChanged); + unawaited(_loadBip39Wordlist()); } void _onSeedChanged() { - // Rebuild to update custom seed toggle visibility as user types + _updateSeedWordSuggestions(); + _syncWalletTypeWithSeedCompatibility(); setState(() {}); } + Future _loadBip39Wordlist() async { + try { + final wordlist = await rootBundle.loadString( + 'packages/komodo_defi_types/assets/bip-0039/english-wordlist.txt', + ); + final words = wordlist + .split('\n') + .map((word) => word.trim().toLowerCase()) + .where((word) => word.isNotEmpty) + .toList(growable: false); + + if (!mounted) return; + setState(() { + _bip39Words = words; + _updateSeedWordSuggestions(); + }); + } catch (_) { + // Suggestions are a progressive enhancement; import still works without + // the wordlist if asset loading fails. + } + } + + void _clearSeedWordSuggestions() { + _seedWordSuggestions = const []; + _activeWordStart = -1; + _activeWordEnd = -1; + } + + void _updateSeedWordSuggestions() { + if (_allowCustomSeed || _isSeedHidden || _bip39Words.isEmpty) { + _clearSeedWordSuggestions(); + return; + } + + final text = _seedController.text.toLowerCase(); + final cursor = _seedController.selection.baseOffset; + if (cursor < 0 || cursor > text.length) { + _clearSeedWordSuggestions(); + return; + } + + int start = cursor; + while (start > 0 && text[start - 1] != ' ') { + start--; + } + + int end = cursor; + while (end < text.length && text[end] != ' ') { + end++; + } + + final prefix = text.substring(start, cursor).trim(); + if (prefix.isEmpty || !RegExp(r'^[a-z]+$').hasMatch(prefix)) { + _clearSeedWordSuggestions(); + return; + } + + final suggestions = _bip39Words + .where((word) => word.startsWith(prefix)) + .take(_maxSeedSuggestions) + .toList(growable: false); + + if (suggestions.length == 1 && suggestions.first == prefix) { + _clearSeedWordSuggestions(); + return; + } + + _seedWordSuggestions = suggestions; + _activeWordStart = start; + _activeWordEnd = end; + } + + void _onSeedSuggestionSelected(String suggestion) { + if (_activeWordStart < 0 || _activeWordEnd < _activeWordStart) return; + + var nextText = _seedController.text.replaceRange( + _activeWordStart, + _activeWordEnd, + suggestion, + ); + var nextCursor = _activeWordStart + suggestion.length; + + if (nextCursor == nextText.length || nextText[nextCursor] != ' ') { + nextText = nextText.replaceRange(nextCursor, nextCursor, ' '); + nextCursor += 1; + } + + _seedController.value = TextEditingValue( + text: nextText, + selection: TextSelection.collapsed(offset: nextCursor), + ); + } + @override void dispose() { _nameController.dispose(); @@ -173,6 +275,7 @@ class _WalletImportWrapperState extends State { onChanged: (value) { setState(() { _allowCustomSeed = value; + _updateSeedWordSuggestions(); }); _refreshFormValidationState(); @@ -252,15 +355,34 @@ class _WalletImportWrapperState extends State { _buildNameField(), const SizedBox(height: 16), _buildSeedField(), + if (_seedWordSuggestions.isNotEmpty) ...[ + const SizedBox(height: 10), + Align( + alignment: Alignment.centerLeft, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: _seedWordSuggestions + .map( + (word) => ActionChip( + label: Text(word), + onPressed: () => _onSeedSuggestionSelected(word), + ), + ) + .toList(), + ), + ), + ], const SizedBox(height: 16), - HDWalletModeSwitch( - value: _isHdMode, - onChanged: (value) { + WalletImportTypeDropdown( + selectedType: _isHdMode ? WalletType.hdwallet : WalletType.iguana, + isHdOptionEnabled: _isHdCompatibleWithCurrentSeed, + onChanged: (walletType) { setState(() { - _isHdMode = value; + _isHdMode = walletType == WalletType.hdwallet; _allowCustomSeed = false; + _updateSeedWordSuggestions(); }); - _refreshFormValidationState(); }, ), @@ -317,6 +439,7 @@ class _WalletImportWrapperState extends State { onVisibilityChange: (bool isObscured) { setState(() { _isSeedHidden = isObscured; + _updateSeedWordSuggestions(); }); }, ), @@ -427,9 +550,7 @@ class _WalletImportWrapperState extends State { MnemonicFailedReason.invalidChecksum => LocaleKeys.mnemonicInvalidChecksumError.tr(), MnemonicFailedReason.invalidLength => - // TODO: Specify the valid lengths since not all lengths between 12 and - // 24 are valid - LocaleKeys.mnemonicInvalidLengthError.tr(args: ['12', '24']), + LocaleKeys.mnemonicInvalidLengthError.tr(), }; } @@ -444,4 +565,32 @@ class _WalletImportWrapperState extends State { final isBip39 = validator.validateBip39(seed); return !isBip39; } + + void _syncWalletTypeWithSeedCompatibility() { + if (_isHdMode && !_isHdCompatibleWithCurrentSeed) { + _isHdMode = false; + _allowCustomSeed = false; + } + } + + bool get _isHdCompatibleWithCurrentSeed { + final seed = _seedController.text.trim().toLowerCase(); + if (seed.isEmpty) return true; + + final words = seed.split(RegExp(r'\s+')).where((word) => word.isNotEmpty); + final int wordCount = words.length; + if (wordCount == 1) { + final token = words.first; + final bool looksLikeBip39Word = + RegExp(r'^[a-z]+$').hasMatch(token) && token.length <= 8; + return looksLikeBip39Word; + } + + if (wordCount < 12) { + return true; + } + + final validator = context.read().mnemonicValidator; + return validator.validateBip39(seed); + } } diff --git a/lib/views/wallets_manager/widgets/wallets_manager.dart b/lib/views/wallets_manager/widgets/wallets_manager.dart index 4f2b9d2188..596e630b6a 100644 --- a/lib/views/wallets_manager/widgets/wallets_manager.dart +++ b/lib/views/wallets_manager/widgets/wallets_manager.dart @@ -6,7 +6,7 @@ import 'package:web_dex/views/wallets_manager/widgets/iguana_wallets_manager.dar class WalletsManager extends StatelessWidget { const WalletsManager({ - Key? key, + super.key, required this.eventType, required this.walletType, required this.close, @@ -14,7 +14,7 @@ class WalletsManager extends StatelessWidget { this.selectedWallet, this.initialHdMode = false, this.rememberMe = false, - }) : super(key: key); + }); final WalletsManagerEventType eventType; final WalletType walletType; final VoidCallback close; @@ -38,7 +38,11 @@ class WalletsManager extends StatelessWidget { ); case WalletType.trezor: - return HardwareWalletsManager(close: close, eventType: eventType); + return HardwareWalletsManager( + close: close, + onSuccess: onSuccess, + eventType: eventType, + ); case WalletType.keplr: case WalletType.metamask: return const SizedBox(); diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index d69f25017b..cdb4d82fa4 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -1,5 +1,5 @@ # Project-level configuration. -cmake_minimum_required(VERSION 3.10) +cmake_minimum_required(VERSION 3.13) project(runner LANGUAGES CXX) # The name of the executable created for the application. Change this to change @@ -128,6 +128,12 @@ foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) COMPONENT Runtime) endforeach(bundled_library) +# Copy the native assets provided by build hooks. +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/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index acf5262322..d9c61ba08d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -15,7 +15,6 @@ import flutter_window_close import local_auth_darwin import mobile_scanner import package_info_plus -import path_provider_foundation import share_plus import shared_preferences_foundation import url_launcher_macos @@ -33,7 +32,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 50dd16991f..0e481369a3 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -209,34 +209,34 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_size/macos SPEC CHECKSUMS: - device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215 - file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af + device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e - firebase_analytics: c6a3f80cf2e8681b4e6b3402162acf116c4d3a57 - firebase_core: 2af692f4818474ed52eda1ba6aeb448a6a3352af + firebase_analytics: 3091f96bd17636f6da5092a4701ffacf67c6e455 + firebase_core: 7667f880631ae8ad10e3d6567ab7582fe0682326 FirebaseAnalytics: 6433dfd311ba78084fc93bdfc145e8cb75740eae FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4 FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843 - flutter_inappwebview_macos: bdf207b8f4ebd58e86ae06cd96b147de99a67c9b - flutter_secure_storage_darwin: 12d2375c690785d97a4e586f15f11be5ae35d5b0 - flutter_window_close: a0f4f388e956ebafa866e3e171ce837c166eab9c + flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d + flutter_secure_storage_darwin: ce237a8775b39723566dc72571190a3769d70468 + flutter_window_close: bd408414cbbf0d39f0d3076c4da0cdbf1c527168 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 GoogleAppMeasurement: 700dce7541804bec33db590a5c496b663fbe2539 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - komodo_defi_framework: bca6c793d77d5210744e5bdf3780cc3c37f4e578 - local_auth_darwin: fa4b06454df7df8e97c18d7ee55151c57e7af0de - mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e + komodo_defi_framework: 2e2b89505f158840822ed30ffc7589ff8035e248 + local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19 + mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 - package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - share_plus: 1fa619de8392a4398bfaf176d441853922614e89 - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 - url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 - video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3 - window_size: 339dafa0b27a95a62a843042038fa6c3c48de195 + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b + window_size: 4bd15034e6e3d0720fd77928a7c42e5492cfece9 PODFILE CHECKSUM: a890bc27443c296eb8ca4510f54c35d2e0f66ed0 diff --git a/packages/komodo_persistence_layer/lib/src/hive/box.dart b/packages/komodo_persistence_layer/lib/src/hive/box.dart index 7d93351d53..2f042f593b 100644 --- a/packages/komodo_persistence_layer/lib/src/hive/box.dart +++ b/packages/komodo_persistence_layer/lib/src/hive/box.dart @@ -1,4 +1,4 @@ -import 'package:hive/hive.dart'; +import 'package:hive_ce/hive.dart'; import '../persistence_provider.dart'; @@ -10,22 +10,15 @@ import '../persistence_provider.dart'; /// implement the [ObjectWithPrimaryKey] interface. class HiveBoxProvider> extends PersistenceProvider { - HiveBoxProvider({ - required this.name, - }); + HiveBoxProvider({required this.name}); - HiveBoxProvider.init({ - required this.name, - required Box box, - }) : _box = box; + HiveBoxProvider.init({required this.name, required Box box}) : _box = box; final String name; Box? _box; static Future> - create>({ - required String name, - }) async { + create>({required String name}) async { final Box box = await Hive.openBox(name); return HiveBoxProvider.init(name: name, box: box); } diff --git a/packages/komodo_persistence_layer/lib/src/hive/lazy_box.dart b/packages/komodo_persistence_layer/lib/src/hive/lazy_box.dart index 03df5faace..c1281305e9 100644 --- a/packages/komodo_persistence_layer/lib/src/hive/lazy_box.dart +++ b/packages/komodo_persistence_layer/lib/src/hive/lazy_box.dart @@ -1,4 +1,4 @@ -import 'package:hive/hive.dart'; +import 'package:hive_ce/hive.dart'; import '../persistence_provider.dart'; @@ -10,22 +10,16 @@ import '../persistence_provider.dart'; /// implement the [ObjectWithPrimaryKey] interface. class HiveLazyBoxProvider> extends PersistenceProvider { - HiveLazyBoxProvider({ - required this.name, - }); + HiveLazyBoxProvider({required this.name}); - HiveLazyBoxProvider.init({ - required this.name, - required LazyBox box, - }) : _box = box; + HiveLazyBoxProvider.init({required this.name, required LazyBox box}) + : _box = box; final String name; LazyBox? _box; static Future> - create>({ - required String name, - }) async { + create>({required String name}) async { final LazyBox box = await Hive.openLazyBox(name); return HiveLazyBoxProvider.init(name: name, box: box); } @@ -52,8 +46,9 @@ class HiveLazyBoxProvider> Future> getAll() async { _box ??= await Hive.openLazyBox(name); - final Iterable> valueFutures = - _box!.keys.map((dynamic key) => _box!.get(key as K)); + final Iterable> valueFutures = _box!.keys.map( + (dynamic key) => _box!.get(key as K), + ); final List result = await Future.wait(valueFutures); return result; } diff --git a/packages/komodo_persistence_layer/lib/src/persisted_types.dart b/packages/komodo_persistence_layer/lib/src/persisted_types.dart index 4bb0d90e9c..6d5d1e984a 100644 --- a/packages/komodo_persistence_layer/lib/src/persisted_types.dart +++ b/packages/komodo_persistence_layer/lib/src/persisted_types.dart @@ -1,4 +1,4 @@ -import 'package:hive/hive.dart'; +import 'package:hive_ce/hive.dart'; import 'persistence_provider.dart'; @@ -25,10 +25,7 @@ class PersistedStringAdapter extends TypeAdapter { final Map fields = { for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), }; - return PersistedString( - fields[0] as String, - fields[1] as String, - ); + return PersistedString(fields[0] as String, fields[1] as String); } @override diff --git a/packages/komodo_persistence_layer/pubspec.yaml b/packages/komodo_persistence_layer/pubspec.yaml index 103e5e5440..f3ce226d91 100644 --- a/packages/komodo_persistence_layer/pubspec.yaml +++ b/packages/komodo_persistence_layer/pubspec.yaml @@ -11,8 +11,8 @@ environment: # Add regular dependencies here. dependencies: # Approved via https://github.com/GLEECBTC/gleec-wallet/pull/1106 - hive: ^2.2.3 # Changed from git to pub.dev because git dependencies are not allowed in published packages + hive_ce: ^2.19.3 # Community-maintained Hive package with WASM support dev_dependencies: lints: ^5.1.1 - test: ^1.24.0 + test: ^1.30.0 diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_dropdown.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_dropdown.dart index ea733565e5..58b166f73a 100644 --- a/packages/komodo_ui_kit/lib/src/buttons/ui_dropdown.dart +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_dropdown.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; class UiDropdown extends StatefulWidget { @@ -43,7 +44,7 @@ class _UiDropdownState extends State with WidgetsBindingObserver { _switcherOffset = renderObject.localToGlobal(Offset.zero); } _tooltipWrapper = _buildTooltipWrapper(); - + if (widget.isOpen) _open(); }); @@ -107,21 +108,25 @@ class _UiDropdownState extends State with WidgetsBindingObserver { bottom: 0, child: Material( color: Colors.transparent, - child: InkWell( - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - hoverColor: Colors.transparent, - onTap: () => _switch(), + child: Listener( + onPointerSignal: (event) { + if (event is PointerScrollEvent) { + _close(); + } + }, + child: InkWell( + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + hoverColor: Colors.transparent, + onTap: () => _switch(), + ), ), ), ), Positioned( top: (_top ?? 0) + 10, right: _right, - child: Material( - color: Colors.transparent, - child: widget.dropdown, - ), + child: Material(color: Colors.transparent, child: widget.dropdown), ), ], ), diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_primary_button.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_primary_button.dart index 6da824ac8d..741a39937f 100644 --- a/packages/komodo_ui_kit/lib/src/buttons/ui_primary_button.dart +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_primary_button.dart @@ -68,11 +68,9 @@ class _UiPrimaryButtonState extends State { ), child: DefaultTextStyle( style: _defaultTextStyle, - child: widget.child ?? - _ButtonContent( - text: widget.text, - prefix: widget.prefix, - ), + child: + widget.child ?? + _ButtonContent(text: widget.text, prefix: widget.prefix), ), ), ); @@ -100,11 +98,12 @@ class _UiPrimaryButtonState extends State { /// button's background brightness. If a custom textStyle is provided but /// lacks a color, the computed foreground color is applied. TextStyle get _defaultTextStyle { - final baseStyle = Theme.of(context).textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.bold, - fontSize: 14, - color: _foregroundColor, - ) ?? + final baseStyle = + Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + fontSize: 14, + color: _foregroundColor, + ) ?? TextStyle( fontWeight: FontWeight.bold, fontSize: 14, @@ -120,16 +119,12 @@ class _UiPrimaryButtonState extends State { } OutlinedBorder get _shape => RoundedRectangleBorder( - borderRadius: - BorderRadius.all(Radius.circular(widget.borderRadius ?? 18)), - ); + borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius ?? 18)), + ); } class _ButtonContent extends StatelessWidget { - const _ButtonContent({ - required this.text, - required this.prefix, - }); + const _ButtonContent({required this.text, required this.prefix}); final String text; final Widget? prefix; @@ -140,8 +135,14 @@ class _ButtonContent extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ if (prefix != null) prefix!, - // Text style is inherited from DefaultTextStyle set by the parent - Text(text), + Flexible( + child: Text( + text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), ], ); } diff --git a/packages/komodo_ui_kit/lib/src/controls/selected_coin_graph_control.dart b/packages/komodo_ui_kit/lib/src/controls/selected_coin_graph_control.dart index 62f3abe86d..112808e05a 100644 --- a/packages/komodo_ui_kit/lib/src/controls/selected_coin_graph_control.dart +++ b/packages/komodo_ui_kit/lib/src/controls/selected_coin_graph_control.dart @@ -34,9 +34,7 @@ class SelectedCoinGraphControl extends StatelessWidget { // assert(onCoinSelected != null || emptySelectAllowed); // If onCoinSelected is non-null, then availableCoins must be non-null - assert( - onCoinSelected == null || availableCoins != null, - ); + assert(onCoinSelected == null || availableCoins != null); return LayoutBuilder( builder: (context, constraints) { final isWideScreen = constraints.maxWidth > 600; @@ -44,6 +42,19 @@ class SelectedCoinGraphControl extends StatelessWidget { final themeCustom = Theme.of(context).brightness == Brightness.dark ? Theme.of(context).extension()! : Theme.of(context).extension()!; + final bool isCentreAmountValid = centreAmount.isFinite; + final double safeCentreAmount = isCentreAmountValid + ? centreAmount + : 0.0; + final double safePercentage = percentageIncrease.isFinite + ? percentageIncrease + : 0.0; + final String centreAmountText = isCentreAmountValid + ? (NumberFormat.currency(symbol: "\$") + ..minimumSignificantDigits = 3 + ..minimumFractionDigits = 2) + .format(safeCentreAmount) + : '--'; return ClipRect( child: Container( @@ -95,25 +106,24 @@ class SelectedCoinGraphControl extends StatelessWidget { ], ], ) - : Text('All', - style: Theme.of(context).textTheme.bodyLarge), + : Text( + 'All', + style: Theme.of(context).textTheme.bodyLarge, + ), ), if (isWideScreen) Text( - (NumberFormat.currency(symbol: "\$") - ..minimumSignificantDigits = 3 - ..minimumFractionDigits = 2) - .format(centreAmount), + centreAmountText, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - // TODO: Incorporate into theme and remove duplication accross charts - fontWeight: FontWeight.w600, - ), + // TODO: Incorporate into theme and remove duplication accross charts + fontWeight: FontWeight.w600, + ), ), Row( mainAxisSize: MainAxisSize.min, children: [ TrendPercentageText( - percentage: percentageIncrease, + percentage: safePercentage, upColor: themeCustom.increaseColor, downColor: themeCustom.decreaseColor, ), diff --git a/packages/komodo_ui_kit/lib/src/display/statistic_card.dart b/packages/komodo_ui_kit/lib/src/display/statistic_card.dart index 0557c13ba8..9bc0cc0ec2 100644 --- a/packages/komodo_ui_kit/lib/src/display/statistic_card.dart +++ b/packages/komodo_ui_kit/lib/src/display/statistic_card.dart @@ -9,6 +9,7 @@ class StatisticCard extends StatelessWidget { // The value of the stat used for the title. If null, shows a skeleton placeholder final double? value; + final String? valueText; // The formatter used to format the value for the title final NumberFormat _valueFormatter; @@ -26,6 +27,7 @@ class StatisticCard extends StatelessWidget { StatisticCard({ super.key, this.value, + this.valueText, required this.caption, this.trendWidget, this.actionWidget, @@ -50,8 +52,6 @@ class StatisticCard extends StatelessWidget { @override Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - return Container( decoration: BoxDecoration( gradient: containerGradient(Theme.of(context)), @@ -76,12 +76,9 @@ class StatisticCard extends StatelessWidget { // Value or skeleton placeholder value != null ? AutoScrollText( - text: _valueFormatter.format(value!), + text: valueText ?? _valueFormatter.format(value!), style: Theme.of(context).textTheme.titleLarge - ?.copyWith( - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : null, - ), + ?.copyWith(fontWeight: FontWeight.w600), ) : _ValuePlaceholder(), const SizedBox(height: 4), diff --git a/packages/komodo_ui_kit/lib/src/dividers/ui_scrollbar.dart b/packages/komodo_ui_kit/lib/src/dividers/ui_scrollbar.dart index 6550a8bbfa..2beaae0e33 100644 --- a/packages/komodo_ui_kit/lib/src/dividers/ui_scrollbar.dart +++ b/packages/komodo_ui_kit/lib/src/dividers/ui_scrollbar.dart @@ -6,11 +6,11 @@ class DexScrollbar extends StatefulWidget { final ScrollController scrollController; const DexScrollbar({ - Key? key, + super.key, required this.child, required this.scrollController, this.isMobile = false, - }) : super(key: key); + }); @override DexScrollbarState createState() => DexScrollbarState(); @@ -22,25 +22,61 @@ class DexScrollbarState extends State { @override void initState() { super.initState(); - widget.scrollController.addListener(_checkScrollbarVisibility); + _attachControllerListener(widget.scrollController); + _scheduleVisibilityCheck(); + } + + @override + void didUpdateWidget(covariant DexScrollbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.scrollController == widget.scrollController) { + return; + } + + _detachControllerListener(oldWidget.scrollController); + _attachControllerListener(widget.scrollController); + _scheduleVisibilityCheck(); } void _checkScrollbarVisibility() { - if (!mounted) return; + if (!mounted || !widget.scrollController.hasClients) return; final maxScroll = widget.scrollController.position.maxScrollExtent; - final newVisibility = maxScroll > 0; + _updateScrollbarVisibility(maxScroll > 0); + } - if (isScrollbarVisible != newVisibility) { - setState(() { - isScrollbarVisible = newVisibility; - }); + void _updateScrollbarVisibility(bool visible) { + if (isScrollbarVisible == visible) { + return; } + + setState(() { + isScrollbarVisible = visible; + }); + } + + bool _onScrollMetricsChanged(ScrollMetricsNotification notification) { + _updateScrollbarVisibility(notification.metrics.maxScrollExtent > 0); + return false; + } + + void _attachControllerListener(ScrollController controller) { + controller.addListener(_checkScrollbarVisibility); + } + + void _detachControllerListener(ScrollController controller) { + controller.removeListener(_checkScrollbarVisibility); + } + + void _scheduleVisibilityCheck() { + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkScrollbarVisibility(); + }); } @override void dispose() { - widget.scrollController.removeListener(_checkScrollbarVisibility); + _detachControllerListener(widget.scrollController); super.dispose(); } @@ -48,28 +84,23 @@ class DexScrollbarState extends State { Widget build(BuildContext context) { if (widget.isMobile) return widget.child; - return LayoutBuilder( - builder: (context, constraints) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _checkScrollbarVisibility(); - }); - - return isScrollbarVisible - ? Scrollbar( - thumbVisibility: true, - thickness: 5, - controller: widget.scrollController, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith(scrollbars: false), - child: Padding( - padding: const EdgeInsets.only(right: 10), - child: widget.child, - ), - ), - ) - : widget.child; - }, + final double rightPadding = isScrollbarVisible ? 10 : 0; + + return Scrollbar( + thumbVisibility: isScrollbarVisible, + trackVisibility: isScrollbarVisible, + thickness: 5, + controller: widget.scrollController, + child: NotificationListener( + onNotification: _onScrollMetricsChanged, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: Padding( + padding: EdgeInsets.only(right: rightPadding), + child: widget.child, + ), + ), + ), ); } } diff --git a/packages/komodo_ui_kit/pubspec.yaml b/packages/komodo_ui_kit/pubspec.yaml index 7fbb077e31..58d4a2950c 100644 --- a/packages/komodo_ui_kit/pubspec.yaml +++ b/packages/komodo_ui_kit/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: none environment: sdk: ">=3.8.1 <4.0.0" - flutter: ">=3.35.3 <4.0.0" + flutter: ">=3.41.4 <4.0.0" resolution: workspace @@ -31,6 +31,8 @@ dependencies: # ^0.3.0+3 # Option 3: Pub.dev dependency dev_dependencies: + flutter_test: + sdk: flutter flutter_lints: ^5.0.0 # flutter.dev flutter: diff --git a/packages/komodo_ui_kit/test/ui_scrollbar_focus_test.dart b/packages/komodo_ui_kit/test/ui_scrollbar_focus_test.dart new file mode 100644 index 0000000000..37e01fe9d0 --- /dev/null +++ b/packages/komodo_ui_kit/test/ui_scrollbar_focus_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +void main() { + testWidgets('keeps text field focus when scrollability changes', ( + tester, + ) async { + final focusNode = FocusNode(); + final hostKey = GlobalKey<_DexScrollbarFocusHostState>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: _DexScrollbarFocusHost( + key: hostKey, + searchFocusNode: focusNode, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byKey(const Key('search-field'))); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + + hostKey.currentState!.setScrollable(false); + await tester.pump(); + await tester.pump(); + expect(focusNode.hasFocus, isTrue); + + hostKey.currentState!.setScrollable(true); + await tester.pump(); + await tester.pump(); + expect(focusNode.hasFocus, isTrue); + + focusNode.dispose(); + }); +} + +class _DexScrollbarFocusHost extends StatefulWidget { + const _DexScrollbarFocusHost({required this.searchFocusNode, super.key}); + + final FocusNode searchFocusNode; + + @override + State<_DexScrollbarFocusHost> createState() => _DexScrollbarFocusHostState(); +} + +class _DexScrollbarFocusHostState extends State<_DexScrollbarFocusHost> { + static const int _scrollableItemCount = 50; + static const int _notScrollableItemCount = 1; + + final ScrollController _scrollController = ScrollController(); + bool _isScrollable = true; + + void setScrollable(bool value) { + if (_isScrollable == value) { + return; + } + setState(() { + _isScrollable = value; + }); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: SizedBox( + width: 420, + height: 260, + child: DexScrollbar( + isMobile: false, + scrollController: _scrollController, + child: Column( + children: [ + const SizedBox(height: 8), + TextFormField( + key: const Key('search-field'), + focusNode: widget.searchFocusNode, + decoration: const InputDecoration(hintText: 'Search'), + ), + const SizedBox(height: 8), + Expanded( + child: ListView.builder( + controller: _scrollController, + itemCount: _isScrollable + ? _scrollableItemCount + : _notScrollableItemCount, + itemExtent: 36, + itemBuilder: (context, index) { + return Text('Item $index'); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index e3a079fec9..2517e213a6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + sha256: f7bac1065b51df46b2291296e1c1b3616a47aeb735aea46a8ca3dcb7bb700ee7 url: "https://pub.dev" source: hosted - version: "85.0.0" + version: "86.0.0" _flutterfire_internals: dependency: transitive description: @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: analyzer - sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + sha256: "4001e2de7c9d125af9504b4c4f64ebba507c9cb9c712caf02ac1d4c37824f58c" url: "https://pub.dev" source: hosted - version: "7.7.1" + version: "8.0.0" args: dependency: "direct main" description: @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: bloc - sha256: "52c10575f4445c61dd9e0cafcc6356fdd827c4c64dd7945ef3c4105f6b6ac189" + sha256: a48653a82055a900b88cd35f92429f068c5a8057ae9b136d197b3d56c57efb81 url: "https://pub.dev" source: hosted - version: "9.0.0" + version: "9.2.0" bloc_concurrency: dependency: "direct main" description: @@ -93,18 +93,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a - url: "https://pub.dev" - source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -129,6 +121,14 @@ 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" collection: dependency: transitive description: @@ -189,10 +189,10 @@ 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: @@ -205,10 +205,10 @@ packages: dependency: transitive description: name: device_info_plus - sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" url: "https://pub.dev" source: hosted - version: "11.5.0" + version: "12.3.0" device_info_plus_platform_interface: dependency: transitive description: @@ -223,14 +223,14 @@ packages: path: "sdk/packages/dragon_charts_flutter" relative: true source: path - version: "0.1.1-dev.3" + version: "0.1.1-dev.4" dragon_logs: dependency: "direct main" description: path: "sdk/packages/dragon_logs" relative: true source: path - version: "2.0.0" + version: "2.0.1" easy_localization: dependency: "direct main" description: @@ -283,10 +283,10 @@ packages: dependency: transitive 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: @@ -299,10 +299,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: e7e16c9d15c36330b94ca0e2ad8cb61f93cd5282d0158c09805aed13b5452f22 + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" url: "https://pub.dev" source: hosted - version: "10.3.2" + version: "10.3.10" firebase_analytics: dependency: "direct main" description: @@ -339,10 +339,10 @@ packages: dependency: transitive description: name: firebase_core_platform_interface - sha256: "5dbc900677dcbe5873d22ad7fbd64b047750124f1f9b7ebe2a33b9ddccc838eb" + sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.0.2" firebase_core_web: dependency: transitive description: @@ -397,10 +397,10 @@ packages: dependency: transitive description: name: flutter_inappwebview_internal_annotations - sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd" + sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" flutter_inappwebview_ios: dependency: transitive description: @@ -466,34 +466,34 @@ packages: 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_secure_storage: dependency: transitive description: name: flutter_secure_storage - sha256: f7eceb0bc6f4fd0441e29d43cab9ac2a1c5ffd7ea7b64075136b718c46954874 + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 url: "https://pub.dev" source: hosted - version: "10.0.0-beta.4" + version: "10.0.0" flutter_secure_storage_darwin: dependency: transitive description: name: flutter_secure_storage_darwin - sha256: f226f2a572bed96bc6542198ebaec227150786e34311d455a7e2d3d06d951845 + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.2.0" flutter_secure_storage_linux: dependency: transitive description: name: flutter_secure_storage_linux - sha256: "9b4b73127e857cd3117d43a70fa3dddadb6e0b253be62e6a6ab85caa0742182c" + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.0" flutter_secure_storage_platform_interface: dependency: transitive description: @@ -506,18 +506,18 @@ packages: dependency: transitive description: name: flutter_secure_storage_web - sha256: "4c3f233e739545c6cb09286eeec1cc4744138372b985113acc904f7263bef517" + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: ff32af20f70a8d0e59b2938fc92de35b54a74671041c814275afd80e27df9f21 + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.1.0" flutter_slidable: dependency: "direct main" description: @@ -585,10 +585,10 @@ packages: dependency: "direct main" description: name: get_it - sha256: a4292e7cf67193f8e7c1258203104eb2a51ec8b3a04baa14695f4064c144297b + sha256: ae78de7c3f2304b8d81f2bb6e320833e5e81de942188542328f074978cc0efa9 url: "https://pub.dev" source: hosted - version: "8.2.0" + version: "8.3.0" glob: dependency: transitive description: @@ -597,38 +597,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" - hive: - dependency: "direct main" - description: - name: hive - sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" - url: "https://pub.dev" - source: hosted - version: "2.2.3" hive_ce: - dependency: transitive + dependency: "direct main" description: name: hive_ce - sha256: "708bb39050998707c5d422752159f91944d3c81ab42d80e1bd0ee37d8e130658" + sha256: "8e9980e68643afb1e765d3af32b47996552a64e190d03faf622cea07c1294418" url: "https://pub.dev" source: hosted - version: "2.11.3" + version: "2.19.3" hive_ce_flutter: - dependency: transitive + dependency: "direct main" description: name: hive_ce_flutter - sha256: f5bd57fda84402bca7557fedb8c629c96c8ea10fab4a542968d7b60864ca02cc + sha256: "2677e95a333ff15af43ccd06af7eb7abbf1a4f154ea071997f3de4346cae913a" url: "https://pub.dev" source: hosted - version: "2.3.2" - hive_flutter: - dependency: "direct main" + version: "2.3.4" + hooks: + dependency: transitive description: - name: hive_flutter - sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.0.2" html: dependency: transitive description: @@ -686,12 +678,12 @@ 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: "direct main" + dependency: transitive description: name: js sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" @@ -702,88 +694,88 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.11.0" komodo_cex_market_data: dependency: "direct main" description: path: "sdk/packages/komodo_cex_market_data" relative: true source: path - version: "0.0.3+1" + version: "0.1.0" komodo_coin_updates: dependency: "direct overridden" description: path: "sdk/packages/komodo_coin_updates" relative: true source: path - version: "1.1.1" + version: "2.0.0" komodo_coins: dependency: "direct overridden" description: path: "sdk/packages/komodo_coins" relative: true source: path - version: "0.3.1+2" + version: "0.3.2" komodo_defi_framework: dependency: "direct overridden" description: path: "sdk/packages/komodo_defi_framework" relative: true source: path - version: "0.3.1+2" + version: "0.4.0" komodo_defi_local_auth: dependency: "direct overridden" description: path: "sdk/packages/komodo_defi_local_auth" relative: true source: path - version: "0.3.1+2" + version: "0.4.0" komodo_defi_rpc_methods: dependency: "direct overridden" description: path: "sdk/packages/komodo_defi_rpc_methods" relative: true source: path - version: "0.3.1+1" + version: "0.4.0" komodo_defi_sdk: dependency: "direct main" description: path: "sdk/packages/komodo_defi_sdk" relative: true source: path - version: "0.4.0+3" + version: "0.5.0" komodo_defi_types: dependency: "direct main" description: path: "sdk/packages/komodo_defi_types" relative: true source: path - version: "0.3.2+1" + version: "0.4.0" komodo_ui: dependency: "direct main" description: path: "sdk/packages/komodo_ui" relative: true source: path - version: "0.3.0+3" + version: "0.3.1" komodo_wallet_build_transformer: dependency: "direct overridden" description: path: "sdk/packages/komodo_wallet_build_transformer" relative: true source: path - version: "0.4.0" + version: "0.4.1" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "11.0.1" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: @@ -820,26 +812,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: @@ -860,42 +852,42 @@ packages: dependency: transitive description: name: markdown - sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 url: "https://pub.dev" source: hosted - version: "7.3.0" + version: "7.3.1" 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" matomo_tracker: dependency: "direct main" description: name: matomo_tracker - sha256: e5f179b3660193d62b7494abd2179c8dfcab8e66ffa76eeefe3b6f88fb3d8291 + sha256: cdf3bd31f50c89a313181a8f671074870f8ecc129ab4590cf016a2cb0e3c511a url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -908,10 +900,10 @@ packages: dependency: transitive description: name: mobile_scanner - sha256: "54005bdea7052d792d35b4fef0f84ec5ddc3a844b250ecd48dc192fb9b4ebc95" + sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.2.0" mutex: dependency: "direct main" description: @@ -920,6 +912,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" nested: dependency: transitive description: @@ -944,6 +944,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" package_config: dependency: transitive description: @@ -996,18 +1004,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: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -1036,10 +1044,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -1068,10 +1076,10 @@ packages: dependency: transitive description: name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.2" process: dependency: transitive description: @@ -1148,26 +1156,26 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.4" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74 + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" url: "https://pub.dev" source: hosted - version: "2.4.12" + version: "2.4.21" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -1257,10 +1265,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" sprintf: dependency: transitive description: @@ -1321,26 +1329,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.26.2" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.11" + version: "0.6.16" typed_data: dependency: transitive description: @@ -1349,22 +1357,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - universal_html: - dependency: "direct main" - description: - name: universal_html - sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971" - url: "https://pub.dev" - source: hosted - version: "2.2.4" - universal_io: - dependency: transitive - description: - name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" - url: "https://pub.dev" - source: hosted - version: "2.2.2" url_launcher: dependency: "direct main" description: @@ -1377,34 +1369,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "69ee86740f2847b9a4ba6cffa74ed12ce500bbe2b07f3dc1e643439da60637b7" + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" url: "https://pub.dev" source: hosted - version: "6.3.18" + 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: @@ -1417,18 +1409,18 @@ 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: @@ -1441,10 +1433,10 @@ packages: 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: @@ -1457,10 +1449,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: @@ -1473,34 +1465,34 @@ packages: dependency: "direct main" description: name: video_player - sha256: "0d55b1f1a31e5ad4c4967bfaa8ade0240b07d20ee4af1dfef5f531056512961a" + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" url: "https://pub.dev" source: hosted - version: "2.10.0" + version: "2.11.1" video_player_android: dependency: transitive description: name: video_player_android - sha256: "59e5a457ddcc1688f39e9aef0efb62aa845cf0cbbac47e44ac9730dc079a2385" + sha256: "9862c67c4661c98f30fe707bc1a4f97d6a0faa76784f485d282668e4651a7ac3" url: "https://pub.dev" source: hosted - version: "2.8.13" + version: "2.9.4" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: f9a780aac57802b2892f93787e5ea53b5f43cc57dc107bee9436458365be71cd + sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e url: "https://pub.dev" source: hosted - version: "2.8.4" + version: "2.9.4" video_player_platform_interface: dependency: transitive description: name: video_player_platform_interface - sha256: cf2a1d29a284db648fd66cbd18aacc157f9862d77d2cc790f6f9678a46c1db5a + sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" url: "https://pub.dev" source: hosted - version: "6.4.0" + version: "6.6.0" video_player_web: dependency: transitive description: @@ -1521,12 +1513,12 @@ 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: transitive + dependency: "direct main" description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" @@ -1569,10 +1561,10 @@ packages: dependency: transitive 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: @@ -1615,5 +1607,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.1 <4.0.0" - flutter: ">=3.35.3" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.41.4" diff --git a/pubspec.yaml b/pubspec.yaml index d29eb4636a..f963e894a4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,11 +15,11 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.9.3+1 +version: 0.9.4+0 environment: sdk: ">=3.8.1 <4.0.0" - flutter: ">=3.35.3 <4.0.0" + flutter: ">=3.41.4 <4.0.0" workspace: - packages/komodo_ui_kit @@ -64,16 +64,16 @@ dependencies: # ref: dev # ^2.0.0 # Option 3: Pub.dev dependency - ## ---- Dart.dev, Flutter.dev + ## ---- Dart.dev, Flutter.dev (Official Packages) args: ^2.7.0 # dart.dev - flutter_markdown: ^0.7.7 # flutter.dev + flutter_markdown: ^0.7.7+1 # flutter.dev http: 1.4.0 # dart.dev intl: 0.20.2 # dart.dev - js: ">=0.6.7 <=0.7.2" # dart.dev + web: ^1.1.1 # dart.dev url_launcher: 6.3.1 # flutter.dev crypto: 3.0.6 # dart.dev cross_file: 0.3.4+2 # flutter.dev - video_player: ^2.9.5 # flutter.dev + video_player: ^2.11.1 # flutter.dev logging: 1.3.0 mutex: ^3.1.0 @@ -93,8 +93,8 @@ dependencies: # Upgraded Firebase, needs secure code review - firebase_analytics: ^11.4.5 - firebase_core: ^3.13.0 + firebase_analytics: ^11.6.0 + firebase_core: ^3.15.2 ## ---- Fluttercommunity.dev @@ -120,13 +120,10 @@ dependencies: easy_localization: 3.0.7+1 # last reviewed 3.0.2 via https://github.com/GLEECBTC/gleec-wallet/pull/1106 # Approved via https://github.com/GLEECBTC/gleec-wallet/pull/1106 (Outdated) - universal_html: 2.2.4 + hive_ce: ^2.19.3 # Community-maintained Hive package with WASM support - # Approved via https://github.com/GLEECBTC/gleec-wallet/pull/1106 - hive: ^2.2.3 # Changed from git to pub.dev because git dependencies are not allowed in published packages - - # Approved via https://github.com/GLEECBTC/gleec-wallet/pull/1106 - hive_flutter: ^1.1.0 # Changed from git to pub.dev because git dependencies are not allowed in published packages + # Approved via https://github.com/GLEECBTC/gleec-wallet/pull/1106 (Outdated) + hive_ce_flutter: ^2.3.4 # Flutter bindings for hive_ce # Approved via https://github.com/GLEECBTC/gleec-wallet/pull/1106 (Outdated) badges: 3.1.2 @@ -151,16 +148,16 @@ dependencies: # ^0.1.1-dev.3 # Option 3: Pub.dev dependency bloc_concurrency: 0.3.0 - file_picker: ^10.3.2 + file_picker: ^10.3.10 # TODO: review required - SDK integration path_provider: 2.1.5 # flutter.dev - shared_preferences: ^2.5.3 # flutter.dev + shared_preferences: ^2.5.4 # flutter.dev decimal: 3.2.1 # transitive dependency that is required to fix breaking changes in rational package rational: 2.2.3 # sdk depends on decimal ^3.0.2, which depends on rational ^2.0.0 uuid: 4.5.1 # sdk depends on this version - flutter_bloc: ^9.1.0 # sdk depends on this version, and hosted instead of git reference - get_it: ^8.0.3 # sdk depends on this version, and hosted instead of git reference + flutter_bloc: ^9.1.1 # sdk depends on this version, and hosted instead of git reference + get_it: ^8.3.0 # sdk depends on this version, and hosted instead of git reference komodo_defi_sdk: path: sdk/packages/komodo_defi_sdk # Option 1: Local path via sdk submodule (pinned). See docs/SDK_SUBMODULE_MANAGEMENT.md @@ -186,13 +183,13 @@ dependencies: # ref: dev # ^0.3.0+3 # Option 3: Pub.dev dependency - feedback: ^3.1.0 + feedback: ^3.2.0 ntp: ^2.0.0 - matomo_tracker: ^6.0.0 - flutter_window_close: ^1.2.0 + matomo_tracker: ^6.1.0 + flutter_window_close: ^1.3.0 dev_dependencies: - test: ^1.24.1 # dart.dev + test: ^1.30.0 # dart.dev # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is @@ -221,6 +218,7 @@ flutter: assets: - assets/ - assets/custom_icons/16px/ + - assets/legal/ - assets/logo/ - assets/fonts/ - assets/flags/ diff --git a/roles/nginx/templates/airdex.conf.j2 b/roles/nginx/templates/airdex.conf.j2 index 51760a8624..359ae388ea 100644 --- a/roles/nginx/templates/airdex.conf.j2 +++ b/roles/nginx/templates/airdex.conf.j2 @@ -8,6 +8,9 @@ server } location / { root /var/www/flutterapp/{{ deploy_branch }}/build/web/; + # Required headers for Flutter WebAssembly multi-threading support + add_header Cross-Origin-Embedder-Policy credentialless; + add_header Cross-Origin-Opener-Policy same-origin; } ssl_certificate /etc/letsencrypt/live/node.dragonhound.info/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/node.dragonhound.info/privkey.pem; diff --git a/sdk b/sdk index d68143356b..e7dfde0aae 160000 --- a/sdk +++ b/sdk @@ -1 +1 @@ -Subproject commit d68143356b38f21d324358828027c7e201314afe +Subproject commit e7dfde0aaef873993f9e38fe01cc0a09592a76d8 diff --git a/test_integration/bloc/cex_market_data/common/update_frequency_backoff_strategy_integration_test.dart b/test_integration/bloc/cex_market_data/common/update_frequency_backoff_strategy_integration_test.dart index aa4ffd11f9..6f65202117 100644 --- a/test_integration/bloc/cex_market_data/common/update_frequency_backoff_strategy_integration_test.dart +++ b/test_integration/bloc/cex_market_data/common/update_frequency_backoff_strategy_integration_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'package:flutter_test/flutter_test.dart'; import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; @@ -12,69 +14,74 @@ void main() { actualIntervals.add(strategy.getNextInterval()); } - // Verify the pattern: 2min pairs, then 4min pairs, then 8min pairs, etc. - expect(actualIntervals[0], const Duration(minutes: 2)); // Attempt 0 - expect(actualIntervals[1], const Duration(minutes: 2)); // Attempt 1 - expect(actualIntervals[2], const Duration(minutes: 4)); // Attempt 2 - expect(actualIntervals[3], const Duration(minutes: 4)); // Attempt 3 - expect(actualIntervals[4], const Duration(minutes: 8)); // Attempt 4 - expect(actualIntervals[5], const Duration(minutes: 8)); // Attempt 5 - expect(actualIntervals[6], const Duration(minutes: 16)); // Attempt 6 - expect(actualIntervals[7], const Duration(minutes: 16)); // Attempt 7 - expect(actualIntervals[8], const Duration(minutes: 32)); // Attempt 8 - expect(actualIntervals[9], const Duration(minutes: 32)); // Attempt 9 - expect(actualIntervals[10], const Duration(minutes: 60)); // Capped at 1 hour - expect(actualIntervals[11], const Duration(minutes: 60)); // Capped at 1 hour + // Verify the pattern: 1min pairs, then 2min pairs, then 4min, etc. (default base) + expect(actualIntervals[0], const Duration(minutes: 1)); // Attempt 0 + expect(actualIntervals[1], const Duration(minutes: 1)); // Attempt 1 + expect(actualIntervals[2], const Duration(minutes: 2)); // Attempt 2 + expect(actualIntervals[3], const Duration(minutes: 2)); // Attempt 3 + expect(actualIntervals[4], const Duration(minutes: 4)); // Attempt 4 + expect(actualIntervals[5], const Duration(minutes: 4)); // Attempt 5 + expect(actualIntervals[6], const Duration(minutes: 8)); // Attempt 6 + expect(actualIntervals[7], const Duration(minutes: 8)); // Attempt 7 + expect(actualIntervals[8], const Duration(minutes: 16)); // Attempt 8 + expect(actualIntervals[9], const Duration(minutes: 16)); // Attempt 9 + expect(actualIntervals[10], const Duration(minutes: 32)); // Attempt 10 + expect(actualIntervals[11], const Duration(minutes: 32)); // Attempt 11 + expect(actualIntervals[12], const Duration(minutes: 60)); // Capped at 1 hour + expect(actualIntervals[13], const Duration(minutes: 60)); // Capped at 1 hour // Verify that all subsequent intervals are capped at max - for (int i = 12; i < actualIntervals.length; i++) { + for (int i = 14; i < actualIntervals.length; i++) { expect(actualIntervals[i], const Duration(minutes: 60)); } }); test('should reduce API calls over time compared to fixed interval', () { final strategy = UpdateFrequencyBackoffStrategy(); - + // Calculate total time and API calls over 24 hours with backoff strategy const simulationDuration = Duration(hours: 24); int backoffApiCalls = 0; Duration totalBackoffTime = Duration.zero; - + while (totalBackoffTime < simulationDuration) { final interval = strategy.getNextInterval(); totalBackoffTime += interval; backoffApiCalls++; } - // Calculate API calls with fixed 2-minute interval - const fixedInterval = Duration(minutes: 2); - final fixedApiCalls = simulationDuration.inMinutes ~/ fixedInterval.inMinutes; + // Compare to fixed polling at the same base interval (1 minute) + const fixedInterval = Duration(minutes: 1); + final fixedApiCalls = + simulationDuration.inMinutes ~/ fixedInterval.inMinutes; // Backoff strategy should make significantly fewer API calls expect(backoffApiCalls, lessThan(fixedApiCalls)); expect(backoffApiCalls, lessThan(fixedApiCalls * 0.5)); // Less than 50% of fixed calls - - print('Fixed interval (2min): $fixedApiCalls API calls in 24h'); + + print('Fixed interval (1min): $fixedApiCalls API calls in 24h'); print('Backoff strategy: $backoffApiCalls API calls in 24h'); - print('Reduction: ${((fixedApiCalls - backoffApiCalls) / fixedApiCalls * 100).toStringAsFixed(1)}%'); + print( + 'Reduction: ${((fixedApiCalls - backoffApiCalls) / fixedApiCalls * 100).toStringAsFixed(1)}%', + ); }); test('should recover quickly after reset', () { final strategy = UpdateFrequencyBackoffStrategy(); - + // Advance to high attempt count for (int i = 0; i < 10; i++) { strategy.getNextInterval(); } - + // Should be at a high interval expect(strategy.getCurrentInterval(), greaterThan(const Duration(minutes: 10))); - + // Reset and verify quick recovery strategy.reset(); + expect(strategy.getNextInterval(), const Duration(minutes: 1)); + expect(strategy.getNextInterval(), const Duration(minutes: 1)); expect(strategy.getNextInterval(), const Duration(minutes: 2)); - expect(strategy.getNextInterval(), const Duration(minutes: 2)); - expect(strategy.getNextInterval(), const Duration(minutes: 4)); }); test('should handle custom intervals for different use cases', () { @@ -105,32 +112,34 @@ void main() { test('should be suitable for portfolio update scenarios', () { final strategy = UpdateFrequencyBackoffStrategy(); - + // First hour of updates (user just logged in) final firstHourIntervals = []; Duration elapsed = Duration.zero; const oneHour = Duration(hours: 1); - + while (elapsed < oneHour) { final interval = strategy.getNextInterval(); firstHourIntervals.add(interval); elapsed += interval; } - + // Should have frequent updates in the first hour expect(firstHourIntervals.length, greaterThan(5)); expect(firstHourIntervals.length, lessThan(30)); // But not too frequent - + // First few updates should be relatively quick - expect(firstHourIntervals[0], const Duration(minutes: 2)); - expect(firstHourIntervals[1], const Duration(minutes: 2)); - + expect(firstHourIntervals[0], const Duration(minutes: 1)); + expect(firstHourIntervals[1], const Duration(minutes: 1)); + // Later updates should be less frequent final lastInterval = firstHourIntervals.last; - expect(lastInterval, greaterThan(const Duration(minutes: 2))); - + expect(lastInterval, greaterThan(const Duration(minutes: 1))); + print('Updates in first hour: ${firstHourIntervals.length}'); - print('Intervals: ${firstHourIntervals.map((d) => '${d.inMinutes}min').join(', ')}'); + print( + 'Intervals: ${firstHourIntervals.map((d) => '${d.inMinutes}min').join(', ')}', + ); }); }); -} \ No newline at end of file +} diff --git a/test_integration/runners/integration_test_runner.dart b/test_integration/runners/integration_test_runner.dart index 3447d57e9a..4248f6baad 100644 --- a/test_integration/runners/integration_test_runner.dart +++ b/test_integration/runners/integration_test_runner.dart @@ -8,6 +8,8 @@ import 'app_data.dart'; /// Runs integration tests for web or native apps using the `flutter drive` /// command. class IntegrationTestRunner { + static const _chromeExecutableEnvironmentKey = 'CHROME_EXECUTABLE'; + /// Runs integration tests for web or native apps using the `flutter drive` /// command. /// @@ -25,6 +27,19 @@ class IntegrationTestRunner { final String testsDirectory; bool get isWeb => _args.device == 'web-server'; + String get _browserDimension => _args.browserDimension.replaceAll(',', 'x'); + String? get _chromeBinaryPath { + if (_args.browserName != 'chrome') { + return null; + } + + final chromeBinary = Platform.environment[_chromeExecutableEnvironmentKey]; + if (chromeBinary == null || chromeBinary.isEmpty) { + return null; + } + + return chromeBinary; + } Future runTest(String test) async { ProcessResult result; @@ -106,14 +121,13 @@ class IntegrationTestRunner { '-d', _args.device, '--browser-dimension', - _args.browserDimension, + _browserDimension, '--${_args.displayMode}', '--${_args.runMode}', if (_args.runMode == 'profile') '--profile-memory=memory_profile.json', '--browser-name', _args.browserName, - '--web-renderer', - 'canvaskit', + if (_chromeBinaryPath != null) '--chrome-binary=$_chromeBinaryPath', '--${_args.pub ? '' : 'no-'}pub', '--${_args.keepRunning ? '' : 'no-'}keep-app-running', '--driver-port=${_args.driverPort}', diff --git a/test_integration/tests/wallets_tests/test_coin_details_core.dart b/test_integration/tests/wallets_tests/test_coin_details_core.dart new file mode 100644 index 0000000000..a742150369 --- /dev/null +++ b/test_integration/tests/wallets_tests/test_coin_details_core.dart @@ -0,0 +1,63 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../common/widget_tester_action_extensions.dart'; +import '../../common/widget_tester_find_extension.dart'; +import '../../common/widget_tester_pump_extension.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; +import 'wallet_tools.dart'; + +Future _activateMarty(WidgetTester tester) async { + final coinsList = find.byKeyName('wallet-page-scroll-view'); + final martyCoinItem = find.byKeyName('coins-manager-list-item-marty'); + final martyCoinActive = find.byKeyName('active-coin-item-marty'); + + await addAsset(tester, asset: martyCoinItem, search: 'marty'); + await tester.pumpUntilVisible( + martyCoinActive, + timeout: const Duration(seconds: 30), + throwOnError: false, + ); + await tester.dragUntilVisible( + martyCoinActive, + coinsList, + const Offset(0, -50), + ); + await tester.tapAndPump(martyCoinActive); + await tester.pumpAndSettle(); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('coin details core sections render after opening active coin', ( + tester, + ) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await _activateMarty(tester); + + expect(find.byKeyName('coin-details-send-button'), findsOneWidget); + expect(find.byKeyName('coin-details-receive-button'), findsOneWidget); + expect(find.byKeyName('coin-details-balance'), findsOneWidget); + + // Core navigation sanity: open send flow and return. + await tester.tapAndPump(find.byKeyName('coin-details-send-button')); + expect(find.byKeyName('withdraw-recipient-address-input'), findsOneWidget); + await tester.tapAndPump(find.byKey(const Key('back-button'))); + + // Receive flow can open and return cleanly. + await tester.tapAndPump(find.byKeyName('coin-details-receive-button')); + expect(find.byKeyName('coin-details-address-field'), findsOneWidget); + await tester.tapAndPump(find.byKey(const Key('back-button'))); + }); +} diff --git a/test_integration/tests/wallets_tests/test_coin_details_rewards.dart b/test_integration/tests/wallets_tests/test_coin_details_rewards.dart new file mode 100644 index 0000000000..7e6b4b4ba6 --- /dev/null +++ b/test_integration/tests/wallets_tests/test_coin_details_rewards.dart @@ -0,0 +1,53 @@ +// ignore_for_file: avoid_print + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../common/widget_tester_action_extensions.dart'; +import '../../common/widget_tester_find_extension.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('rewards flow opens and handles no rewards gracefully', ( + tester, + ) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + + final kmdCoinActive = find.byKeyName('active-coin-item-kmd'); + if (kmdCoinActive.evaluate().isEmpty) { + print('Skipping rewards check: KMD is not active in this environment.'); + return; + } + + await tester.tapAndPump(kmdCoinActive); + await tester.pumpAndSettle(); + + final rewardsButtonText = find.text('getRewards'); + if (rewardsButtonText.evaluate().isEmpty) { + print( + 'Skipping rewards check: rewards button unavailable for current wallet mode.', + ); + return; + } + + await tester.tapAndPump(rewardsButtonText.first); + await tester.pumpAndSettle(); + + // Accept both "no rewards" and claimable states. + final noRewards = find.text('noRewards'); + final claimButton = find.byKeyName('reward-claim-button'); + expect( + noRewards.evaluate().isNotEmpty || claimButton.evaluate().isNotEmpty, + isTrue, + ); + }); +} diff --git a/test_integration/tests/wallets_tests/test_withdraw.dart b/test_integration/tests/wallets_tests/test_withdraw.dart index 0edb11b299..471de796a2 100644 --- a/test_integration/tests/wallets_tests/test_withdraw.dart +++ b/test_integration/tests/wallets_tests/test_withdraw.dart @@ -20,6 +20,9 @@ Future testWithdraw(WidgetTester tester) async { Finder martyCoinItem = await _activateMarty(tester); print('🔍 WITHDRAW TEST: Marty coin activated'); + await _assertCoinDetailsCoreSections(tester); + print('🔍 WITHDRAW TEST: Core coin details sections verified'); + await _testCopyAddressButton(tester); print('🔍 WITHDRAW TEST: Copy address button test completed'); @@ -41,6 +44,18 @@ Future testWithdraw(WidgetTester tester) async { } } +Future _assertCoinDetailsCoreSections(WidgetTester tester) async { + expect(find.byKeyName('coin-details-balance'), findsOneWidget); + expect(find.byKeyName('coin-details-send-button'), findsOneWidget); + expect(find.byKeyName('coin-details-receive-button'), findsOneWidget); + + // Some assets expose faucet; if it exists, at least verify the button can be found. + final faucetFinder = find.byKeyName('coin-details-faucet-button'); + if (faucetFinder.evaluate().isNotEmpty) { + expect(faucetFinder, findsWidgets); + } +} + Future _activateMarty(WidgetTester tester) async { print('🔍 ACTIVATE MARTY: Starting activation process'); @@ -60,7 +75,10 @@ Future _activateMarty(WidgetTester tester) async { print('🔍 ACTIVATE MARTY: Waited for coin to become visible'); await tester.dragUntilVisible( - martyCoinActive, coinsList, const Offset(0, -50)); + martyCoinActive, + coinsList, + const Offset(0, -50), + ); print('🔍 ACTIVATE MARTY: Scrolled to coin'); await tester.tapAndPump(martyCoinActive); @@ -75,12 +93,8 @@ Future _activateMarty(WidgetTester tester) async { Future _testCopyAddressButton(WidgetTester tester) async { print('🔍 COPY ADDRESS: Starting copy address test'); - final Finder coinBalance = find.byKey( - const Key('coin-details-balance'), - ); - final Finder exitButton = find.byKey( - const Key('back-button'), - ); + final Finder coinBalance = find.byKey(const Key('coin-details-balance')); + final Finder exitButton = find.byKey(const Key('back-button')); final Finder receiveButton = find.byKey( const Key('coin-details-receive-button'), ); @@ -167,25 +181,21 @@ Future _sendAmountToAddress( void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - testWidgets( - 'Run withdraw tests:', - (WidgetTester tester) async { - print('🔍 MAIN: Starting withdraw test suite'); - tester.testTextInput.register(); - await app.main(); - await tester.pumpAndSettle(); - - print('🔍 MAIN: Accepting alpha warning'); - await acceptAlphaWarning(tester); - - await restoreWalletToTest(tester); - print('🔍 MAIN: Wallet restored'); - - await testWithdraw(tester); - await tester.pumpAndSettle(); - - print('🔍 MAIN: Withdraw tests completed successfully'); - }, - semanticsEnabled: false, - ); + testWidgets('Run withdraw tests:', (WidgetTester tester) async { + print('🔍 MAIN: Starting withdraw test suite'); + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + + print('🔍 MAIN: Accepting alpha warning'); + await acceptAlphaWarning(tester); + + await restoreWalletToTest(tester); + print('🔍 MAIN: Wallet restored'); + + await testWithdraw(tester); + await tester.pumpAndSettle(); + + print('🔍 MAIN: Withdraw tests completed successfully'); + }, semanticsEnabled: false); } diff --git a/test_units/bloc/cex_market_data/common/update_frequency_backoff_strategy_test.dart b/test_units/bloc/cex_market_data/common/update_frequency_backoff_strategy_test.dart index 6dbb7c5fbc..0a25f103fc 100644 --- a/test_units/bloc/cex_market_data/common/update_frequency_backoff_strategy_test.dart +++ b/test_units/bloc/cex_market_data/common/update_frequency_backoff_strategy_test.dart @@ -14,39 +14,39 @@ void main() { }); test('should return base interval for first two attempts', () { - expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); - expect(strategy.getNextInterval(), const Duration(minutes: 2)); + expect(strategy.getCurrentInterval(), const Duration(minutes: 1)); + expect(strategy.getNextInterval(), const Duration(minutes: 1)); expect(strategy.attemptCount, 1); - - expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); - expect(strategy.getNextInterval(), const Duration(minutes: 2)); + + expect(strategy.getCurrentInterval(), const Duration(minutes: 1)); + expect(strategy.getNextInterval(), const Duration(minutes: 1)); expect(strategy.attemptCount, 2); }); test('should double interval for next pair of attempts', () { // Skip first two attempts - strategy.getNextInterval(); // 2 min - strategy.getNextInterval(); // 2 min - - expect(strategy.getCurrentInterval(), const Duration(minutes: 4)); - expect(strategy.getNextInterval(), const Duration(minutes: 4)); + strategy.getNextInterval(); // 1 min + strategy.getNextInterval(); // 1 min + + expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 2)); expect(strategy.attemptCount, 3); - - expect(strategy.getCurrentInterval(), const Duration(minutes: 4)); - expect(strategy.getNextInterval(), const Duration(minutes: 4)); + + expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 2)); expect(strategy.attemptCount, 4); }); - test('should follow exponential backoff pattern: 2,2,4,4,8,8,16,16', () { + test('should follow exponential backoff pattern: 1,1,2,2,4,4,8,8', () { final expectedIntervals = [ - const Duration(minutes: 2), // attempt 0 - const Duration(minutes: 2), // attempt 1 - const Duration(minutes: 4), // attempt 2 - const Duration(minutes: 4), // attempt 3 - const Duration(minutes: 8), // attempt 4 - const Duration(minutes: 8), // attempt 5 - const Duration(minutes: 16), // attempt 6 - const Duration(minutes: 16), // attempt 7 + const Duration(minutes: 1), // attempt 0 + const Duration(minutes: 1), // attempt 1 + const Duration(minutes: 2), // attempt 2 + const Duration(minutes: 2), // attempt 3 + const Duration(minutes: 4), // attempt 4 + const Duration(minutes: 4), // attempt 5 + const Duration(minutes: 8), // attempt 6 + const Duration(minutes: 8), // attempt 7 ]; for (int i = 0; i < expectedIntervals.length; i++) { @@ -78,15 +78,15 @@ void main() { strategy.getNextInterval(); strategy.getNextInterval(); strategy.getNextInterval(); - + expect(strategy.attemptCount, 3); - expect(strategy.getCurrentInterval(), const Duration(minutes: 4)); - + expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); + // Reset strategy.reset(); - + expect(strategy.attemptCount, 0); - expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); + expect(strategy.getCurrentInterval(), const Duration(minutes: 1)); }); test('should always return true for shouldUpdateCache', () { @@ -100,40 +100,40 @@ void main() { test('should preview next intervals without changing state', () { // Start at attempt count 0 expect(strategy.attemptCount, 0); - + final preview = strategy.previewNextIntervals(6); - + // State should be unchanged expect(strategy.attemptCount, 0); - + // Preview should show correct intervals expect(preview, [ - const Duration(minutes: 2), // attempt 0 - const Duration(minutes: 2), // attempt 1 - const Duration(minutes: 4), // attempt 2 - const Duration(minutes: 4), // attempt 3 - const Duration(minutes: 8), // attempt 4 - const Duration(minutes: 8), // attempt 5 + const Duration(minutes: 1), // attempt 0 + const Duration(minutes: 1), // attempt 1 + const Duration(minutes: 2), // attempt 2 + const Duration(minutes: 2), // attempt 3 + const Duration(minutes: 4), // attempt 4 + const Duration(minutes: 4), // attempt 5 ]); }); test('should preview intervals from current position', () { // Advance to attempt 2 - strategy.getNextInterval(); // 2 min - strategy.getNextInterval(); // 2 min - + strategy.getNextInterval(); // 1 min + strategy.getNextInterval(); // 1 min + expect(strategy.attemptCount, 2); - + final preview = strategy.previewNextIntervals(4); - + // Should show intervals starting from attempt 2 expect(preview, [ - const Duration(minutes: 4), // attempt 2 - const Duration(minutes: 4), // attempt 3 - const Duration(minutes: 8), // attempt 4 - const Duration(minutes: 8), // attempt 5 + const Duration(minutes: 2), // attempt 2 + const Duration(minutes: 2), // attempt 3 + const Duration(minutes: 4), // attempt 4 + const Duration(minutes: 4), // attempt 5 ]); - + // State should be unchanged expect(strategy.attemptCount, 2); }); @@ -161,4 +161,4 @@ void main() { ]); }); }); -} \ No newline at end of file +} diff --git a/test_units/main.dart b/test_units/main.dart index 857c20b25e..fa383f82be 100644 --- a/test_units/main.dart +++ b/test_units/main.dart @@ -1,39 +1,51 @@ -import 'package:test/test.dart'; +import 'package:flutter_test/flutter_test.dart'; -import 'tests/encryption/encrypt_data_test.dart'; +import 'tests/encryption/encrypt_data_tests.dart'; import 'tests/formatter/compare_dex_to_cex_tests.dart'; -import 'tests/formatter/cut_trailing_zeros_test.dart'; -import 'tests/formatter/duration_format_test.dart'; -import 'tests/formatter/format_amount_test.dart'; -import 'tests/formatter/format_amount_test_alt.dart'; +import 'tests/formatter/cut_trailing_zeros_tests.dart'; +import 'tests/formatter/duration_format_tests.dart'; +import 'tests/formatter/format_amount_tests.dart'; +import 'tests/formatter/format_amount_test_alt_tests.dart'; import 'tests/formatter/format_dex_amt_tests.dart'; -import 'tests/formatter/formatted_date_test.dart'; -import 'tests/formatter/leading_zeros_test.dart'; -import 'tests/formatter/number_without_exponent_test.dart'; -import 'tests/formatter/text_input_formatter_test.dart'; -import 'tests/formatter/truncate_hash_test.dart'; -import 'tests/helpers/calculate_buy_amount_test.dart'; -import 'tests/helpers/get_sell_amount_test.dart'; -import 'tests/helpers/max_min_rational_test.dart'; -import 'tests/helpers/total_24_change_test.dart'; +import 'tests/formatter/formatted_date_tests.dart'; +import 'tests/formatter/leading_zeros_tests.dart'; +import 'tests/formatter/number_without_exponent_tests.dart'; +import 'tests/formatter/text_input_formatter_tests.dart'; +import 'tests/formatter/truncate_hash_tests.dart'; +import 'tests/helpers/calculate_buy_amount_tests.dart'; +import 'tests/helpers/get_sell_amount_tests.dart'; +import 'tests/helpers/max_min_rational_tests.dart'; +import 'tests/helpers/total_24_change_tests.dart'; import 'tests/helpers/total_fee_test.dart'; -import 'tests/helpers/update_sell_amount_test.dart'; -import 'tests/password/validate_password_test.dart'; -import 'tests/password/validate_rpc_password_test.dart'; -import 'tests/sorting/sorting_test.dart'; -import 'tests/swaps/my_recent_swaps_response_test.dart'; -import 'tests/system_health/http_head_time_provider_test.dart'; -import 'tests/system_health/http_time_provider_test.dart'; -import 'tests/system_health/ntp_time_provider_test.dart'; -import 'tests/system_health/system_clock_repository_test.dart'; -import 'tests/system_health/time_provider_registry_test.dart'; -import 'tests/utils/convert_double_to_string_test.dart'; -import 'tests/utils/convert_fract_rat_test.dart'; -import 'tests/utils/double_to_string_test.dart'; +import 'tests/helpers/update_sell_amount_tests.dart'; +import 'tests/password/validate_password_tests.dart'; +import 'tests/password/validate_rpc_password_tests.dart'; +import 'tests/sorting/sorting_tests.dart'; +import 'tests/swaps/my_recent_swaps_response_tests.dart'; +import 'tests/system_health/http_head_time_provider_tests.dart'; +import 'tests/system_health/http_time_provider_tests.dart'; +import 'tests/system_health/ntp_time_provider_tests.dart'; +import 'tests/system_health/system_clock_repository_tests.dart'; +import 'tests/system_health/time_provider_registry_tests.dart'; +import 'tests/balance_utils/compute_wallet_total_usd_tests.dart'; +import 'tests/balance_utils/coins_state_usd_conversion_test.dart'; +import 'tests/wallet/coin_details/coin_details_balance_confirmation_controller_test.dart'; +import 'tests/wallet/coin_details/coin_details_balance_content_test.dart'; +import 'tests/wallet/coin_details/kmd_rewards_logic_test.dart'; +import 'tests/wallet/coin_details/receive_address_faucet_widget_test.dart'; +import 'tests/wallet/coin_details/rewards_widget_test.dart'; +import 'tests/wallet/coin_details/transaction_details_logic_test.dart'; +import 'tests/wallet/coin_details/transaction_views_widget_test.dart'; +import 'tests/wallet/coin_details/withdraw_form_bloc_test.dart'; +import 'tests/wallet/coin_details/withdraw_form_fill_section_test.dart'; +import 'tests/utils/convert_double_to_string_tests.dart'; +import 'tests/utils/convert_fract_rat_tests.dart'; +import 'tests/utils/double_to_string_tests.dart'; +import 'tests/utils/explorer_url_tests.dart'; import 'tests/utils/get_fiat_amount_tests.dart'; -import 'tests/utils/get_usd_balance_test.dart'; +import 'tests/utils/get_usd_balance_tests.dart'; import 'tests/utils/ipfs_gateway_manager_test.dart'; -import 'tests/utils/transaction_history/sanitize_transaction_test.dart'; +import 'tests/utils/transaction_history/sanitize_transaction_tests.dart'; /// Run in terminal flutter test test_units/main.dart /// More info at documentation "Unit and Widget testing" section @@ -63,10 +75,13 @@ void main() { }); group('Utils:', () { + testComputeWalletTotalUsd(); + testCoinsStateUsdConversion(); // TODO: re-enable or migrate to the SDK testUsdBalanceFormatter(); testGetFiatAmount(); testCustomDoubleToString(); + testExplorerUrlHelpers(); testRatToFracAndViseVersa(); testDoubleToString(); @@ -99,4 +114,16 @@ void main() { testNtpTimeProvider(); testTimeProviderRegistry(); }); + + group('CoinDetails:', () { + testWithdrawFormBloc(); + testCoinDetailsBalanceConfirmationController(); + testCoinDetailsBalanceContent(); + testWithdrawFormFillSection(); + testTransactionDetailsLogic(); + testKmdRewardsLogic(); + testRewardsWidgets(); + testTransactionViewsWidgets(); + testReceiveAddressFaucetWidgets(); + }); } diff --git a/test_units/tests/analytics_test.dart b/test_units/tests/analytics_test.dart index c29d77b120..86c02c1a1f 100644 --- a/test_units/tests/analytics_test.dart +++ b/test_units/tests/analytics_test.dart @@ -12,27 +12,25 @@ void main() { test('AnalyticsRepository implements AnalyticsRepo', () { final repo = AnalyticsRepository(testSettings); + addTearDown(() async => repo.dispose()); expect(repo, isA()); }); test('AnalyticsRepository has correct initialization state', () { final repo = AnalyticsRepository(testSettings); + addTearDown(() async => repo.dispose()); // Initially should not be initialized (async initialization) expect(repo.isInitialized, false); expect(repo.isEnabled, false); }); - testWidgets('AnalyticsRepository can send test event', ( - WidgetTester tester, - ) async { + test('AnalyticsRepository can send test event', () async { final repo = AnalyticsRepository(testSettings); + addTearDown(() async => repo.dispose()); - // Create a test event final testEvent = TestAnalyticsEvent(); - - // This should not throw an exception - expect(() => repo.queueEvent(testEvent), returnsNormally); + await repo.queueEvent(testEvent); }); }); } diff --git a/test_units/tests/balance_utils/coins_state_usd_conversion_test.dart b/test_units/tests/balance_utils/coins_state_usd_conversion_test.dart new file mode 100644 index 0000000000..2a37bd5139 --- /dev/null +++ b/test_units/tests/balance_utils/coins_state_usd_conversion_test.dart @@ -0,0 +1,33 @@ +import 'package:test/test.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; + +import '../utils/test_util.dart'; + +void main() { + testCoinsStateUsdConversion(); +} + +void testCoinsStateUsdConversion() { + final coin = setCoin(coinAbbr: 'TRX', usdPrice: 4.0); + final state = CoinsState( + coins: {'TRX': coin}, + walletCoins: {'TRX': coin}, + pubkeys: const {}, + prices: {'TRX': coin.usdPrice!}, + ); + + test('typed USD conversion handles numeric values safely', () { + expect(state.getUsdPriceForAmount(1.1, 'TRX'), closeTo(4.4, 1e-12)); + }); + + test( + 'legacy string conversion returns null for display-formatted values', + () { + expect(state.getUsdPriceByAmount('1.1 TRX', 'TRX'), isNull); + }, + ); + + test('legacy string conversion still supports numeric strings', () { + expect(state.getUsdPriceByAmount('1.1', 'TRX'), closeTo(4.4, 1e-12)); + }); +} diff --git a/test_units/tests/balance_utils/compute_wallet_total_usd_tests.dart b/test_units/tests/balance_utils/compute_wallet_total_usd_tests.dart new file mode 100644 index 0000000000..dc49418d52 --- /dev/null +++ b/test_units/tests/balance_utils/compute_wallet_total_usd_tests.dart @@ -0,0 +1,197 @@ +import 'package:decimal/decimal.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:test/test.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; +import 'package:web_dex/model/cex_price.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/shared/utils/balance_utils.dart'; + +Coin _buildCoin(String abbr) { + final assetId = AssetId( + id: abbr, + name: '$abbr Coin', + symbol: AssetSymbol(assetConfigId: abbr), + chainId: AssetChainId(chainId: 1), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + return Coin( + type: CoinType.utxo, + abbr: abbr, + id: assetId, + name: '$abbr Coin', + explorerUrl: 'https://example.com/$abbr', + explorerTxUrl: 'https://example.com/$abbr/tx', + explorerAddressUrl: 'https://example.com/$abbr/address', + protocolType: 'UTXO', + protocolData: null, + isTestCoin: false, + logoImageUrl: null, + coingeckoId: null, + fallbackSwapContract: null, + priority: 0, + state: CoinState.active, + swapContractAddress: null, + walletOnly: false, + mode: CoinMode.standard, + usdPrice: null, + ); +} + +CexPrice _cexPrice(AssetId id, double price) => CexPrice( + assetId: id, + price: Decimal.parse(price.toString()), + change24h: Decimal.zero, + lastUpdated: DateTime.fromMillisecondsSinceEpoch(0), +); + +class _FakeBalanceManager implements BalanceManager { + _FakeBalanceManager(this._byAsset); + final Map _byAsset; + + @override + BalanceInfo? lastKnown(AssetId assetId) => _byAsset[assetId]; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSdk implements KomodoDefiSdk { + _FakeSdk(this._balances); + final Map _balances; + + @override + BalanceManager get balances => _FakeBalanceManager(_balances); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +CoinsState _stateWithPrices({ + required Map coins, + required Map prices, +}) { + return CoinsState( + coins: coins, + walletCoins: coins, + pubkeys: const {}, + prices: prices, + ); +} + +void testComputeWalletTotalUsd() { + final btc = _buildCoin('BTC'); + final kmd = _buildCoin('KMD'); + + test('sums balances using CEX prices from CoinsState', () { + final sdk = _FakeSdk({ + btc.id: BalanceInfo( + total: null, + spendable: Decimal.fromInt(2), + unspendable: Decimal.zero, + ), + kmd.id: BalanceInfo( + total: null, + spendable: Decimal.fromInt(100), + unspendable: Decimal.zero, + ), + }); + final state = _stateWithPrices( + coins: {'BTC': btc, 'KMD': kmd}, + prices: {'BTC': _cexPrice(btc.id, 30_000), 'KMD': _cexPrice(kmd.id, 1)}, + ); + + expect( + computeWalletTotalUsd(coins: [btc, kmd], coinsState: state, sdk: sdk), + 60_100.0, + ); + }); + + test('returns null when no coin has both balance and price', () { + final sdk = _FakeSdk({ + btc.id: BalanceInfo( + total: null, + spendable: Decimal.one, + unspendable: Decimal.zero, + ), + }); + final state = _stateWithPrices(coins: {'BTC': btc}, prices: {}); + + expect( + computeWalletTotalUsd(coins: [btc], coinsState: state, sdk: sdk), + isNull, + ); + }); + + test('sums only coins that have both balance and price', () { + final sdk = _FakeSdk({ + btc.id: BalanceInfo( + total: null, + spendable: Decimal.fromInt(2), + unspendable: Decimal.zero, + ), + kmd.id: BalanceInfo( + total: null, + spendable: Decimal.fromInt(100), + unspendable: Decimal.zero, + ), + }); + final state = _stateWithPrices( + coins: {'BTC': btc, 'KMD': kmd}, + prices: {'BTC': _cexPrice(btc.id, 30_000)}, + ); + + expect( + computeWalletTotalUsd(coins: [btc, kmd], coinsState: state, sdk: sdk), + 60_000.0, + ); + }); + + test('returns 0 when priced balances are all zero', () { + final sdk = _FakeSdk({btc.id: BalanceInfo.zero()}); + final state = _stateWithPrices( + coins: {'BTC': btc}, + prices: {'BTC': _cexPrice(btc.id, 30_000)}, + ); + + expect( + computeWalletTotalUsd(coins: [btc], coinsState: state, sdk: sdk), + 0.0, + ); + }); + + test('returns 0.01 for positive dust totals below 0.01', () { + final sdk = _FakeSdk({ + btc.id: BalanceInfo( + total: null, + spendable: Decimal.parse('0.0001'), + unspendable: Decimal.zero, + ), + }); + final state = _stateWithPrices( + coins: {'BTC': btc}, + prices: {'BTC': _cexPrice(btc.id, 10)}, + ); + + expect( + computeWalletTotalUsd(coins: [btc], coinsState: state, sdk: sdk), + 0.01, + ); + }); + + test('returns null when lastKnown balance is missing', () { + final sdk = _FakeSdk({}); + final state = _stateWithPrices( + coins: {'BTC': btc}, + prices: {'BTC': _cexPrice(btc.id, 30_000)}, + ); + + expect( + computeWalletTotalUsd(coins: [btc], coinsState: state, sdk: sdk), + isNull, + ); + }); +} diff --git a/test_units/tests/custom_token_import/custom_token_import_bloc_test.dart b/test_units/tests/custom_token_import/custom_token_import_bloc_test.dart new file mode 100644 index 0000000000..7b84f2158c --- /dev/null +++ b/test_units/tests/custom_token_import/custom_token_import_bloc_test.dart @@ -0,0 +1,508 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' + as kdf_rpc; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_sdk/src/assets/asset_manager.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; +import 'package:web_dex/bloc/analytics/analytics_repo.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/custom_token_import/bloc/custom_token_import_bloc.dart'; +import 'package:web_dex/bloc/custom_token_import/bloc/custom_token_import_event.dart'; +import 'package:web_dex/bloc/custom_token_import/bloc/custom_token_import_state.dart'; +import 'package:web_dex/bloc/custom_token_import/data/custom_token_import_repository.dart'; +import 'package:web_dex/bloc/settings/settings_repository.dart'; +import 'package:web_dex/model/stored_settings.dart'; +import 'package:web_dex/services/storage/base_storage.dart'; + +Map _trxConfig() => { + 'coin': 'TRX', + 'type': 'TRX', + 'name': 'TRON', + 'fname': 'TRON', + 'wallet_only': true, + 'mm2': 1, + 'decimals': 6, + 'required_confirmations': 1, + 'derivation_path': "m/44'/195'", + 'protocol': { + 'type': 'TRX', + 'protocol_data': {'network': 'Mainnet'}, + }, + 'nodes': >[], +}; + +Map _trc20Config({ + required String coin, + required String contractAddress, +}) => { + 'coin': coin, + 'type': 'TRC-20', + 'name': 'Tether', + 'fname': 'Tether', + 'wallet_only': true, + 'mm2': 1, + 'decimals': 6, + 'derivation_path': "m/44'/195'", + 'protocol': { + 'type': 'TRC20', + 'protocol_data': {'platform': 'TRX', 'contract_address': contractAddress}, + }, + 'contract_address': contractAddress, + 'parent_coin': 'TRX', + 'nodes': >[], +}; + +class _MemoryStorage implements BaseStorage { + final Map _store = {}; + + @override + Future delete(String key) async { + _store.remove(key); + return true; + } + + @override + Future read(String key) async => _store[key]; + + @override + Future write(String key, dynamic data) async { + _store[key] = data; + return true; + } +} + +class _FakeAnalyticsRepo implements AnalyticsRepo { + final List queuedEvents = []; + + @override + Future activate() async {} + + @override + Future deactivate() async {} + + @override + Future dispose() async {} + + @override + bool get isEnabled => true; + + @override + bool get isInitialized => true; + + @override + Future loadPersistedQueue() async {} + + @override + Future persistQueue() async {} + + @override + Future queueEvent(AnalyticsEventData data) async { + queuedEvents.add(data); + } + + @override + Future retryInitialization(dynamic settings) async {} + + @override + Future sendData(AnalyticsEventData data) async { + queuedEvents.add(data); + } +} + +class _FakeAssetManager implements AssetManager { + _FakeAssetManager(this._available); + + final Map _available; + + @override + Map get available => _available; + + void addAsset(Asset asset) { + _available[asset.id] = asset; + } + + void removeAsset(AssetId assetId) { + _available.remove(assetId); + } + + @override + Set findAssetsByConfigId(String ticker) { + return _available.values.where((asset) => asset.id.id == ticker).toSet(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeAuth implements KomodoDefiLocalAuth { + _FakeAuth({required this.user}); + + KdfUser? user; + + @override + Future get currentUser async => user; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSdk implements KomodoDefiSdk { + _FakeSdk({required this.assets, required this.auth}); + + @override + final _FakeAssetManager assets; + + @override + final KomodoDefiLocalAuth auth; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _RollbackCall { + const _RollbackCall({ + required this.assets, + required this.deleteCustomTokens, + required this.removeWalletMetadataAssets, + }); + + final List assets; + final Set deleteCustomTokens; + final Set removeWalletMetadataAssets; +} + +class _FakeCoinsRepo implements CoinsRepo { + _FakeCoinsRepo({required this.assetManager, this.balanceInfo}); + + final _FakeAssetManager assetManager; + final List> activateCalls = []; + final List<_RollbackCall> rollbackCalls = []; + final Set activeAssetIds = {}; + final kdf_rpc.BalanceInfo? balanceInfo; + + @override + Future activateAssetsSync( + List assets, { + bool notifyListeners = true, + bool addToWalletMetadata = true, + int maxRetryAttempts = 15, + Duration initialRetryDelay = const Duration(milliseconds: 500), + Duration maxRetryDelay = const Duration(seconds: 10), + }) async { + activateCalls.add(List.from(assets)); + for (final asset in assets) { + activeAssetIds.add(asset.id); + assetManager.addAsset(asset); + } + } + + @override + double? getUsdPriceForAmount(num amount, String coinAbbr) => 12.5; + + @override + double? getUsdPriceByAmount(String amount, String coinAbbr) { + final parsed = double.tryParse(amount); + if (parsed == null) return null; + return getUsdPriceForAmount(parsed, coinAbbr); + } + + @override + Future isAssetActivated( + AssetId assetId, { + bool forceRefresh = false, + }) async { + return activeAssetIds.contains(assetId); + } + + @override + Future rollbackPreviewAssets( + Iterable assets, { + Set deleteCustomTokens = const {}, + Set removeWalletMetadataAssets = const {}, + bool notifyListeners = false, + }) async { + final assetList = assets.toList(); + rollbackCalls.add( + _RollbackCall( + assets: assetList, + deleteCustomTokens: deleteCustomTokens, + removeWalletMetadataAssets: removeWalletMetadataAssets, + ), + ); + + for (final asset in assetList) { + activeAssetIds.remove(asset.id); + } + for (final assetId in deleteCustomTokens) { + assetManager.removeAsset(assetId); + } + } + + @override + Future tryGetBalanceInfo(AssetId coinId) async { + return balanceInfo ?? kdf_rpc.BalanceInfo.zero(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeCustomTokenImportRepository implements ICustomTokenImportRepository { + Asset? fetchResult; + Object? fetchError; + int importCalls = 0; + + @override + void dispose() {} + + @override + Future fetchCustomToken({ + required CoinSubClass network, + required Asset platformAsset, + required String address, + }) async { + if (fetchError != null) { + throw fetchError!; + } + return fetchResult ?? (throw StateError('fetchResult not configured')); + } + + @override + String? getNetworkApiName(CoinSubClass coinType) { + return switch (coinType) { + CoinSubClass.trc20 => 'tron', + CoinSubClass.erc20 => 'ethereum', + _ => null, + }; + } + + @override + Future importCustomToken(Asset asset) async { + importCalls += 1; + } +} + +Future _setTrc20Input(CustomTokenImportBloc bloc) async { + bloc.add(const UpdateNetworkEvent(CoinSubClass.trc20)); + bloc.add(const UpdateAddressEvent('0x1234')); + await Future.delayed(Duration.zero); +} + +Future _fetchPreview(CustomTokenImportBloc bloc) async { + final successState = bloc.stream.firstWhere( + (state) => state.formStatus == FormStatus.success, + ); + bloc.add(const SubmitFetchCustomTokenEvent()); + return successState; +} + +AnalyticsBloc _createAnalyticsBloc(_FakeAnalyticsRepo analyticsRepo) { + return AnalyticsBloc( + analytics: analyticsRepo, + storedData: StoredSettings.initial(), + repository: SettingsRepository(storage: _MemoryStorage()), + ); +} + +void main() { + group('CustomTokenImportBloc preview lifecycle', () { + late Asset platformAsset; + late Asset tokenAsset; + late _FakeAssetManager assetManager; + late _FakeCoinsRepo coinsRepo; + late _FakeCustomTokenImportRepository repository; + late _FakeAnalyticsRepo analyticsRepo; + late AnalyticsBloc analyticsBloc; + late _FakeAuth auth; + late CustomTokenImportBloc bloc; + + setUp(() { + platformAsset = Asset.fromJson(_trxConfig(), knownIds: const {}); + tokenAsset = Asset.fromJson( + _trc20Config( + coin: 'USDT-TRC20', + contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + ), + knownIds: {platformAsset.id}, + ); + + assetManager = _FakeAssetManager({platformAsset.id: platformAsset}); + coinsRepo = _FakeCoinsRepo( + assetManager: assetManager, + balanceInfo: kdf_rpc.BalanceInfo.zero(), + ); + repository = _FakeCustomTokenImportRepository()..fetchResult = tokenAsset; + analyticsRepo = _FakeAnalyticsRepo(); + analyticsBloc = _createAnalyticsBloc(analyticsRepo); + auth = _FakeAuth( + user: KdfUser( + walletId: WalletId.fromName( + 'test-wallet', + const AuthOptions(derivationMethod: DerivationMethod.hdWallet), + ), + isBip39Seed: true, + metadata: const {'activated_coins': []}, + ), + ); + bloc = CustomTokenImportBloc( + repository, + coinsRepo, + _FakeSdk(assets: assetManager, auth: auth), + analyticsBloc, + ); + }); + + tearDown(() async { + if (!bloc.isClosed) { + await bloc.close(); + } + await analyticsBloc.close(); + }); + + test('successful preview does not roll back immediately', () async { + await _setTrc20Input(bloc); + + final successState = await _fetchPreview(bloc); + + expect(successState.coin, tokenAsset); + expect(coinsRepo.rollbackCalls, isEmpty); + expect( + coinsRepo.activeAssetIds, + containsAll({platformAsset.id, tokenAsset.id}), + ); + }); + + test('fetch failure rolls back preview-only platform activation', () async { + repository.fetchError = StateError('token lookup failed'); + await _setTrc20Input(bloc); + + final failureState = bloc.stream.firstWhere( + (state) => state.formStatus == FormStatus.failure, + ); + bloc.add(const SubmitFetchCustomTokenEvent()); + await failureState; + + expect(coinsRepo.rollbackCalls, hasLength(1)); + expect( + coinsRepo.rollbackCalls.single.assets.map((asset) => asset.id).toSet(), + {platformAsset.id}, + ); + expect(coinsRepo.rollbackCalls.single.deleteCustomTokens, isEmpty); + }); + + test( + 'reset rolls back preview token and parent, deleting new token', + () async { + await _setTrc20Input(bloc); + await _fetchPreview(bloc); + + final resetState = bloc.stream.firstWhere( + (state) => state.formStatus == FormStatus.initial, + ); + bloc.add(const ResetFormStatusEvent()); + await resetState; + + expect(coinsRepo.rollbackCalls, hasLength(1)); + expect( + coinsRepo.rollbackCalls.single.assets + .map((asset) => asset.id) + .toSet(), + {platformAsset.id, tokenAsset.id}, + ); + expect(coinsRepo.rollbackCalls.single.deleteCustomTokens, { + tokenAsset.id, + }); + expect(coinsRepo.rollbackCalls.single.removeWalletMetadataAssets, { + platformAsset.id, + tokenAsset.id, + }); + expect(assetManager.available.containsKey(tokenAsset.id), isFalse); + }, + ); + + test('close rolls back preview token and parent', () async { + await _setTrc20Input(bloc); + await _fetchPreview(bloc); + + await bloc.close(); + + expect(coinsRepo.rollbackCalls, hasLength(1)); + expect( + coinsRepo.rollbackCalls.single.assets.map((asset) => asset.id).toSet(), + {platformAsset.id, tokenAsset.id}, + ); + }); + + test('pre-existing preview asset is not deleted on reset', () async { + assetManager.addAsset(tokenAsset); + auth.user = auth.user!.copyWith( + metadata: { + 'activated_coins': [platformAsset.id.id, tokenAsset.id.id], + }, + ); + await _setTrc20Input(bloc); + await _fetchPreview(bloc); + + final resetState = bloc.stream.firstWhere( + (state) => state.formStatus == FormStatus.initial, + ); + bloc.add(const ResetFormStatusEvent()); + await resetState; + + expect(coinsRepo.rollbackCalls, hasLength(1)); + expect(coinsRepo.rollbackCalls.single.deleteCustomTokens, isEmpty); + expect( + coinsRepo.rollbackCalls.single.removeWalletMetadataAssets, + isEmpty, + ); + expect(assetManager.available.containsKey(tokenAsset.id), isTrue); + }); + + test('preview rollback preserves saved parent metadata', () async { + auth.user = auth.user!.copyWith( + metadata: { + 'activated_coins': [platformAsset.id.id], + }, + ); + await _setTrc20Input(bloc); + await _fetchPreview(bloc); + + final resetState = bloc.stream.firstWhere( + (state) => state.formStatus == FormStatus.initial, + ); + bloc.add(const ResetFormStatusEvent()); + await resetState; + + expect(coinsRepo.rollbackCalls, hasLength(1)); + expect(coinsRepo.rollbackCalls.single.deleteCustomTokens, { + tokenAsset.id, + }); + expect(coinsRepo.rollbackCalls.single.removeWalletMetadataAssets, { + tokenAsset.id, + }); + }); + + test( + 'import success keeps preview activation and skips rollback on close', + () async { + await _setTrc20Input(bloc); + await _fetchPreview(bloc); + + final importState = bloc.stream.firstWhere( + (state) => state.importStatus == FormStatus.success, + ); + bloc.add(const SubmitImportCustomTokenEvent()); + await importState; + + expect(repository.importCalls, 1); + expect(analyticsRepo.queuedEvents, hasLength(1)); + + await bloc.close(); + + expect(coinsRepo.rollbackCalls, isEmpty); + }, + ); + }); +} diff --git a/test_units/tests/custom_token_import/custom_token_import_repository_test.dart b/test_units/tests/custom_token_import/custom_token_import_repository_test.dart new file mode 100644 index 0000000000..1fa5765073 --- /dev/null +++ b/test_units/tests/custom_token_import/custom_token_import_repository_test.dart @@ -0,0 +1,272 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_sdk/src/assets/asset_manager.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/custom_token_import/data/custom_token_import_repository.dart'; + +Map _trxConfig() => { + 'coin': 'TRX', + 'type': 'TRX', + 'name': 'TRON', + 'fname': 'TRON', + 'wallet_only': true, + 'mm2': 1, + 'decimals': 6, + 'required_confirmations': 1, + 'derivation_path': "m/44'/195'", + 'protocol': { + 'type': 'TRX', + 'protocol_data': {'network': 'Mainnet'}, + }, + 'nodes': >[], +}; + +Map _trc20Config({ + required String coin, + required String contractAddress, +}) => { + 'coin': coin, + 'type': 'TRC-20', + 'name': 'Tether', + 'fname': 'Tether', + 'wallet_only': true, + 'mm2': 1, + 'decimals': 6, + 'derivation_path': "m/44'/195'", + 'protocol': { + 'type': 'TRC20', + 'protocol_data': {'platform': 'TRX', 'contract_address': contractAddress}, + }, + 'contract_address': contractAddress, + 'parent_coin': 'TRX', + 'nodes': >[], +}; + +class _StubCoinsRepo implements CoinsRepo { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeAssetManager implements AssetManager { + _FakeAssetManager(this._available); + + final Map _available; + + @override + Map get available => _available; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSdk implements KomodoDefiSdk { + _FakeSdk({required this.client, required this.assets}); + + @override + final ApiClient client; + + @override + final AssetManager assets; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeApiClient implements ApiClient { + _FakeApiClient({ + required this.convertedContractAddress, + required this.tokenSymbol, + required this.decimals, + }); + + final String convertedContractAddress; + final String tokenSymbol; + final int decimals; + int convertAddressCalls = 0; + int getTokenInfoCalls = 0; + Map? lastConvertAddressRequest; + Map? lastGetTokenInfoRequest; + + @override + FutureOr> executeRpc(Map request) { + final method = request['method'] as String?; + switch (method) { + case 'convertaddress': + convertAddressCalls += 1; + lastConvertAddressRequest = request; + return { + 'mmrpc': '2.0', + 'result': {'address': convertedContractAddress}, + }; + case 'get_token_info': + getTokenInfoCalls += 1; + lastGetTokenInfoRequest = request; + return { + 'mmrpc': '2.0', + 'result': { + 'type': request['params']['protocol']['type'], + 'info': {'symbol': tokenSymbol, 'decimals': decimals}, + }, + }; + default: + throw UnsupportedError('Unexpected RPC method: $method'); + } + } +} + +class _FakeHttpClient extends http.BaseClient { + _FakeHttpClient(this._body); + + final String _body; + + @override + Future send(http.BaseRequest request) async { + final bytes = utf8.encode(_body); + return http.StreamedResponse( + Stream.value(bytes), + 200, + request: request, + headers: {'content-type': 'application/json'}, + ); + } +} + +void main() { + group('KdfCustomTokenImportRepository', () { + late Asset platformAsset; + + setUp(() { + platformAsset = Asset.fromJson(_trxConfig(), knownIds: const {}); + }); + + test('TRC20 fetch preserves selected protocol context end-to-end', () async { + final apiClient = _FakeApiClient( + convertedContractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + tokenSymbol: 'USDT', + decimals: 18, + ); + final repository = KdfCustomTokenImportRepository( + _FakeSdk( + client: apiClient, + assets: _FakeAssetManager({platformAsset.id: platformAsset}), + ), + _StubCoinsRepo(), + httpClient: _FakeHttpClient( + jsonEncode({ + 'id': 'tether', + 'name': 'Tether USD', + 'image': {'large': 'https://example.com/usdt.png'}, + }), + ), + ); + + final asset = await repository.fetchCustomToken( + network: CoinSubClass.trc20, + platformAsset: platformAsset, + address: '0x1234', + ); + + expect(asset.id.subClass, CoinSubClass.trc20); + expect(asset.protocol, isA()); + expect(asset.id.parentId, platformAsset.id); + expect(asset.id.id, 'USDT-TRC20'); + expect( + asset.protocol.contractAddress, + 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + ); + expect( + apiClient.lastConvertAddressRequest?['coin'], + equals(platformAsset.id.id), + ); + expect( + apiClient.lastGetTokenInfoRequest?['params']['protocol']['type'], + equals('TRC20'), + ); + expect( + apiClient + .lastGetTokenInfoRequest?['params']['protocol']['protocol_data']['platform'], + equals('TRX'), + ); + expect(asset.id.chainId.decimals, 18); + expect(asset.protocol.config['decimals'], 18); + }); + + test('same-contract re-import returns the existing known asset', () async { + final existingAsset = Asset.fromJson( + _trc20Config( + coin: 'USDT-TRC20', + contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + ), + knownIds: {platformAsset.id}, + ); + final apiClient = _FakeApiClient( + convertedContractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + tokenSymbol: 'USDT', + decimals: 6, + ); + final repository = KdfCustomTokenImportRepository( + _FakeSdk( + client: apiClient, + assets: _FakeAssetManager({ + platformAsset.id: platformAsset, + existingAsset.id: existingAsset, + }), + ), + _StubCoinsRepo(), + httpClient: _FakeHttpClient('{}'), + ); + + final asset = await repository.fetchCustomToken( + network: CoinSubClass.trc20, + platformAsset: platformAsset, + address: '0x1234', + ); + + expect(asset, same(existingAsset)); + expect(apiClient.convertAddressCalls, 1); + expect(apiClient.getTokenInfoCalls, 0); + }); + + test( + 'same generated asset id with different contract throws conflict', + () async { + final existingAsset = Asset.fromJson( + _trc20Config( + coin: 'USDT-TRC20', + contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + ), + knownIds: {platformAsset.id}, + ); + final repository = KdfCustomTokenImportRepository( + _FakeSdk( + client: _FakeApiClient( + convertedContractAddress: 'TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj', + tokenSymbol: 'USDT', + decimals: 6, + ), + assets: _FakeAssetManager({ + platformAsset.id: platformAsset, + existingAsset.id: existingAsset, + }), + ), + _StubCoinsRepo(), + httpClient: _FakeHttpClient('{}'), + ); + + expect( + repository.fetchCustomToken( + network: CoinSubClass.trc20, + platformAsset: platformAsset, + address: '0x1234', + ), + throwsA(isA()), + ); + }, + ); + }); +} diff --git a/test_units/tests/encryption/encrypt_data_test.dart b/test_units/tests/encryption/encrypt_data_tests.dart similarity index 100% rename from test_units/tests/encryption/encrypt_data_test.dart rename to test_units/tests/encryption/encrypt_data_tests.dart diff --git a/test_units/tests/fiat/tron_fiat_mapping_test.dart b/test_units/tests/fiat/tron_fiat_mapping_test.dart new file mode 100644 index 0000000000..7a588f8af6 --- /dev/null +++ b/test_units/tests/fiat/tron_fiat_mapping_test.dart @@ -0,0 +1,248 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/bloc/fiat/banxa_fiat_provider.dart'; +import 'package:web_dex/bloc/fiat/base_fiat_provider.dart'; +import 'package:web_dex/bloc/fiat/fiat_order_status.dart'; +import 'package:web_dex/bloc/fiat/models/models.dart'; +import 'package:web_dex/bloc/fiat/ramp/ramp_fiat_provider.dart'; +import 'package:web_dex/model/coin_type.dart'; + +class _TestFiatProvider extends BaseFiatProvider { + @override + Future buyCoin( + String accountReference, + String source, + ICurrency target, + String walletAddress, + String paymentMethodId, + String sourceAmount, + String returnUrlOnSuccess, + ) { + throw UnimplementedError(); + } + + @override + Future> getFiatList() { + throw UnimplementedError(); + } + + @override + Future> getCoinList() { + throw UnimplementedError(); + } + + @override + Future getPaymentMethodPrice( + String source, + ICurrency target, + String sourceAmount, + FiatPaymentMethod paymentMethod, + ) { + throw UnimplementedError(); + } + + @override + Future> getPaymentMethodsList( + String source, + ICurrency target, + String sourceAmount, + ) { + throw UnimplementedError(); + } + + @override + String getProviderId() => 'test'; + + @override + String get providerIconPath => ''; + + @override + Stream watchOrderStatus(String orderId) { + throw UnimplementedError(); + } +} + +class _TestBanxaFiatProvider extends BanxaFiatProvider { + _TestBanxaFiatProvider(this._coinsResponse); + + final Map _coinsResponse; + + @override + Future apiRequest( + String method, + String endpoint, { + Map? queryParams, + Map? body, + }) async { + if (queryParams?['endpoint'] == '/api/coins') { + return _coinsResponse; + } + + throw UnimplementedError('Unexpected Banxa API request'); + } +} + +class _TestBanxaPaymentMethodsProvider extends BanxaFiatProvider { + int paymentMethodsRequests = 0; + + @override + Future apiRequest( + String method, + String endpoint, { + Map? queryParams, + Map? body, + }) async { + if (queryParams?['endpoint'] == '/api/payment-methods') { + paymentMethodsRequests += 1; + return { + 'data': {'payment_methods': >[]}, + }; + } + + throw UnimplementedError('Unexpected Banxa API request'); + } +} + +void main() { + group('TRON fiat mapping', () { + final provider = _TestFiatProvider(); + + test('native TRX resolves to trx coin type', () { + expect(provider.getCoinType('TRX', coinSymbol: 'TRX'), CoinType.trx); + expect(provider.getCoinType('TRX'), CoinType.trx); + }); + + test('TRON tokens resolve to trc20 coin type', () { + expect(provider.getCoinType('TRON', coinSymbol: 'USDT'), CoinType.trc20); + }); + + test('native TRX abbreviation stays unchanged', () { + final currency = CryptoCurrency( + symbol: 'TRX', + name: 'TRON', + chainType: CoinType.trx, + minPurchaseAmount: Decimal.zero, + ); + + expect(currency.getAbbr(), 'TRX'); + }); + + test('TRC20 token abbreviation gets TRC20 suffix', () { + final currency = CryptoCurrency( + symbol: 'USDT', + name: 'Tether', + chainType: CoinType.trc20, + minPurchaseAmount: Decimal.zero, + ); + + expect(currency.getAbbr(), 'USDT-TRC20'); + }); + + test('Ramp asset codes use the TRON prefix', () { + final ramp = RampFiatProvider(); + + expect( + ramp.getFullCoinCode( + CryptoCurrency( + symbol: 'TRX', + name: 'TRON', + chainType: CoinType.trx, + minPurchaseAmount: Decimal.zero, + ), + ), + 'TRON_TRX', + ); + expect( + ramp.getFullCoinCode( + CryptoCurrency( + symbol: 'USDT', + name: 'Tether', + chainType: CoinType.trc20, + minPurchaseAmount: Decimal.zero, + ), + ), + 'TRON_USDT', + ); + }); + + test( + 'Banxa keeps native TRX while filtering unsupported BEP20 TRX', + () async { + final provider = _TestBanxaFiatProvider({ + 'data': { + 'coins': [ + { + 'coin_code': 'TRX', + 'coin_name': 'TRON', + 'blockchains': [ + {'code': 'TRX', 'min_value': '10'}, + {'code': 'BNB', 'min_value': '10'}, + ], + }, + ], + }, + }); + + final coins = await provider.getCoinList(); + + expect(coins, hasLength(1)); + expect(coins.single.symbol, 'TRX'); + expect(coins.single.chainType, CoinType.trx); + }, + ); + + test('Banxa payment methods allow native TRX', () async { + final provider = _TestBanxaPaymentMethodsProvider(); + + final methods = await provider.getPaymentMethodsList( + 'USD', + CryptoCurrency( + symbol: 'TRX', + name: 'TRON', + chainType: CoinType.trx, + minPurchaseAmount: Decimal.zero, + ), + '100', + ); + + expect(methods, isEmpty); + expect(provider.paymentMethodsRequests, 1); + }); + + test('Banxa payment methods allow native AVAX', () async { + final provider = _TestBanxaPaymentMethodsProvider(); + + final methods = await provider.getPaymentMethodsList( + 'USD', + CryptoCurrency( + symbol: 'AVAX', + name: 'Avalanche', + chainType: CoinType.avx20, + minPurchaseAmount: Decimal.zero, + ), + '100', + ); + + expect(methods, isEmpty); + expect(provider.paymentMethodsRequests, 1); + }); + + test('Banxa payment methods still block unsupported BEP20 TRX', () async { + final provider = _TestBanxaPaymentMethodsProvider(); + + final methods = await provider.getPaymentMethodsList( + 'USD', + CryptoCurrency( + symbol: 'TRX', + name: 'TRON (BEP20)', + chainType: CoinType.bep20, + minPurchaseAmount: Decimal.zero, + ), + '100', + ); + + expect(methods, isEmpty); + expect(provider.paymentMethodsRequests, 0); + }); + }); +} diff --git a/test_units/tests/formatter/cut_trailing_zeros_test.dart b/test_units/tests/formatter/cut_trailing_zeros_tests.dart similarity index 100% rename from test_units/tests/formatter/cut_trailing_zeros_test.dart rename to test_units/tests/formatter/cut_trailing_zeros_tests.dart diff --git a/test_units/tests/formatter/duration_format_test.dart b/test_units/tests/formatter/duration_format_tests.dart similarity index 100% rename from test_units/tests/formatter/duration_format_test.dart rename to test_units/tests/formatter/duration_format_tests.dart diff --git a/test_units/tests/formatter/format_amount_test_alt.dart b/test_units/tests/formatter/format_amount_test_alt_tests.dart similarity index 100% rename from test_units/tests/formatter/format_amount_test_alt.dart rename to test_units/tests/formatter/format_amount_test_alt_tests.dart diff --git a/test_units/tests/formatter/format_amount_test.dart b/test_units/tests/formatter/format_amount_tests.dart similarity index 100% rename from test_units/tests/formatter/format_amount_test.dart rename to test_units/tests/formatter/format_amount_tests.dart diff --git a/test_units/tests/formatter/formatted_date_test.dart b/test_units/tests/formatter/formatted_date_tests.dart similarity index 100% rename from test_units/tests/formatter/formatted_date_test.dart rename to test_units/tests/formatter/formatted_date_tests.dart diff --git a/test_units/tests/formatter/leading_zeros_test.dart b/test_units/tests/formatter/leading_zeros_tests.dart similarity index 100% rename from test_units/tests/formatter/leading_zeros_test.dart rename to test_units/tests/formatter/leading_zeros_tests.dart diff --git a/test_units/tests/formatter/number_without_exponent_test.dart b/test_units/tests/formatter/number_without_exponent_tests.dart similarity index 100% rename from test_units/tests/formatter/number_without_exponent_test.dart rename to test_units/tests/formatter/number_without_exponent_tests.dart diff --git a/test_units/tests/formatter/text_input_formatter_test.dart b/test_units/tests/formatter/text_input_formatter_tests.dart similarity index 100% rename from test_units/tests/formatter/text_input_formatter_test.dart rename to test_units/tests/formatter/text_input_formatter_tests.dart diff --git a/test_units/tests/formatter/truncate_decimal_test.dart b/test_units/tests/formatter/truncate_decimal_tests.dart similarity index 100% rename from test_units/tests/formatter/truncate_decimal_test.dart rename to test_units/tests/formatter/truncate_decimal_tests.dart diff --git a/test_units/tests/formatter/truncate_hash_test.dart b/test_units/tests/formatter/truncate_hash_tests.dart similarity index 100% rename from test_units/tests/formatter/truncate_hash_test.dart rename to test_units/tests/formatter/truncate_hash_tests.dart diff --git a/test_units/tests/helpers/calculate_buy_amount_test.dart b/test_units/tests/helpers/calculate_buy_amount_tests.dart similarity index 100% rename from test_units/tests/helpers/calculate_buy_amount_test.dart rename to test_units/tests/helpers/calculate_buy_amount_tests.dart diff --git a/test_units/tests/helpers/get_sell_amount_test.dart b/test_units/tests/helpers/get_sell_amount_tests.dart similarity index 100% rename from test_units/tests/helpers/get_sell_amount_test.dart rename to test_units/tests/helpers/get_sell_amount_tests.dart diff --git a/test_units/tests/helpers/max_min_rational_test.dart b/test_units/tests/helpers/max_min_rational_tests.dart similarity index 100% rename from test_units/tests/helpers/max_min_rational_test.dart rename to test_units/tests/helpers/max_min_rational_tests.dart diff --git a/test_units/tests/helpers/total_24_change_test.dart b/test_units/tests/helpers/total_24_change_tests.dart similarity index 100% rename from test_units/tests/helpers/total_24_change_test.dart rename to test_units/tests/helpers/total_24_change_tests.dart diff --git a/test_units/tests/helpers/update_sell_amount_test.dart b/test_units/tests/helpers/update_sell_amount_tests.dart similarity index 100% rename from test_units/tests/helpers/update_sell_amount_test.dart rename to test_units/tests/helpers/update_sell_amount_tests.dart diff --git a/test_units/tests/password/validate_password_test.dart b/test_units/tests/password/validate_password_tests.dart similarity index 100% rename from test_units/tests/password/validate_password_test.dart rename to test_units/tests/password/validate_password_tests.dart diff --git a/test_units/tests/password/validate_rpc_password_test.dart b/test_units/tests/password/validate_rpc_password_tests.dart similarity index 100% rename from test_units/tests/password/validate_rpc_password_test.dart rename to test_units/tests/password/validate_rpc_password_tests.dart diff --git a/test_units/tests/sorting/sorting_test.dart b/test_units/tests/sorting/sorting_tests.dart similarity index 100% rename from test_units/tests/sorting/sorting_test.dart rename to test_units/tests/sorting/sorting_tests.dart diff --git a/test_units/tests/swaps/my_recent_swaps_response_test.dart b/test_units/tests/swaps/my_recent_swaps_response_tests.dart similarity index 100% rename from test_units/tests/swaps/my_recent_swaps_response_test.dart rename to test_units/tests/swaps/my_recent_swaps_response_tests.dart diff --git a/test_units/tests/system_health/http_head_time_provider_test.dart b/test_units/tests/system_health/http_head_time_provider_tests.dart similarity index 100% rename from test_units/tests/system_health/http_head_time_provider_test.dart rename to test_units/tests/system_health/http_head_time_provider_tests.dart diff --git a/test_units/tests/system_health/http_time_provider_test.dart b/test_units/tests/system_health/http_time_provider_tests.dart similarity index 100% rename from test_units/tests/system_health/http_time_provider_test.dart rename to test_units/tests/system_health/http_time_provider_tests.dart diff --git a/test_units/tests/system_health/ntp_time_provider_test.dart b/test_units/tests/system_health/ntp_time_provider_tests.dart similarity index 100% rename from test_units/tests/system_health/ntp_time_provider_test.dart rename to test_units/tests/system_health/ntp_time_provider_tests.dart diff --git a/test_units/tests/system_health/system_clock_repository_test.dart b/test_units/tests/system_health/system_clock_repository_tests.dart similarity index 100% rename from test_units/tests/system_health/system_clock_repository_test.dart rename to test_units/tests/system_health/system_clock_repository_tests.dart diff --git a/test_units/tests/system_health/time_provider_registry_test.dart b/test_units/tests/system_health/time_provider_registry_tests.dart similarity index 100% rename from test_units/tests/system_health/time_provider_registry_test.dart rename to test_units/tests/system_health/time_provider_registry_tests.dart diff --git a/test_units/tests/utils/convert_double_to_string_test.dart b/test_units/tests/utils/convert_double_to_string_tests.dart similarity index 100% rename from test_units/tests/utils/convert_double_to_string_test.dart rename to test_units/tests/utils/convert_double_to_string_tests.dart diff --git a/test_units/tests/utils/convert_fract_rat_test.dart b/test_units/tests/utils/convert_fract_rat_tests.dart similarity index 82% rename from test_units/tests/utils/convert_fract_rat_test.dart rename to test_units/tests/utils/convert_fract_rat_tests.dart index 76731755b7..0bb4114468 100644 --- a/test_units/tests/utils/convert_fract_rat_test.dart +++ b/test_units/tests/utils/convert_fract_rat_tests.dart @@ -36,16 +36,17 @@ void testRatToFracAndViseVersa() { }); test('fract2rat handles very large integers without precision loss', () { - // 10^50 / 10^20 = 10^30 + // 10^50 / 10^20 = 10^30; Rational normalizes to lowest terms. final numer = '100000000000000000000000000000000000000000000000000'; final denom = '100000000000000000000'; final rat = fract2rat({'numer': numer, 'denom': denom}, false)!; - expect(rat.numerator, BigInt.parse(numer)); - expect(rat.denominator, BigInt.parse(denom)); - // Ensure round-trip + final expectedValue = Rational(BigInt.parse(numer), BigInt.parse(denom)); + expect(rat, expectedValue); + expect(rat.numerator, BigInt.parse('1000000000000000000000000000000')); + expect(rat.denominator, BigInt.one); final back = rat2fract(rat, false)!; - expect(back['numer'], numer); - expect(back['denom'], denom); + expect(back['numer'], '1000000000000000000000000000000'); + expect(back['denom'], '1'); }); test('fract2rat correctly parses strings that would overflow double', () { diff --git a/test_units/tests/utils/double_to_string_test.dart b/test_units/tests/utils/double_to_string_tests.dart similarity index 100% rename from test_units/tests/utils/double_to_string_test.dart rename to test_units/tests/utils/double_to_string_tests.dart diff --git a/test_units/tests/utils/explorer_url_tests.dart b/test_units/tests/utils/explorer_url_tests.dart new file mode 100644 index 0000000000..1a371dbedf --- /dev/null +++ b/test_units/tests/utils/explorer_url_tests.dart @@ -0,0 +1,78 @@ +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:test/test.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +void testExplorerUrlHelpers() { + test( + 'getTxExplorerUrl falls back to base explorer when tx pattern is empty', + () { + final coin = _coinWithExplorer( + explorerUrl: 'https://explorer.example/tx/', + explorerTxUrl: '', + ); + + expect( + getTxExplorerUrl(coin, 'abc123'), + 'https://explorer.example/tx/abc123', + ); + }, + ); + + test( + 'getTxExplorerUrl keeps 0x prefix behavior on base explorer fallback', + () { + final coin = _coinWithExplorer( + explorerUrl: 'https://etherscan.io/tx/', + explorerTxUrl: '', + protocolType: 'ERC20', + type: CoinType.erc20, + ); + + expect( + getTxExplorerUrl(coin, 'deadbeef'), + 'https://etherscan.io/tx/0xdeadbeef', + ); + }, + ); +} + +Coin _coinWithExplorer({ + required String explorerUrl, + required String explorerTxUrl, + String protocolType = 'UTXO', + CoinType type = CoinType.utxo, +}) { + final assetId = AssetId( + id: 'TEST', + name: 'Test Coin', + parentId: null, + symbol: AssetSymbol(assetConfigId: 'TEST'), + derivationPath: null, + chainId: AssetChainId(chainId: 0), + subClass: CoinSubClass.utxo, + ); + + return Coin( + type: type, + abbr: 'TEST', + id: assetId, + name: 'Test Coin', + logoImageUrl: null, + isCustomCoin: false, + explorerUrl: explorerUrl, + explorerTxUrl: explorerTxUrl, + explorerAddressUrl: '', + protocolType: protocolType, + protocolData: ProtocolData(platform: '', contractAddress: ''), + isTestCoin: false, + coingeckoId: null, + fallbackSwapContract: null, + priority: 0, + state: CoinState.inactive, + walletOnly: false, + mode: CoinMode.standard, + swapContractAddress: null, + ); +} diff --git a/test_units/tests/utils/get_usd_balance_test.dart b/test_units/tests/utils/get_usd_balance_tests.dart similarity index 100% rename from test_units/tests/utils/get_usd_balance_test.dart rename to test_units/tests/utils/get_usd_balance_tests.dart diff --git a/test_units/tests/utils/transaction_history/sanitize_transaction_test.dart b/test_units/tests/utils/transaction_history/sanitize_transaction_tests.dart similarity index 100% rename from test_units/tests/utils/transaction_history/sanitize_transaction_test.dart rename to test_units/tests/utils/transaction_history/sanitize_transaction_tests.dart diff --git a/test_units/tests/views/dex/simple/form/tables/table_utils_test.dart b/test_units/tests/views/dex/simple/form/tables/table_utils_test.dart index 1031f9015c..14a6836754 100644 --- a/test_units/tests/views/dex/simple/form/tables/table_utils_test.dart +++ b/test_units/tests/views/dex/simple/form/tables/table_utils_test.dart @@ -1,10 +1,13 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart' show AssetChainId, AssetId, CoinSubClass; import 'package:komodo_defi_types/src/assets/asset_symbol.dart'; import 'package:rational/rational.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; import 'package:web_dex/model/authorize_mode.dart'; import 'package:web_dex/model/cex_price.dart'; @@ -12,6 +15,56 @@ import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_type.dart'; import 'package:web_dex/views/dex/simple/form/tables/table_utils.dart'; +class _TableTestCoinsRepo implements CoinsRepo { + _TableTestCoinsRepo(this._byAbbr); + + final Map _byAbbr; + + @override + Coin? getCoin(String abbr) => _byAbbr[abbr]; + + @override + double? getUsdPriceForAmount(num amount, String coinAbbr) => 0; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _UnrestrictedTradingStatusBloc implements TradingStatusBloc { + @override + TradingStatusState get state => TradingStatusLoadSuccess(); + + @override + Stream get stream => + Stream.value(state).asBroadcastStream(); + + @override + Future close() async {} + + @override + void add(TradingStatusEvent event) {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Widget _wrapForTableUtilsTest({ + required Map coinsForRepo, + required Widget child, +}) { + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value( + value: _TableTestCoinsRepo(coinsForRepo), + ), + ], + child: BlocProvider.value( + value: _UnrestrictedTradingStatusBloc(), + child: child, + ), + ); +} + Coin _buildCoin( String abbr, { double usdPrice = 0, @@ -75,7 +128,7 @@ void main() { final btc = _buildCoin('BTC', usdPrice: 30_000); final kmd = _buildCoin('KMD', usdPrice: 1); final coins = {'BTC': btc, 'KMD': kmd}; - final coinLookup = (String abbr) => coins[abbr]; + Coin? coinLookup(String abbr) => coins[abbr]; final orders = >{ 'BTC-KMD': [_buildOrder('BTC', 1)], @@ -91,15 +144,18 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Builder( - builder: (context) { - caches = buildOrderCoinCaches( - context, - orders, - coinLookup: coinLookup, - ); - return const SizedBox.shrink(); - }, + home: _wrapForTableUtilsTest( + coinsForRepo: coins, + child: Builder( + builder: (context) { + caches = buildOrderCoinCaches( + context, + orders, + coinLookup: coinLookup, + ); + return const SizedBox.shrink(); + }, + ), ), ), ); @@ -119,7 +175,7 @@ void main() { final kmd = _buildCoin('KMD', usdPrice: 1, walletOnly: true); final tbtc = _buildCoin('TBTC', usdPrice: 25_000, isTestCoin: true); final coins = {'BTC': btc, 'KMD': kmd, 'TBTC': tbtc}; - final coinLookup = (String abbr) => coins[abbr]; + Coin? coinLookup(String abbr) => coins[abbr]; final orders = >{ 'BTC-KMD': [_buildOrder('BTC', 1)], @@ -131,18 +187,21 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Builder( - builder: (context) { - sorted = prepareOrdersForTable( - context, - orders, - null, - AuthorizeMode.noLogin, - testCoinsEnabled: false, - coinLookup: coinLookup, - ); - return const SizedBox.shrink(); - }, + home: _wrapForTableUtilsTest( + coinsForRepo: coins, + child: Builder( + builder: (context) { + sorted = prepareOrdersForTable( + context, + orders, + null, + AuthorizeMode.noLogin, + testCoinsEnabled: false, + coinLookup: coinLookup, + ); + return const SizedBox.shrink(); + }, + ), ), ), ); @@ -204,17 +263,20 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Builder( - builder: (context) { - prepareOrdersForTable( - context, - orders, - null, - AuthorizeMode.noLogin, - coinLookup: optimisedLookup, - ); - return const SizedBox.shrink(); - }, + home: _wrapForTableUtilsTest( + coinsForRepo: coins, + child: Builder( + builder: (context) { + prepareOrdersForTable( + context, + orders, + null, + AuthorizeMode.noLogin, + coinLookup: optimisedLookup, + ); + return const SizedBox.shrink(); + }, + ), ), ), ); diff --git a/test_units/tests/wallet/coin_details/coin_details_balance_confirmation_controller_test.dart b/test_units/tests/wallet/coin_details/coin_details_balance_confirmation_controller_test.dart new file mode 100644 index 0000000000..5ea1c5baac --- /dev/null +++ b/test_units/tests/wallet/coin_details/coin_details_balance_confirmation_controller_test.dart @@ -0,0 +1,140 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_details_balance_confirmation_controller.dart'; + +BalanceInfo _balance(int amount) { + final value = Decimal.fromInt(amount); + return BalanceInfo(total: value, spendable: value, unspendable: Decimal.zero); +} + +Future _drainAsyncQueue([int iterations = 10]) async { + for (var i = 0; i < iterations; i++) { + await Future.delayed(Duration.zero); + } +} + +void testCoinDetailsBalanceConfirmationController() { + group('CoinDetailsBalanceConfirmationController', () { + test( + 'keeps cached startup balance unconfirmed until bootstrap succeeds', + () async { + int fetchCalls = 0; + final controller = CoinDetailsBalanceConfirmationController( + initialBalance: _balance(0), + fetchConfirmedBalance: () async { + fetchCalls += 1; + return _balance(12); + }, + ); + + expect(controller.isConfirmed, isFalse); + expect(controller.latestBalance?.spendable, Decimal.zero); + + await controller.bootstrap(); + + expect(fetchCalls, 1); + expect(controller.isConfirmed, isTrue); + expect(controller.latestBalance?.spendable, Decimal.fromInt(12)); + }, + ); + + test( + 'pre-bootstrap stream update stays unconfirmed even when cached value is non-zero', + () { + final controller = CoinDetailsBalanceConfirmationController( + fetchConfirmedBalance: () async => _balance(0), + ); + + expect(controller.isConfirmed, isFalse); + + controller.onStreamBalance(_balance(7)); + + expect(controller.isConfirmed, isFalse); + expect(controller.latestBalance?.spendable, Decimal.fromInt(7)); + }, + ); + + test( + 'stream update confirms balance after a bootstrap attempt completes', + () async { + final controller = CoinDetailsBalanceConfirmationController( + fetchConfirmedBalance: () async { + throw StateError('temporary bootstrap failure'); + }, + ); + + expect(controller.isConfirmed, isFalse); + + await controller.bootstrap(); + controller.onStreamBalance(_balance(7)); + + expect(controller.isConfirmed, isTrue); + expect(controller.latestBalance?.spendable, Decimal.fromInt(7)); + }, + ); + + test('bootstrap failures trigger bounded startup retries', () async { + int attempts = 0; + final controller = CoinDetailsBalanceConfirmationController( + fetchConfirmedBalance: () async { + attempts += 1; + throw StateError('temporary startup issue'); + }, + maxStartupRetries: 2, + retryBackoffBase: Duration.zero, + ); + + await controller.bootstrap(); + await _drainAsyncQueue(); + + expect(attempts, 3, reason: 'Initial bootstrap + 2 retries'); + expect(controller.startupRetryAttempts, 2); + expect(controller.isConfirmed, isFalse); + }); + + test('startup stream errors trigger bounded bootstrap retries', () async { + int attempts = 0; + final controller = CoinDetailsBalanceConfirmationController( + fetchConfirmedBalance: () async { + attempts += 1; + throw StateError('temporary startup issue'); + }, + maxStartupRetries: 2, + retryBackoffBase: Duration.zero, + ); + + await controller.bootstrap(); + await _drainAsyncQueue(); + await controller.onStartupStreamError(); + await _drainAsyncQueue(); + + expect(attempts, 3, reason: 'Initial bootstrap + 2 retries'); + expect(controller.startupRetryAttempts, 2); + expect(controller.isConfirmed, isFalse); + }); + + test('dispose turns startup paths into no-ops', () async { + int fetchCalls = 0; + final controller = CoinDetailsBalanceConfirmationController( + fetchConfirmedBalance: () async { + fetchCalls += 1; + return _balance(1); + }, + ); + + controller.dispose(); + await controller.bootstrap(); + await controller.onStartupStreamError(); + controller.onStreamBalance(_balance(9)); + + expect(fetchCalls, 0); + expect(controller.isConfirmed, isFalse); + expect(controller.latestBalance, isNull); + }); + }); +} + +void main() { + testCoinDetailsBalanceConfirmationController(); +} diff --git a/test_units/tests/wallet/coin_details/coin_details_balance_content_test.dart b/test_units/tests/wallet/coin_details/coin_details_balance_content_test.dart new file mode 100644 index 0000000000..e9b366f6c4 --- /dev/null +++ b/test_units/tests/wallet/coin_details/coin_details_balance_content_test.dart @@ -0,0 +1,67 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_details_info.dart'; + +import '../../utils/test_util.dart'; + +BalanceInfo _balance(int amount) { + final value = Decimal.fromInt(amount); + return BalanceInfo(total: value, spendable: value, unspendable: Decimal.zero); +} + +Widget _buildTestWidget({ + required bool isConfirmed, + required BalanceInfo? latestBalance, +}) { + return MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: Size(1280, 800)), + child: Builder( + builder: (context) { + updateScreenType(context); + return Scaffold( + body: CoinDetailsBalanceContent( + coin: setCoin(coinAbbr: 'TRX'), + hideBalances: false, + isConfirmed: isConfirmed, + latestBalance: latestBalance, + fiatBalance: const Text('fiat-probe'), + ), + ); + }, + ), + ), + ); +} + +void testCoinDetailsBalanceContent() { + group('CoinDetailsBalanceContent', () { + testWidgets( + 'desktop ghost state suppresses fiat balance until confirmation', + (tester) async { + await tester.pumpWidget( + _buildTestWidget(isConfirmed: false, latestBalance: _balance(5)), + ); + + expect(find.byKey(const Key('coin-details-balance')), findsOneWidget); + expect(find.text('fiat-probe'), findsNothing); + + await tester.pumpWidget( + _buildTestWidget(isConfirmed: true, latestBalance: _balance(5)), + ); + + expect(find.text('fiat-probe'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 12)); + }, + ); + }); +} + +void main() { + testCoinDetailsBalanceContent(); +} diff --git a/test_units/tests/wallet/coin_details/coin_details_test_harness.dart b/test_units/tests/wallet/coin_details/coin_details_test_harness.dart new file mode 100644 index 0000000000..7b1d662a13 --- /dev/null +++ b/test_units/tests/wallet/coin_details/coin_details_test_harness.dart @@ -0,0 +1,46 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_type.dart'; + +import '../../utils/test_util.dart'; + +Coin buildTestCoin({String abbr = 'KMD', CoinType type = CoinType.smartChain}) { + final coin = setCoin(coinAbbr: abbr); + return coin.copyWith(type: type); +} + +Transaction buildTestTransaction({ + required AssetId assetId, + String txHash = 'tx-hash-1', + Decimal? netChange, + int confirmations = 0, + int blockHeight = 0, + FeeInfo? fee, + String? memo, +}) { + return Transaction( + id: 'tx-id-1', + internalId: 'tx-internal-1', + assetId: assetId, + balanceChanges: BalanceChanges( + netChange: netChange ?? Decimal.parse('-1.0'), + receivedByMe: Decimal.zero, + spentByMe: Decimal.one, + totalAmount: Decimal.one, + ), + timestamp: DateTime.utc(2025, 1, 1, 0, 0, 0), + confirmations: confirmations, + blockHeight: blockHeight, + from: const ['from-address'], + to: const ['to-address'], + txHash: txHash, + fee: fee, + memo: memo, + ); +} + +Widget wrapWithMaterial(Widget child) { + return MaterialApp(home: Scaffold(body: child)); +} diff --git a/test_units/tests/wallet/coin_details/kmd_rewards_logic_test.dart b/test_units/tests/wallet/coin_details/kmd_rewards_logic_test.dart new file mode 100644 index 0000000000..fe66eaa7f6 --- /dev/null +++ b/test_units/tests/wallet/coin_details/kmd_rewards_logic_test.dart @@ -0,0 +1,273 @@ +// ignore_for_file: use_build_context_synchronously + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/blocs/kmd_rewards_bloc.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/bloc_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/kmd_rewards_info/kmd_reward_item.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/kmd_rewards_info/kmd_rewards_info_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_request.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/model/withdraw_details/fee_details.dart'; +import 'package:web_dex/model/withdraw_details/withdraw_details.dart'; + +import 'coin_details_test_harness.dart'; + +class _FakeCoinsRepo implements CoinsRepo { + _FakeCoinsRepo({this.coin, this.withdrawResult}); + + final Coin? coin; + final BlocResponse? withdrawResult; + + @override + Coin? getCoin(String _) => coin; + + @override + Future> withdraw( + WithdrawRequest _, + ) async { + return withdrawResult ?? + BlocResponse(error: TextError(error: 'withdraw not configured')); + } + + @override + double? getUsdPriceForAmount(num amount, String coinAbbr) => 0; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeMm2Api implements Mm2Api { + _FakeMm2Api({this.rewards, this.sendResponse}); + + final List? rewards; + final SendRawTransactionResponse? sendResponse; + + @override + Future?> getRewardsInfo(KmdRewardsInfoRequest _) async { + if (rewards == null) { + return null; + } + + return { + 'result': rewards! + .map( + (r) => { + 'tx_hash': r.txHash, + 'height': r.height, + 'output_index': r.outputIndex, + 'amount': r.amount, + 'locktime': r.lockTime, + 'accrued_rewards': r.reward == null + ? {'NotAccruedReason': 'OneHourNotPassedYet'} + : {'Accrued': '${r.reward!}'}, + 'accrue_start_at': r.accrueStartAt, + 'accrue_stop_at': r.accrueStopAt, + }, + ) + .toList(), + }; + } + + @override + Future sendRawTransaction( + SendRawTransactionRequest _, + ) async { + return sendResponse ?? + SendRawTransactionResponse( + txHash: null, + error: TextError(error: 'broadcast failed'), + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +WithdrawDetails _withdrawDetails({ + String txHex = 'signed-hex', + String myBalanceChange = '1.5', +}) { + return WithdrawDetails( + txHex: txHex, + txHash: 'tx-hash', + from: const ['from'], + to: const ['to'], + totalAmount: '1.5', + spentByMe: '0', + receivedByMe: '1.5', + myBalanceChange: myBalanceChange, + blockHeight: 1, + timestamp: 1, + feeDetails: FeeDetails.empty(), + coin: 'KMD', + internalId: 'internal-1', + ); +} + +void testKmdRewardsLogic() { + group('KmdRewardsBloc', () { + testWidgets('claim returns error when no KMD coin is active', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + context = ctx; + return const SizedBox.shrink(); + }, + ), + ), + ); + final bloc = KmdRewardsBloc(_FakeCoinsRepo(coin: null), _FakeMm2Api()); + + final response = await bloc.claim(context); + + expect(response.error, isNotNull); + expect(response.result, isNull); + }); + + testWidgets('claim returns error when withdraw fails', (tester) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + context = ctx; + return const SizedBox.shrink(); + }, + ), + ), + ); + final coin = buildTestCoin(abbr: 'KMD'); + coin.address = 'R-address'; + + final bloc = KmdRewardsBloc( + _FakeCoinsRepo( + coin: coin, + withdrawResult: BlocResponse( + error: TextError(error: 'withdraw failed'), + ), + ), + _FakeMm2Api(), + ); + + final response = await bloc.claim(context); + + expect(response.error, isNotNull); + expect(response.result, isNull); + }); + + testWidgets('claim returns error when txHex is missing', (tester) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + context = ctx; + return const SizedBox.shrink(); + }, + ), + ), + ); + final coin = buildTestCoin(abbr: 'KMD'); + coin.address = 'R-address'; + + final bloc = KmdRewardsBloc( + _FakeCoinsRepo( + coin: coin, + withdrawResult: BlocResponse(result: _withdrawDetails(txHex: '')), + ), + _FakeMm2Api(), + ); + + final response = await bloc.claim(context); + + expect(response.error, isNotNull); + expect(response.result, isNull); + }); + + testWidgets('claim succeeds when withdraw and broadcast succeed', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + context = ctx; + return const SizedBox.shrink(); + }, + ), + ), + ); + final coin = buildTestCoin(abbr: 'KMD'); + coin.address = 'R-address'; + + final bloc = KmdRewardsBloc( + _FakeCoinsRepo( + coin: coin, + withdrawResult: BlocResponse(result: _withdrawDetails()), + ), + _FakeMm2Api(sendResponse: SendRawTransactionResponse(txHash: 'hash-1')), + ); + + final response = await bloc.claim(context); + + expect(response.error, isNull); + expect(response.result, '1.5'); + }); + + test('getInfo returns empty list when API returns no result', () async { + final bloc = KmdRewardsBloc(_FakeCoinsRepo(), _FakeMm2Api(rewards: null)); + + final info = await bloc.getInfo(); + + expect(info, isEmpty); + }); + + testWidgets('getTotal returns parsed total from withdraw response', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + context = ctx; + return const SizedBox.shrink(); + }, + ), + ), + ); + final coin = buildTestCoin(abbr: 'KMD'); + coin.address = 'R-address'; + + final bloc = KmdRewardsBloc( + _FakeCoinsRepo( + coin: coin, + withdrawResult: BlocResponse( + result: _withdrawDetails(myBalanceChange: '2.25'), + ), + ), + _FakeMm2Api(), + ); + + final total = await bloc.getTotal(context); + + expect(total, 2.25); + }); + }); +} + +void main() { + testKmdRewardsLogic(); +} diff --git a/test_units/tests/wallet/coin_details/receive_address_faucet_widget_test.dart b/test_units/tests/wallet/coin_details/receive_address_faucet_widget_test.dart new file mode 100644 index 0000000000..0283099f43 --- /dev/null +++ b/test_units/tests/wallet/coin_details/receive_address_faucet_widget_test.dart @@ -0,0 +1,100 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/faucet_button/faucet_button_bloc.dart'; +import 'package:web_dex/bloc/faucet_button/faucet_button_event.dart'; +import 'package:web_dex/bloc/faucet_button/faucet_button_state.dart'; +import 'package:web_dex/views/wallet/coin_details/faucet/faucet_button.dart'; + +class _FakeFaucetBloc extends Cubit implements FaucetBloc { + _FakeFaucetBloc(super.initialState); + + FaucetEvent? lastEvent; + + @override + void add(FaucetEvent event) { + lastEvent = event; + if (event is FaucetRequested) { + emit(FaucetRequestInProgress(address: event.address)); + } + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +PubkeyInfo _address(String value) { + return PubkeyInfo( + address: value, + derivationPath: "m/44'/141'/0'/0/0", + chain: 'external', + balance: BalanceInfo( + total: Decimal.one, + spendable: Decimal.one, + unspendable: Decimal.zero, + ), + coinTicker: 'KMD', + ); +} + +void testReceiveAddressFaucetWidgets() { + group('Receive/address/faucet widgets', () { + testWidgets('faucet button dispatches request for selected address', ( + tester, + ) async { + final bloc = _FakeFaucetBloc(const FaucetInitial()); + addTearDown(bloc.close); + final address = _address('R-test-address'); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: bloc, + child: Scaffold( + body: FaucetButton(coinAbbr: 'KMD', address: address), + ), + ), + ), + ); + + await tester.tap(find.byType(UiPrimaryButton)); + await tester.pump(); + + expect(bloc.lastEvent, isA()); + final event = bloc.lastEvent! as FaucetRequested; + expect(event.coinAbbr, 'KMD'); + expect(event.address, address.address); + }); + + testWidgets('faucet button disabled while request pending', (tester) async { + final address = _address('R-test-address'); + final bloc = _FakeFaucetBloc( + FaucetRequestInProgress(address: address.address), + ); + addTearDown(bloc.close); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: bloc, + child: Scaffold( + body: FaucetButton(coinAbbr: 'KMD', address: address), + ), + ), + ), + ); + + final button = tester.widget( + find.byType(UiPrimaryButton), + ); + expect(button.onPressed, isNull); + }); + }); +} + +void main() { + testReceiveAddressFaucetWidgets(); +} diff --git a/test_units/tests/wallet/coin_details/rewards_widget_test.dart b/test_units/tests/wallet/coin_details/rewards_widget_test.dart new file mode 100644 index 0000000000..0de3207861 --- /dev/null +++ b/test_units/tests/wallet/coin_details/rewards_widget_test.dart @@ -0,0 +1,155 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/blocs/kmd_rewards_bloc.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/bloc_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/kmd_rewards_info/kmd_reward_item.dart'; +import 'package:web_dex/views/wallet/coin_details/rewards/kmd_rewards_info.dart'; + +import 'coin_details_test_harness.dart'; + +class _FakeCoinsRepo implements CoinsRepo { + _FakeCoinsRepo(); + + @override + double? getUsdPriceForAmount(num amount, String coinAbbr) => 0; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeKmdRewardsBloc implements KmdRewardsBloc { + _FakeKmdRewardsBloc({required this.totalFuture, required this.infoFuture}); + + final Future totalFuture; + final Future> infoFuture; + + @override + Future getTotal(BuildContext context) => totalFuture; + + @override + Future> getInfo() => infoFuture; + + @override + Future> claim(BuildContext context) async => + BlocResponse(result: '0'); + + @override + void dispose() {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeAuthBloc extends Cubit implements AuthBloc { + _FakeAuthBloc() : super(AuthBlocState.initial()); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Widget _buildWidget({ + required KmdRewardsBloc rewardsBloc, + required CoinsRepo coinsRepo, +}) { + final coin = buildTestCoin(abbr: 'KMD'); + coin.address = 'R-address'; + + return MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: Size(1600, 1200)), + child: Builder( + builder: (context) { + updateScreenType(context); + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: coinsRepo), + RepositoryProvider.value(value: rewardsBloc), + ], + child: BlocProvider( + create: (_) => _FakeAuthBloc(), + child: Scaffold( + body: KmdRewardsInfo( + coin: coin, + onSuccess: (_, __) {}, + onBackButtonPressed: () {}, + ), + ), + ), + ); + }, + ), + ), + ); +} + +void testRewardsWidgets() { + group('KmdRewardsInfo widget', () { + testWidgets('rewards page shows spinner before data load', (tester) async { + final totalCompleter = Completer(); + final infoCompleter = Completer>(); + final rewardsBloc = _FakeKmdRewardsBloc( + totalFuture: totalCompleter.future, + infoFuture: infoCompleter.future, + ); + + await tester.pumpWidget( + _buildWidget(rewardsBloc: rewardsBloc, coinsRepo: _FakeCoinsRepo()), + ); + + expect(find.byType(UiSpinnerList), findsOneWidget); + }); + + testWidgets('rewards page shows no rewards when empty', (tester) async { + final rewardsBloc = _FakeKmdRewardsBloc( + totalFuture: Future.value(0), + infoFuture: Future>.value(const []), + ); + + await tester.pumpWidget( + _buildWidget(rewardsBloc: rewardsBloc, coinsRepo: _FakeCoinsRepo()), + ); + await tester.pumpAndSettle(); + + expect(find.text('noRewards'), findsOneWidget); + }); + + testWidgets('rewards page renders reward items when present', ( + tester, + ) async { + final rewardsBloc = _FakeKmdRewardsBloc( + totalFuture: Future.value(1.2), + infoFuture: Future>.value([ + KmdRewardItem( + txHash: 'hash-1', + height: 1, + outputIndex: 0, + amount: '10', + lockTime: 1, + reward: 0.1, + accrueStartAt: 1, + accrueStopAt: 2, + ), + ]), + ); + + await tester.pumpWidget( + _buildWidget(rewardsBloc: rewardsBloc, coinsRepo: _FakeCoinsRepo()), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('reward-claim-button')), findsOneWidget); + }); + }); +} + +void main() { + testRewardsWidgets(); +} diff --git a/test_units/tests/wallet/coin_details/transaction_details_logic_test.dart b/test_units/tests/wallet/coin_details/transaction_details_logic_test.dart new file mode 100644 index 0000000000..b42f999073 --- /dev/null +++ b/test_units/tests/wallet/coin_details/transaction_details_logic_test.dart @@ -0,0 +1,226 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/wallet/coin_details/transactions/transaction_details.dart'; + +import 'coin_details_test_harness.dart'; + +Future _disposeAnimatedWidgets(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 3)); +} + +void testTransactionDetailsLogic() { + group('TransactionDetails logic', () { + testWidgets('confirmations label returns count when confirmed', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction( + assetId: coin.id, + confirmations: 3, + blockHeight: 100, + ); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.text('3'), findsOneWidget); + await _disposeAnimatedWidgets(tester); + }); + + testWidgets( + 'confirmations label returns zero when block height present without confirmations', + (tester) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction( + assetId: coin.id, + confirmations: 0, + blockHeight: 10, + ); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.text('0'), findsOneWidget); + await _disposeAnimatedWidgets(tester); + }, + ); + + testWidgets('confirmations label returns in-progress when pending', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction( + assetId: coin.id, + confirmations: 0, + blockHeight: 0, + ); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.text(LocaleKeys.inProgress), findsOneWidget); + await _disposeAnimatedWidgets(tester); + }); + + testWidgets('block height label returns unknown when block is zero', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction(assetId: coin.id, blockHeight: 0); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.text(LocaleKeys.unknown), findsOneWidget); + await _disposeAnimatedWidgets(tester); + }); + + testWidgets('balance change formats plus sign for incoming', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction( + assetId: coin.id, + netChange: Decimal.parse('1.25'), + ); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.textContaining('+'), findsOneWidget); + await _disposeAnimatedWidgets(tester); + }); + + testWidgets('balance change formats minus sign for outgoing', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction( + assetId: coin.id, + netChange: Decimal.parse('-1.25'), + ); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.textContaining('-'), findsWidgets); + await _disposeAnimatedWidgets(tester); + }); + + testWidgets('balance change renders fiat amount from resolver', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction(assetId: coin.id); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.textContaining(r'($0'), findsWidgets); + await _disposeAnimatedWidgets(tester); + }); + + testWidgets('fee section renders em dash when fee is absent', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction(assetId: coin.id, fee: null); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.text('—'), findsOneWidget); + await _disposeAnimatedWidgets(tester); + }); + + testWidgets('memo section hides when empty', (tester) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction(assetId: coin.id, memo: ''); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.text('${LocaleKeys.memo}: '), findsNothing); + await _disposeAnimatedWidgets(tester); + }); + }); +} + +void main() { + testTransactionDetailsLogic(); +} diff --git a/test_units/tests/wallet/coin_details/transaction_views_widget_test.dart b/test_units/tests/wallet/coin_details/transaction_views_widget_test.dart new file mode 100644 index 0000000000..ebcf3f1144 --- /dev/null +++ b/test_units/tests/wallet/coin_details/transaction_views_widget_test.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/transaction_history/transaction_history_bloc.dart'; +import 'package:web_dex/bloc/transaction_history/transaction_history_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/views/wallet/coin_details/transactions/transaction_details.dart'; +import 'package:web_dex/views/wallet/coin_details/transactions/transaction_table.dart'; + +import 'coin_details_test_harness.dart'; + +class _FakeTransactionHistoryBloc extends Cubit + implements TransactionHistoryBloc { + _FakeTransactionHistoryBloc(super.initialState); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Future _disposeAnimatedWidgets(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 3)); +} + +void testTransactionViewsWidgets() { + group('Transaction views widgets', () { + testWidgets('transaction table shows loading spinner while fetching', ( + tester, + ) async { + final coin = buildTestCoin(type: CoinType.smartChain); + final bloc = _FakeTransactionHistoryBloc( + const TransactionHistoryState( + transactions: [], + loading: true, + error: null, + ), + ); + addTearDown(bloc.close); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: bloc, + child: CustomScrollView( + slivers: [ + TransactionTable( + coin: coin, + selectedTransaction: null, + setTransaction: (_) {}, + ), + ], + ), + ), + ), + ); + + expect(find.byType(UiSpinnerList), findsOneWidget); + }); + + testWidgets('transaction table shows empty state without items', ( + tester, + ) async { + final coin = buildTestCoin(type: CoinType.smartChain); + final bloc = _FakeTransactionHistoryBloc( + const TransactionHistoryState.initial(), + ); + addTearDown(bloc.close); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: bloc, + child: CustomScrollView( + slivers: [ + TransactionTable( + coin: coin, + selectedTransaction: null, + setTransaction: (_) {}, + ), + ], + ), + ), + ), + ); + + expect(find.text(LocaleKeys.noTransactionsTitle), findsOneWidget); + }); + + testWidgets('transaction table shows error state on failure', ( + tester, + ) async { + final coin = buildTestCoin(type: CoinType.smartChain); + final bloc = _FakeTransactionHistoryBloc( + TransactionHistoryState( + transactions: const [], + loading: false, + error: TextError(error: 'network failed'), + ), + ); + addTearDown(bloc.close); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: bloc, + child: CustomScrollView( + slivers: [ + TransactionTable( + coin: coin, + selectedTransaction: null, + setTransaction: (_) {}, + ), + ], + ), + ), + ), + ); + + expect( + find.textContaining(LocaleKeys.connectionToServersFailing), + findsOneWidget, + ); + }); + + testWidgets('transaction details done button calls onClose', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction(assetId: coin.id); + var didClose = false; + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () => didClose = true, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + await tester.tap(find.text(LocaleKeys.done).first); + await tester.pump(); + + expect(didClose, isTrue); + await _disposeAnimatedWidgets(tester); + }); + + testWidgets('transaction details view on explorer uses tx hash', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction(assetId: coin.id, txHash: 'abc-hash'); + String? launched; + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + onLaunchExplorer: (url) => launched = url, + ), + ), + ); + + await tester.tap(find.text(LocaleKeys.viewOnExplorer).first); + await tester.pump(); + + expect(launched, isNotNull); + expect(launched, contains('abc-hash')); + await _disposeAnimatedWidgets(tester); + }); + }); +} + +void main() { + testTransactionViewsWidgets(); +} diff --git a/test_units/tests/wallet/coin_details/withdraw_form_bloc_test.dart b/test_units/tests/wallet/coin_details/withdraw_form_bloc_test.dart new file mode 100644 index 0000000000..1a80f683c2 --- /dev/null +++ b/test_units/tests/wallet/coin_details/withdraw_form_bloc_test.dart @@ -0,0 +1,1153 @@ +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_sdk/src/pubkeys/pubkey_manager.dart'; +import 'package:komodo_defi_sdk/src/withdrawals/withdrawal_manager.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/model/wallet.dart'; + +Map _utxoConfig({ + String coin = 'KMD', + String name = 'Komodo', +}) => { + 'coin': coin, + 'type': 'UTXO', + 'name': name, + 'fname': name, + 'wallet_only': false, + 'mm2': 1, + 'chain_id': 141, + 'decimals': 8, + 'is_testnet': false, + 'required_confirmations': 1, + 'derivation_path': "m/44'/141'/0'", + 'protocol': {'type': 'UTXO'}, +}; + +Map _trxConfig() => { + 'coin': 'TRX', + 'type': 'TRX', + 'name': 'TRON', + 'fname': 'TRON', + 'wallet_only': true, + 'mm2': 1, + 'decimals': 6, + 'required_confirmations': 1, + 'derivation_path': "m/44'/195'", + 'protocol': { + 'type': 'TRX', + 'protocol_data': {'network': 'Mainnet'}, + }, + 'nodes': >[], +}; + +Map _siaConfig() => { + 'coin': 'SC', + 'type': 'SIA', + 'name': 'Siacoin', + 'fname': 'Siacoin', + 'wallet_only': false, + 'mm2': 1, + 'chain_id': 2024, + 'decimals': 24, + 'required_confirmations': 1, + 'nodes': const [ + {'url': 'https://api.siascan.com/wallet/api'}, + ], +}; + +BalanceInfo _balance(String amount) { + final value = Decimal.parse(amount); + return BalanceInfo(total: value, spendable: value, unspendable: Decimal.zero); +} + +Asset _assetFromConfig(Map config) => + Asset.fromJson(config, knownIds: const {}); + +PubkeyInfo _pubkeyForAsset( + Asset asset, { + String address = 'source-address', + String balance = '5', +}) { + return PubkeyInfo( + address: address, + derivationPath: "m/44'/141'/0'/0/0", + chain: 'external', + balance: _balance(balance), + coinTicker: asset.id.id, + ); +} + +AssetPubkeys _assetPubkeys( + Asset asset, { + String address = 'source-address', + String balance = '5', +}) { + return AssetPubkeys( + assetId: asset.id, + keys: [_pubkeyForAsset(asset, address: address, balance: balance)], + availableAddressesCount: 1, + syncStatus: SyncStatusEnum.success, + ); +} + +WithdrawalPreview _utxoPreview({ + required String assetId, + required String txHash, + required String toAddress, + required int timestamp, +}) { + return WithdrawResult( + txHex: 'signed-$txHash', + txHash: txHash, + from: const ['source-address'], + to: [toAddress], + balanceChanges: BalanceChanges( + netChange: Decimal.fromInt(-1), + receivedByMe: Decimal.zero, + spentByMe: Decimal.one, + totalAmount: Decimal.one, + ), + blockHeight: 1, + timestamp: timestamp, + fee: FeeInfo.utxoFixed(coin: assetId, amount: Decimal.parse('0.0001')), + coin: assetId, + ); +} + +WithdrawalPreview _tronPreview({ + required String txHash, + required String toAddress, + required int timestamp, +}) { + return WithdrawResult( + txHex: 'signed-$txHash', + txHash: txHash, + from: const ['source-address'], + to: [toAddress], + balanceChanges: BalanceChanges( + netChange: Decimal.fromInt(-1), + receivedByMe: Decimal.zero, + spentByMe: Decimal.one, + totalAmount: Decimal.one, + ), + blockHeight: 1, + timestamp: timestamp, + fee: FeeInfo.tron( + coin: 'TRX', + bandwidthUsed: 1, + energyUsed: 1, + bandwidthFee: Decimal.zero, + energyFee: Decimal.parse('0.1'), + totalFeeAmount: Decimal.parse('0.1'), + ), + coin: 'TRX', + ); +} + +WithdrawalFeeOptions _utxoFeeOptions(String assetId) { + WithdrawalFeeOption option(WithdrawalFeeLevel priority, String amount) { + return WithdrawalFeeOption( + priority: priority, + feeInfo: FeeInfo.utxoFixed(coin: assetId, amount: Decimal.parse(amount)), + ); + } + + return WithdrawalFeeOptions( + coin: assetId, + low: option(WithdrawalFeeLevel.low, '0.00001'), + medium: option(WithdrawalFeeLevel.medium, '0.00002'), + high: option(WithdrawalFeeLevel.high, '0.00003'), + ); +} + +WithdrawalResult _resultFromPreview(WithdrawalPreview preview) { + return WithdrawalResult( + txHash: preview.txHash, + balanceChanges: preview.balanceChanges, + coin: preview.coin, + toAddress: preview.to.first, + fee: preview.fee, + ); +} + +Future _flush() => Future.delayed(Duration.zero); + +Future _awaitSourceSelection(WithdrawFormBloc bloc) async { + if (bloc.state.selectedSourceAddress != null) { + return; + } + await bloc.stream.firstWhere((state) => state.selectedSourceAddress != null); +} + +Future _primeFillState( + WithdrawFormBloc bloc, { + required String recipient, + required String amount, +}) async { + await _awaitSourceSelection(bloc); + + final recipientState = bloc.stream.firstWhere( + (state) => state.recipientAddress == recipient, + ); + bloc.add(WithdrawFormRecipientChanged(recipient)); + await recipientState; + + final amountState = bloc.stream.firstWhere((state) => state.amount == amount); + bloc.add(WithdrawFormAmountChanged(amount)); + await amountState; +} + +class _FakeAddressOperations implements AddressOperations { + _FakeAddressOperations({this.validateAddressHandler}); + + final Future Function({ + required Asset asset, + required String address, + })? + validateAddressHandler; + + @override + Future validateAddress({ + required Asset asset, + required String address, + }) { + return validateAddressHandler?.call(asset: asset, address: address) ?? + Future.value( + AddressValidation(isValid: true, address: address, asset: asset), + ); + } + + @override + Future convertFormat({ + required Asset asset, + required String address, + required AddressFormat format, + }) { + return Future.value( + AddressConversionResult( + originalAddress: address, + convertedAddress: address, + asset: asset, + format: format, + ), + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeWithdrawalManager implements WithdrawalManager { + _FakeWithdrawalManager({ + required Future Function(WithdrawParameters params) + previewWithdrawalHandler, + Future Function(String assetId)? + getFeeOptionsHandler, + Stream Function( + WithdrawalPreview preview, + String assetId, + )? + executeWithdrawalHandler, + }) : _previewWithdrawalHandler = previewWithdrawalHandler, + _getFeeOptionsHandler = getFeeOptionsHandler, + _executeWithdrawalHandler = executeWithdrawalHandler; + + final Future Function(WithdrawParameters params) + _previewWithdrawalHandler; + final Future Function(String assetId)? + _getFeeOptionsHandler; + final Stream Function( + WithdrawalPreview preview, + String assetId, + )? + _executeWithdrawalHandler; + + int previewCallCount = 0; + int executeCallCount = 0; + final List previewRequests = []; + + @override + Future previewWithdrawal(WithdrawParameters params) async { + previewCallCount += 1; + previewRequests.add(params); + return _previewWithdrawalHandler(params); + } + + @override + Future getFeeOptions(String assetId) async { + return _getFeeOptionsHandler?.call(assetId); + } + + @override + Stream executeWithdrawal( + WithdrawalPreview preview, + String assetId, + ) { + executeCallCount += 1; + return _executeWithdrawalHandler?.call(preview, assetId) ?? + const Stream.empty(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakePubkeyManager implements PubkeyManager { + _FakePubkeyManager(this._pubkeysByAssetId); + + final Map _pubkeysByAssetId; + + @override + AssetPubkeys? lastKnown(AssetId assetId) => _pubkeysByAssetId[assetId]; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeBalanceManager implements BalanceManager { + _FakeBalanceManager(this._balances); + + final Map _balances; + + @override + BalanceInfo? lastKnown(AssetId assetId) => _balances[assetId]; + + @override + Future getBalance(AssetId assetId) async => + _balances[assetId] ?? BalanceInfo.zero(); + + @override + Stream watchBalance( + AssetId assetId, { + bool activateIfNeeded = true, + }) async* { + final balance = _balances[assetId]; + if (balance != null) { + yield balance; + } + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSdk implements KomodoDefiSdk { + _FakeSdk({ + required this.addresses, + required this.withdrawals, + required this.pubkeys, + required this.balances, + }); + + @override + final AddressOperations addresses; + + @override + final WithdrawalManager withdrawals; + + @override + final PubkeyManager pubkeys; + + @override + final BalanceManager balances; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeMm2Api implements Mm2Api { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void testWithdrawFormBloc() { + group('WithdrawFormBloc', () { + test('preview completion uses the original request snapshot', () async { + final asset = _assetFromConfig(_utxoConfig()); + final previewCompleter = Completer(); + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) => previewCompleter.future, + ); + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({asset.id: _assetPubkeys(asset)}), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'recipient-1', amount: '1'); + + final sendingState = bloc.stream.firstWhere((state) => state.isSending); + bloc.add(const WithdrawFormPreviewSubmitted()); + await sendingState; + + bloc.add(const WithdrawFormAmountChanged('2')); + await _flush(); + + previewCompleter.complete( + _utxoPreview( + assetId: asset.id.id, + txHash: 'preview-1', + toAddress: 'recipient-1', + timestamp: 1, + ), + ); + + final confirmState = await bloc.stream.firstWhere( + (state) => + state.step == WithdrawFormStep.confirm && + state.preview?.txHash == 'preview-1', + ); + + expect(withdrawals.previewCallCount, 1); + expect(withdrawals.previewRequests.single.amount, Decimal.one); + expect(confirmState.amount, '1'); + expect(confirmState.recipientAddress, 'recipient-1'); + }); + + test( + 'preview completion preserves concurrent fee option updates', + () async { + final asset = _assetFromConfig(_utxoConfig()); + final feeOptionsCompleter = Completer(); + final previewCompleter = Completer(); + final expectedFeeOptions = _utxoFeeOptions(asset.id.id); + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) => previewCompleter.future, + getFeeOptionsHandler: (_) => feeOptionsCompleter.future, + ); + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({asset.id: _assetPubkeys(asset)}), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + feeOptionsCompleter.complete(expectedFeeOptions); + await _flush(); + + await _primeFillState(bloc, recipient: 'recipient-1', amount: '1'); + + final sendingState = bloc.stream.firstWhere((state) => state.isSending); + bloc.add(const WithdrawFormPreviewSubmitted()); + await sendingState; + + previewCompleter.complete( + _utxoPreview( + assetId: asset.id.id, + txHash: 'preview-1', + toAddress: 'recipient-1', + timestamp: 1, + ), + ); + + final confirmState = await bloc.stream.firstWhere( + (state) => + state.step == WithdrawFormStep.confirm && + state.preview?.txHash == 'preview-1', + ); + + expect(confirmState.feeOptions, expectedFeeOptions); + }, + ); + + test( + 'preview completion survives fee priority defaulting during request', + () async { + final asset = _assetFromConfig(_utxoConfig()); + final feeOptionsCompleter = Completer(); + final previewCompleter = Completer(); + final expectedFeeOptions = _utxoFeeOptions(asset.id.id); + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) => previewCompleter.future, + getFeeOptionsHandler: (_) => feeOptionsCompleter.future, + ); + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({asset.id: _assetPubkeys(asset)}), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'recipient-1', amount: '1'); + + final sendingState = bloc.stream.firstWhere((state) => state.isSending); + bloc.add(const WithdrawFormPreviewSubmitted()); + await sendingState; + + feeOptionsCompleter.complete(expectedFeeOptions); + final feeDefaultedState = await bloc.stream.firstWhere( + (state) => + state.isSending && + state.selectedFeePriority == WithdrawalFeeLevel.medium, + ); + + previewCompleter.complete( + _utxoPreview( + assetId: asset.id.id, + txHash: 'preview-1', + toAddress: 'recipient-1', + timestamp: 1, + ), + ); + + final confirmState = await bloc.stream.firstWhere( + (state) => + state.step == WithdrawFormStep.confirm && + state.preview?.txHash == 'preview-1', + ); + + expect(withdrawals.previewRequests.single.feePriority, isNull); + expect(feeDefaultedState.feeOptions, expectedFeeOptions); + expect(confirmState.feeOptions, expectedFeeOptions); + expect(confirmState.selectedFeePriority, WithdrawalFeeLevel.medium); + }, + ); + + test( + 'stale preview results are discarded after request inputs change', + () async { + final asset = _assetFromConfig(_utxoConfig()); + final previewCompleter = Completer(); + final initialPubkey = PubkeyInfo( + address: 'source-address-1', + derivationPath: "m/44'/141'/0'/0/0", + chain: 'external', + balance: _balance('5'), + coinTicker: asset.id.id, + ); + final updatedPubkey = PubkeyInfo( + address: 'source-address-2', + derivationPath: "m/44'/141'/0'/0/1", + chain: 'external', + balance: _balance('5'), + coinTicker: asset.id.id, + ); + final pubkeysByAssetId = { + asset.id: AssetPubkeys( + assetId: asset.id, + keys: [initialPubkey], + availableAddressesCount: 1, + syncStatus: SyncStatusEnum.success, + ), + }; + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) => previewCompleter.future, + ); + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager(pubkeysByAssetId), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'recipient-1', amount: '1'); + + final sendingState = bloc.stream.firstWhere((state) => state.isSending); + bloc.add(const WithdrawFormPreviewSubmitted()); + await sendingState; + + pubkeysByAssetId[asset.id] = AssetPubkeys( + assetId: asset.id, + keys: [updatedPubkey], + availableAddressesCount: 1, + syncStatus: SyncStatusEnum.success, + ); + bloc.add(const WithdrawFormSourcesLoadRequested()); + await bloc.stream.firstWhere( + (state) => + state.selectedSourceAddress?.derivationPath == + updatedPubkey.derivationPath, + ); + + previewCompleter.complete( + _utxoPreview( + assetId: asset.id.id, + txHash: 'preview-1', + toAddress: 'recipient-1', + timestamp: 1, + ), + ); + + final settledState = await bloc.stream.firstWhere( + (state) => + !state.isSending && + state.selectedSourceAddress?.derivationPath == + updatedPubkey.derivationPath, + ); + + expect( + withdrawals.previewRequests.single.from, + WithdrawalSource.hdDerivationPath(initialPubkey.derivationPath!), + ); + expect(settledState.step, WithdrawFormStep.fill); + expect(settledState.preview, isNull); + expect( + settledState.selectedSourceAddress?.derivationPath, + updatedPubkey.derivationPath, + ); + }, + ); + + test( + 'duplicate preview submissions are dropped while one is running', + () async { + final asset = _assetFromConfig(_utxoConfig()); + final previewCompleter = Completer(); + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) => previewCompleter.future, + ); + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({asset.id: _assetPubkeys(asset)}), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'recipient-1', amount: '1'); + + final sendingState = bloc.stream.firstWhere((state) => state.isSending); + bloc.add(const WithdrawFormPreviewSubmitted()); + bloc.add(const WithdrawFormPreviewSubmitted()); + await sendingState; + await _flush(); + + expect(withdrawals.previewCallCount, 1); + + previewCompleter.complete( + _utxoPreview( + assetId: asset.id.id, + txHash: 'preview-1', + toAddress: 'recipient-1', + timestamp: 1, + ), + ); + + await bloc.stream.firstWhere( + (state) => state.step == WithdrawFormStep.confirm, + ); + }, + ); + + test( + 'recipient validation keeps the latest input when async checks overlap', + () async { + final asset = _assetFromConfig(_utxoConfig()); + final validations = >{ + 'recipient-1': Completer(), + 'recipient-2': Completer(), + }; + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations( + validateAddressHandler: + ({required Asset asset, required String address}) => + validations[address]!.future, + ), + withdrawals: _FakeWithdrawalManager( + previewWithdrawalHandler: (_) async => _utxoPreview( + assetId: asset.id.id, + txHash: 'unused', + toAddress: 'recipient-2', + timestamp: 1, + ), + ), + pubkeys: _FakePubkeyManager({asset.id: _assetPubkeys(asset)}), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _awaitSourceSelection(bloc); + + bloc.add(const WithdrawFormRecipientChanged('recipient-1')); + await bloc.stream.firstWhere( + (state) => state.recipientAddress == 'recipient-1', + ); + + bloc.add(const WithdrawFormRecipientChanged('recipient-2')); + await bloc.stream.firstWhere( + (state) => state.recipientAddress == 'recipient-2', + ); + + validations['recipient-1']!.complete( + AddressValidation( + isValid: false, + address: 'recipient-1', + asset: asset, + invalidReason: 'invalid recipient-1', + ), + ); + await _flush(); + + expect(bloc.state.recipientAddress, 'recipient-2'); + expect(bloc.state.recipientAddressError, isNull); + + validations['recipient-2']!.complete( + AddressValidation( + isValid: true, + address: 'recipient-2', + asset: asset, + ), + ); + await _flush(); + + expect(bloc.state.recipientAddress, 'recipient-2'); + expect(bloc.state.recipientAddressError, isNull); + }, + ); + + test( + 'TRON preview refresh drops duplicate requests and preserves confirm state', + () async { + final asset = _assetFromConfig(_trxConfig()); + final refreshCompleter = Completer(); + final now = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + var previewInvocation = 0; + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) async { + previewInvocation += 1; + if (previewInvocation == 1) { + return _tronPreview( + txHash: 'preview-1', + toAddress: 'tron-recipient', + timestamp: now, + ); + } + + return refreshCompleter.future; + }, + ); + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({ + asset.id: _assetPubkeys(asset, balance: '5'), + }), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'tron-recipient', amount: '1'); + + bloc.add(const WithdrawFormPreviewSubmitted()); + await bloc.stream.firstWhere( + (state) => + state.step == WithdrawFormStep.confirm && + state.preview?.txHash == 'preview-1', + ); + + final refreshingState = bloc.stream.firstWhere( + (state) => state.isPreviewRefreshing, + ); + bloc.add(const WithdrawFormTronPreviewRefreshRequested()); + bloc.add(const WithdrawFormTronPreviewRefreshRequested()); + await refreshingState; + await _flush(); + + expect(withdrawals.previewCallCount, 2); + + refreshCompleter.complete( + _tronPreview( + txHash: 'preview-2', + toAddress: 'tron-recipient', + timestamp: now + 5, + ), + ); + + final refreshedState = await bloc.stream.firstWhere( + (state) => + state.step == WithdrawFormStep.confirm && + !state.isPreviewRefreshing && + state.preview?.txHash == 'preview-2', + ); + + expect(withdrawals.previewCallCount, 2); + expect(refreshedState.amount, '1'); + expect(refreshedState.recipientAddress, 'tron-recipient'); + expect(refreshedState.previewSecondsRemaining, isNotNull); + }, + ); + + test( + 'duplicate submit events are dropped while broadcast is running', + () async { + final asset = _assetFromConfig(_utxoConfig()); + final preview = _utxoPreview( + assetId: asset.id.id, + txHash: 'preview-1', + toAddress: 'recipient-1', + timestamp: 1, + ); + final progressController = StreamController(); + addTearDown(progressController.close); + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) async => preview, + executeWithdrawalHandler: (_, __) => progressController.stream, + ); + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({asset.id: _assetPubkeys(asset)}), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'recipient-1', amount: '1'); + + bloc.add(const WithdrawFormPreviewSubmitted()); + await bloc.stream.firstWhere( + (state) => state.step == WithdrawFormStep.confirm, + ); + + final sendingState = bloc.stream.firstWhere( + (state) => state.step == WithdrawFormStep.confirm && state.isSending, + ); + bloc.add(const WithdrawFormSubmitted()); + bloc.add(const WithdrawFormSubmitted()); + await sendingState; + await _flush(); + + expect(withdrawals.executeCallCount, 1); + + progressController.add( + WithdrawalProgress( + status: WithdrawalStatus.complete, + message: 'done', + withdrawalResult: _resultFromPreview(preview), + ), + ); + + final successState = await bloc.stream.firstWhere( + (state) => state.step == WithdrawFormStep.success, + ); + + expect(withdrawals.executeCallCount, 1); + expect(successState.result?.txHash, 'preview-1'); + }, + ); + + test('submit ignores expired TRON preview until refresh', () async { + final asset = _assetFromConfig(_trxConfig()); + final now = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) async => _tronPreview( + txHash: 'expired-preview', + toAddress: 'tron-recipient', + timestamp: now - 120, + ), + executeWithdrawalHandler: (_, __) async* {}, + ); + + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({ + asset.id: _assetPubkeys(asset, balance: '5'), + }), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'tron-recipient', amount: '1'); + + bloc.add(const WithdrawFormPreviewSubmitted()); + await bloc.stream.firstWhere( + (state) => + state.step == WithdrawFormStep.confirm && state.isPreviewExpired, + ); + + bloc.add(const WithdrawFormSubmitted()); + await _flush(); + + expect(withdrawals.executeCallCount, 0); + expect(bloc.state.step, WithdrawFormStep.confirm); + expect(bloc.state.confirmStepError, isNotNull); + }); + + test('send max recomputes amount when source address changes', () async { + final asset = _assetFromConfig(_utxoConfig()); + final sourceOne = _pubkeyForAsset( + asset, + address: 'source-one', + balance: '5', + ); + final sourceTwo = _pubkeyForAsset( + asset, + address: 'source-two', + balance: '2', + ); + + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: _FakeWithdrawalManager( + previewWithdrawalHandler: (_) async => _utxoPreview( + assetId: asset.id.id, + txHash: 'unused', + toAddress: 'recipient', + timestamp: 1, + ), + ), + pubkeys: _FakePubkeyManager({ + asset.id: AssetPubkeys( + assetId: asset.id, + keys: [sourceOne, sourceTwo], + availableAddressesCount: 2, + syncStatus: SyncStatusEnum.success, + ), + }), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + bloc.add(WithdrawFormSourceChanged(sourceOne)); + await bloc.stream.firstWhere( + (state) => state.selectedSourceAddress?.address == 'source-one', + ); + + bloc.add(const WithdrawFormMaxAmountEnabled(true)); + await bloc.stream.firstWhere( + (state) => state.isMaxAmount && state.amount == '5', + ); + + bloc.add(WithdrawFormSourceChanged(sourceTwo)); + final updated = await bloc.stream.firstWhere( + (state) => + state.selectedSourceAddress?.address == 'source-two' && + state.amount == '2', + ); + + expect(updated.isMaxAmount, isTrue); + }); + + test('preview refresh recovers after expired quote', () async { + final asset = _assetFromConfig(_trxConfig()); + final now = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + var previewCalls = 0; + final refreshedCompleter = Completer(); + + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) async { + previewCalls += 1; + if (previewCalls == 1) { + return _tronPreview( + txHash: 'expired-preview', + toAddress: 'tron-recipient', + timestamp: now - 120, + ); + } + + return refreshedCompleter.future; + }, + ); + + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({ + asset.id: _assetPubkeys(asset, balance: '5'), + }), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'tron-recipient', amount: '1'); + bloc.add(const WithdrawFormPreviewSubmitted()); + await bloc.stream.firstWhere((state) => state.isPreviewExpired); + + final refreshing = bloc.stream.firstWhere( + (state) => state.isPreviewRefreshing, + ); + bloc.add(const WithdrawFormTronPreviewRefreshRequested()); + await refreshing; + + refreshedCompleter.complete( + _tronPreview( + txHash: 'fresh-preview', + toAddress: 'tron-recipient', + timestamp: now + 10, + ), + ); + + final refreshed = await bloc.stream.firstWhere( + (state) => + !state.isPreviewRefreshing && + !state.isPreviewExpired && + state.preview?.txHash == 'fresh-preview', + ); + + expect(withdrawals.previewCallCount, 2); + expect(refreshed.confirmStepError, isNull); + }); + + test('validation maps known sdk errors to user-facing state', () async { + final asset = _assetFromConfig(_utxoConfig()); + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) async => + throw Exception('insufficient gas for transaction'), + ); + + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({asset.id: _assetPubkeys(asset)}), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'recipient-1', amount: '1'); + bloc.add(const WithdrawFormPreviewSubmitted()); + + final errored = await bloc.stream.firstWhere( + (state) => state.previewError != null, + ); + + expect( + errored.previewError!.message, + contains('notEnoughBalanceForGasError'), + ); + }); + + test( + 'SIA preview omits source derivation path in request params', + () async { + final asset = _assetFromConfig(_siaConfig()); + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) async => _utxoPreview( + assetId: asset.id.id, + txHash: 'preview-sia', + toAddress: 'recipient-1', + timestamp: 1, + ), + ); + + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({ + asset.id: _assetPubkeys(asset, balance: '5'), + }), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'recipient-1', amount: '1'); + bloc.add(const WithdrawFormPreviewSubmitted()); + await bloc.stream.firstWhere( + (state) => state.step == WithdrawFormStep.confirm, + ); + + expect(withdrawals.previewRequests.single.from, isNull); + }, + ); + + test('Trezor blocks SIA preview and submit', () async { + final asset = _assetFromConfig(_siaConfig()); + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) async => _utxoPreview( + assetId: asset.id.id, + txHash: 'preview-sia', + toAddress: 'recipient-1', + timestamp: 1, + ), + ); + + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({ + asset.id: _assetPubkeys(asset, balance: '5'), + }), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + walletType: WalletType.trezor, + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'recipient-1', amount: '1'); + + bloc.add(const WithdrawFormPreviewSubmitted()); + final previewBlocked = await bloc.stream.firstWhere( + (state) => state.previewError != null, + ); + + expect(previewBlocked.previewError?.message, contains('SIA is not')); + expect(withdrawals.previewCallCount, 0); + + bloc.add(const WithdrawFormSubmitted()); + final submitBlocked = await bloc.stream.firstWhere( + (state) => state.transactionError != null, + ); + + expect(submitBlocked.transactionError?.message, contains('SIA is not')); + expect(withdrawals.executeCallCount, 0); + }); + }); +} + +void main() { + testWithdrawFormBloc(); +} diff --git a/test_units/tests/wallet/coin_details/withdraw_form_fill_section_test.dart b/test_units/tests/wallet/coin_details/withdraw_form_fill_section_test.dart new file mode 100644 index 0000000000..30f251212d --- /dev/null +++ b/test_units/tests/wallet/coin_details/withdraw_form_fill_section_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/withdraw_form.dart'; + +Map _utxoConfig() => { + 'coin': 'KMD', + 'type': 'UTXO', + 'name': 'Komodo', + 'fname': 'Komodo', + 'wallet_only': false, + 'mm2': 1, + 'chain_id': 141, + 'decimals': 8, + 'is_testnet': false, + 'required_confirmations': 1, + 'derivation_path': "m/44'/141'/0'", + 'protocol': {'type': 'UTXO'}, +}; + +class _FakeWithdrawFormBloc extends Cubit + implements WithdrawFormBloc { + _FakeWithdrawFormBloc(super.initialState); + + final List events = []; + + @override + void add(WithdrawFormEvent event) { + events.add(event); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Widget _buildTestWidget(WithdrawFormBloc bloc) { + return MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: Size(1280, 1200)), + child: Builder( + builder: (context) { + updateScreenType(context); + return BlocProvider.value( + value: bloc, + child: const Scaffold( + body: SingleChildScrollView( + child: WithdrawFormFillSection(suppressPreviewError: false), + ), + ), + ); + }, + ), + ), + ); +} + +void testWithdrawFormFillSection() { + group('WithdrawFormFillSection', () { + testWidgets('locks editable controls while preview is sending', ( + tester, + ) async { + final asset = Asset.fromJson(_utxoConfig(), knownIds: const {}); + final bloc = _FakeWithdrawFormBloc( + WithdrawFormState( + asset: asset, + step: WithdrawFormStep.fill, + recipientAddress: 'recipient', + amount: '1', + isSending: true, + ), + ); + addTearDown(bloc.close); + + await tester.pumpWidget(_buildTestWidget(bloc)); + + final lockWidget = tester.widget( + find.byKey(const Key('withdraw-form-fill-input-lock')), + ); + + expect(lockWidget.ignoring, isTrue); + }); + + testWidgets('keeps editable controls enabled when not sending', ( + tester, + ) async { + final asset = Asset.fromJson(_utxoConfig(), knownIds: const {}); + final bloc = _FakeWithdrawFormBloc( + WithdrawFormState( + asset: asset, + step: WithdrawFormStep.fill, + recipientAddress: 'recipient', + amount: '1', + isSending: false, + ), + ); + addTearDown(bloc.close); + + await tester.pumpWidget(_buildTestWidget(bloc)); + + final lockWidget = tester.widget( + find.byKey(const Key('withdraw-form-fill-input-lock')), + ); + + expect(lockWidget.ignoring, isFalse); + }); + }); +} + +void main() { + testWithdrawFormFillSection(); +} diff --git a/test_units/views/common/hw_wallet_dialog/trezor_dialog_select_wallet_test.dart b/test_units/views/common/hw_wallet_dialog/trezor_dialog_select_wallet_test.dart new file mode 100644 index 0000000000..01f00bae13 --- /dev/null +++ b/test_units/views/common/hw_wallet_dialog/trezor_dialog_select_wallet_test.dart @@ -0,0 +1,100 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_select_wallet.dart'; + +class _EmptyAssetLoader extends AssetLoader { + const _EmptyAssetLoader(); + + @override + Future> load(String path, Locale locale) async => {}; +} + +Future _pumpDialog( + WidgetTester tester, { + required void Function(String) onComplete, +}) async { + await tester.pumpWidget( + EasyLocalization( + supportedLocales: const [Locale('en')], + fallbackLocale: const Locale('en'), + startLocale: const Locale('en'), + saveLocale: false, + path: 'assets/translations', + assetLoader: const _EmptyAssetLoader(), + child: Builder( + builder: (context) { + return MaterialApp( + locale: context.locale, + supportedLocales: context.supportedLocales, + localizationsDelegates: context.localizationDelegates, + home: Scaffold( + body: TrezorDialogSelectWallet(onComplete: onComplete), + ), + ); + }, + ), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets( + 'tapping standard wallet does not show passphrase validation error', + (tester) async { + String? submittedPassphrase; + await _pumpDialog( + tester, + onComplete: (value) { + submittedPassphrase = value; + }, + ); + + await tester.tap(find.text('standardWallet')); + await tester.pump(); + + expect(submittedPassphrase, ''); + expect(find.text('passphraseIsEmpty'), findsNothing); + }, + ); + + testWidgets('hidden wallet submits entered passphrase', (tester) async { + String? submittedPassphrase; + await _pumpDialog( + tester, + onComplete: (value) { + submittedPassphrase = value; + }, + ); + + await tester.enterText(find.byType(TextFormField), 'my-secret-passphrase'); + await tester.pump(); + await tester.tap(find.text('hiddenWallet')); + await tester.pump(); + + expect(submittedPassphrase, 'my-secret-passphrase'); + expect(find.text('passphraseIsEmpty'), findsNothing); + }); + + testWidgets( + 'hidden wallet empty submit via keyboard shows validation error and does not submit', + (tester) async { + String? submittedPassphrase; + await _pumpDialog( + tester, + onComplete: (value) { + submittedPassphrase = value; + }, + ); + + await tester.tap(find.byType(TextFormField)); + await tester.pump(); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(submittedPassphrase, isNull); + expect(find.text('passphraseIsEmpty'), findsOneWidget); + }, + ); +} diff --git a/test_units/views/wallets_manager/widgets/hardware_wallets_manager_test.dart b/test_units/views/wallets_manager/widgets/hardware_wallets_manager_test.dart new file mode 100644 index 0000000000..efd6b552e3 --- /dev/null +++ b/test_units/views/wallets_manager/widgets/hardware_wallets_manager_test.dart @@ -0,0 +1,203 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; +import 'package:web_dex/bloc/analytics/analytics_event.dart'; +import 'package:web_dex/bloc/analytics/analytics_state.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; +import 'package:web_dex/views/wallets_manager/widgets/hardware_wallets_manager.dart'; + +class _EmptyAssetLoader extends AssetLoader { + const _EmptyAssetLoader(); + + @override + Future> load(String path, Locale locale) async => {}; +} + +class _FakeAuthBloc extends Cubit implements AuthBloc { + _FakeAuthBloc(super.initialState); + + final List addedEvents = []; + + @override + void add(AuthBlocEvent event) { + addedEvents.add(event); + } + + void emitState(AuthBlocState newState) => emit(newState); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeCoinsBloc extends Cubit implements CoinsBloc { + _FakeCoinsBloc() : super(CoinsState.initial()); + + final List addedEvents = []; + + @override + void add(CoinsEvent event) { + addedEvents.add(event); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeAnalyticsBloc extends Cubit + implements AnalyticsBloc { + _FakeAnalyticsBloc() : super(AnalyticsState.initial()); + + final List addedEvents = []; + + @override + void add(AnalyticsEvent event) { + addedEvents.add(event); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +KdfUser _buildTrezorUser(String walletName) { + return KdfUser( + walletId: WalletId.fromName( + walletName, + const AuthOptions(derivationMethod: DerivationMethod.hdWallet), + ), + isBip39Seed: true, + metadata: const { + 'type': 'trezor', + 'has_backup': true, + 'activated_coins': [], + }, + ); +} + +Future _pumpHardwareWalletManager( + WidgetTester tester, { + required _FakeAuthBloc authBloc, + required _FakeCoinsBloc coinsBloc, + required _FakeAnalyticsBloc analyticsBloc, + required VoidCallback onClose, + required void Function(Wallet) onSuccess, +}) async { + await tester.pumpWidget( + EasyLocalization( + supportedLocales: const [Locale('en')], + fallbackLocale: const Locale('en'), + startLocale: const Locale('en'), + saveLocale: false, + path: 'assets/translations', + assetLoader: const _EmptyAssetLoader(), + child: Builder( + builder: (context) { + return MaterialApp( + locale: context.locale, + supportedLocales: context.supportedLocales, + localizationsDelegates: context.localizationDelegates, + home: MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: coinsBloc), + BlocProvider.value(value: analyticsBloc), + ], + child: Scaffold( + body: HardwareWalletsManager( + close: onClose, + onSuccess: onSuccess, + eventType: WalletsManagerEventType.header, + ), + ), + ), + ); + }, + ), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets( + 'completed Trezor auth triggers parent success, dispatches coins session, and does not call close', + (tester) async { + final authBloc = _FakeAuthBloc(AuthBlocState.loading()); + final coinsBloc = _FakeCoinsBloc(); + final analyticsBloc = _FakeAnalyticsBloc(); + addTearDown(authBloc.close); + addTearDown(coinsBloc.close); + addTearDown(analyticsBloc.close); + + var closeCalls = 0; + var successCalls = 0; + Wallet? successWallet; + final user = _buildTrezorUser('trezor-wallet-primary'); + + await _pumpHardwareWalletManager( + tester, + authBloc: authBloc, + coinsBloc: coinsBloc, + analyticsBloc: analyticsBloc, + onClose: () => closeCalls++, + onSuccess: (wallet) { + successCalls++; + successWallet = wallet; + }, + ); + + authBloc.emitState(AuthBlocState.loggedIn(user)); + await tester.pump(); + + expect(successCalls, 1); + expect(successWallet, isNotNull); + expect(successWallet!.name, 'trezor-wallet-primary'); + expect(successWallet!.config.type, WalletType.trezor); + expect(closeCalls, 0); + + final sessionEvents = coinsBloc.addedEvents + .whereType(); + expect(sessionEvents.length, 1); + expect(sessionEvents.single.signedInUser, user); + }, + ); + + testWidgets('repeated completed states are handled only once', ( + tester, + ) async { + final authBloc = _FakeAuthBloc(AuthBlocState.loading()); + final coinsBloc = _FakeCoinsBloc(); + final analyticsBloc = _FakeAnalyticsBloc(); + addTearDown(authBloc.close); + addTearDown(coinsBloc.close); + addTearDown(analyticsBloc.close); + + var successCalls = 0; + final userA = _buildTrezorUser('trezor-wallet-a'); + final userB = _buildTrezorUser('trezor-wallet-b'); + + await _pumpHardwareWalletManager( + tester, + authBloc: authBloc, + coinsBloc: coinsBloc, + analyticsBloc: analyticsBloc, + onClose: () {}, + onSuccess: (_) => successCalls++, + ); + + authBloc.emitState(AuthBlocState.loggedIn(userA)); + await tester.pump(); + authBloc.emitState(AuthBlocState.loggedIn(userB)); + await tester.pump(); + + expect(successCalls, 1); + final sessionEvents = coinsBloc.addedEvents + .whereType(); + expect(sessionEvents.length, 1); + }); +} diff --git a/test_units/views/wallets_manager/widgets/wallet_login_test.dart b/test_units/views/wallets_manager/widgets/wallet_login_test.dart index 94dff93b34..2a2388bfe8 100644 --- a/test_units/views/wallets_manager/widgets/wallet_login_test.dart +++ b/test_units/views/wallets_manager/widgets/wallet_login_test.dart @@ -32,12 +32,17 @@ void main() { ), ), ); + await tester.pump(); + // Avoid focused-field clipboard paste heuristic so the test does not depend + // on platform clipboard async behavior. + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(); // Simulate password manager input (multi-character change) controller.text = 'mypassword123'; - // Wait for the auto-submit timer to trigger await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(); expect(submitCalled, true); }, diff --git a/test_units/views/wallets_manager/widgets/wallets_manager_test.dart b/test_units/views/wallets_manager/widgets/wallets_manager_test.dart new file mode 100644 index 0000000000..5a5e6c359b --- /dev/null +++ b/test_units/views/wallets_manager/widgets/wallets_manager_test.dart @@ -0,0 +1,150 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; +import 'package:web_dex/bloc/analytics/analytics_event.dart'; +import 'package:web_dex/bloc/analytics/analytics_state.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; +import 'package:web_dex/views/wallets_manager/widgets/wallets_manager.dart'; + +class _EmptyAssetLoader extends AssetLoader { + const _EmptyAssetLoader(); + + @override + Future> load(String path, Locale locale) async => {}; +} + +class _FakeAuthBloc extends Cubit implements AuthBloc { + _FakeAuthBloc(super.initialState); + + @override + void add(AuthBlocEvent event) {} + + void emitState(AuthBlocState newState) => emit(newState); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeCoinsBloc extends Cubit implements CoinsBloc { + _FakeCoinsBloc() : super(CoinsState.initial()); + + @override + void add(CoinsEvent event) {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeAnalyticsBloc extends Cubit + implements AnalyticsBloc { + _FakeAnalyticsBloc() : super(AnalyticsState.initial()); + + @override + void add(AnalyticsEvent event) {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +KdfUser _buildTrezorUser(String walletName) { + return KdfUser( + walletId: WalletId.fromName( + walletName, + const AuthOptions(derivationMethod: DerivationMethod.hdWallet), + ), + isBip39Seed: true, + metadata: const { + 'type': 'trezor', + 'has_backup': true, + 'activated_coins': [], + }, + ); +} + +Future _pumpWalletsManager( + WidgetTester tester, { + required _FakeAuthBloc authBloc, + required _FakeCoinsBloc coinsBloc, + required _FakeAnalyticsBloc analyticsBloc, + required VoidCallback onClose, + required void Function(Wallet) onSuccess, +}) async { + await tester.pumpWidget( + EasyLocalization( + supportedLocales: const [Locale('en')], + fallbackLocale: const Locale('en'), + startLocale: const Locale('en'), + saveLocale: false, + path: 'assets/translations', + assetLoader: const _EmptyAssetLoader(), + child: Builder( + builder: (context) { + return MaterialApp( + locale: context.locale, + supportedLocales: context.supportedLocales, + localizationsDelegates: context.localizationDelegates, + home: MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: coinsBloc), + BlocProvider.value(value: analyticsBloc), + ], + child: Scaffold( + body: WalletsManager( + eventType: WalletsManagerEventType.wallet, + walletType: WalletType.trezor, + close: onClose, + onSuccess: onSuccess, + ), + ), + ), + ); + }, + ), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets('trezor branch propagates success callback from WalletsManager', ( + tester, + ) async { + final authBloc = _FakeAuthBloc(AuthBlocState.loading()); + final coinsBloc = _FakeCoinsBloc(); + final analyticsBloc = _FakeAnalyticsBloc(); + addTearDown(authBloc.close); + addTearDown(coinsBloc.close); + addTearDown(analyticsBloc.close); + + var closeCalls = 0; + var successCalls = 0; + Wallet? successWallet; + + await _pumpWalletsManager( + tester, + authBloc: authBloc, + coinsBloc: coinsBloc, + analyticsBloc: analyticsBloc, + onClose: () => closeCalls++, + onSuccess: (wallet) { + successCalls++; + successWallet = wallet; + }, + ); + + authBloc.emitState(AuthBlocState.loggedIn(_buildTrezorUser('wallet-x'))); + await tester.pump(); + + expect(successCalls, 1); + expect(successWallet, isNotNull); + expect(successWallet!.name, 'wallet-x'); + expect(closeCalls, 0); + }); +} diff --git a/web/index.html b/web/index.html index 78d5e3f8bf..c2947bb0d2 100644 --- a/web/index.html +++ b/web/index.html @@ -138,7 +138,7 @@ alt="GLEEC Wallet is starting... Please wait." /> + async="false">window.addEventListener("DOMContentLoaded", (d => { const initWasm = window.kdf && window.kdf.init_wasm ? window.kdf.init_wasm : window.init_wasm; console.log("DOM fully loaded and parsed"), initWasm && !0 !== window.is_mm2_preload_busy && (window.is_mm2_preload_busy = !0, initWasm().then((() => { window.is_mm2_preloaded = !0 })).finally((() => { window.is_mm2_preload_busy = !1 }))) }))