diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000000..df054af7ee --- /dev/null +++ b/.cursorignore @@ -0,0 +1,3 @@ +# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) +AGENTS/ +AGENTS.md \ No newline at end of file diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index 1ee9164eab..0000000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,117 +0,0 @@ -FROM docker.io/ubuntu:22.04 - -ENV FLUTTER_VERSION="3.32.5" -ENV USER="komodo" -ENV USER_ID=1000 -ENV PATH=$PATH:/opt/flutter/bin -ENV PATH=$PATH:/android-ndk/bin -ENV ANDROID_HOME=/opt/android-sdk-linux \ - LANG=en_US.UTF-8 \ - LC_ALL=en_US.UTF-8 \ - LANGUAGE=en_US:en -ENV TMPDIR=/tmp/ \ - ANDROID_DATA=/ \ - ANDROID_DNS_MODE=local \ - ANDROID_ROOT=/system - -ENV ANDROID_SDK_ROOT=$ANDROID_HOME \ - PATH=${PATH}:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${ANDROID_HOME}/emulator - -# comes from https://developer.android.com/studio/#command-tools -ENV ANDROID_SDK_TOOLS_VERSION=11076708 - -# https://developer.android.com/studio/releases/build-tools -ENV ANDROID_PLATFORM_VERSION=35 -ENV ANDROID_BUILD_TOOLS_VERSION=35.0.1 - -# https://developer.android.com/ndk/downloads -ENV ANDROID_NDK_VERSION=27.2.12479018 - -RUN apt update && apt install -y sudo && \ - useradd -u $USER_ID -m $USER && \ - usermod -aG sudo $USER && \ - echo "$USER ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \ - mkdir -p /workspaces && \ - chown -R $USER:$USER /workspaces && \ - chown -R $USER:$USER /opt - -RUN apt-get update -y && \ - apt-get install -y --no-install-recommends \ - ca-certificates \ - build-essential \ - libssl-dev \ - cmake \ - llvm-dev \ - libclang-dev \ - lld \ - gcc \ - libc6-dev \ - jq \ - make \ - pkg-config \ - git \ - automake \ - libtool \ - m4 \ - autoconf \ - make \ - file \ - curl \ - wget \ - gnupg \ - software-properties-common \ - lsb-release \ - libudev-dev \ - zip unzip \ - nodejs npm \ - binutils && \ - apt-get clean - -USER $USER - -RUN set -e -o xtrace \ - && cd /opt \ - && sudo chown -R $USER:$USER /opt \ - && sudo apt-get update \ - && sudo apt-get install -y jq \ - openjdk-17-jdk \ - # For Linux build - clang cmake git \ - ninja-build pkg-config \ - libgtk-3-dev liblzma-dev \ - libstdc++-12-dev \ - xz-utils \ - wget zip unzip git openssh-client curl bc software-properties-common build-essential \ - ruby-full ruby-bundler libstdc++6 libpulse0 libglu1-mesa locales lcov \ - libsqlite3-dev --no-install-recommends \ - # for x86 emulators - libxtst6 libnss3-dev libnspr4 libxss1 libatk-bridge2.0-0 libgtk-3-0 libgdk-pixbuf2.0-0 \ - && sudo rm -rf /var/lib/apt/lists/* \ - && sudo sh -c 'echo "en_US.UTF-8 UTF-8" > /etc/locale.gen' \ - && sudo locale-gen \ - && sudo update-locale LANG=en_US.UTF-8 \ - && wget -q https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_SDK_TOOLS_VERSION}_latest.zip -O android-sdk-tools.zip \ - && mkdir -p ${ANDROID_HOME}/cmdline-tools/ \ - && unzip -q android-sdk-tools.zip -d ${ANDROID_HOME}/cmdline-tools/ \ - && mv ${ANDROID_HOME}/cmdline-tools/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest \ - && sudo chown -R $USER:$USER $ANDROID_HOME \ - && rm android-sdk-tools.zip \ - && yes | sdkmanager --licenses \ - && sdkmanager platform-tools \ - && git config --global user.email "hello@komodoplatform.com" \ - && git config --global user.name "Komodo Platform" \ - && yes | sdkmanager \ - "platforms;android-$ANDROID_PLATFORM_VERSION" \ - "build-tools;$ANDROID_BUILD_TOOLS_VERSION" - -RUN cd /opt && \ - curl -O https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz && \ - tar -xvf flutter_linux_${FLUTTER_VERSION}-stable.tar.xz -C /opt && \ - rm flutter_linux_${FLUTTER_VERSION}-stable.tar.xz && \ - flutter config --no-analytics && \ - flutter precache && \ - yes "y" | flutter doctor --android-licenses && \ - flutter doctor && \ - flutter update-packages --verify-only && \ - mkdir -p /workspaces/komodo-wallet && \ - chown -R $USER_ID:$USER_ID /workspaces/komodo-wallet \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e92a2584dd..e5f0fc2fcd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,16 +1,21 @@ { "name": "flutter_docker", "context": "..", - "dockerFile": "Dockerfile", + "dockerFile": "../.docker/komodo-wallet-android.dockerfile", "remoteUser": "komodo", "workspaceFolder": "/workspaces/komodo-wallet", - "postCreateCommand": "sudo chown -R komodo:komodo /workspaces/komodo-wallet", - "postAttachCommand": "flutter pub get", - "runArgs": [ - "--privileged", - "-v", - "/dev/bus/usb:/dev/bus/usb" - ], + "initializeCommand": "docker build -t komodo/android-sdk:35 -f ${localWorkspaceFolder}/.docker/android-sdk.dockerfile ${localWorkspaceFolder}", + "postCreateCommand": "bash -lc '.devcontainer/postCreate.sh'", + "postAttachCommand": "flutter pub get --enforce-lockfile", + // Security Note: The following runArgs are commented out for security reasons. + // Uncomment these lines only when you need to debug Android apps via USB connection + // through the dev container. The --privileged flag grants extended privileges to + // the container, and USB device mounting allows direct hardware access. + // "runArgs": [ + // "--privileged", + // "-v", + // "/dev/bus/usb:/dev/bus/usb" + // ], "forwardPorts": [ 8081, 5037 diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh new file mode 100755 index 0000000000..0884d1419c --- /dev/null +++ b/.devcontainer/postCreate.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Initialize and pin submodules to the recorded commits +git submodule sync --recursive +git submodule update --init --recursive --checkout + +# Recommended git settings for submodules +git config fetch.recurseSubmodules on-demand +git config submodule.sdk.ignore dirty + +echo "postCreate: completed submodule initialization and permissions setup" diff --git a/.docker/android-sdk.dockerfile b/.docker/android-sdk.dockerfile index 91d2291c80..2f28e09cf9 100644 --- a/.docker/android-sdk.dockerfile +++ b/.docker/android-sdk.dockerfile @@ -26,11 +26,11 @@ ENV ANDROID_SDK_ROOT=$ANDROID_HOME \ ENV ANDROID_SDK_TOOLS_VERSION=11076708 # https://developer.android.com/studio/releases/build-tools -ENV ANDROID_PLATFORM_VERSION=35 +ENV ANDROID_PLATFORM_VERSION=36 ENV ANDROID_BUILD_TOOLS_VERSION=35.0.1 # https://developer.android.com/ndk/downloads -ENV ANDROID_NDK_VERSION=27.2.12479018 +ENV ANDROID_NDK_VERSION=27.3.13750724 RUN set -o xtrace \ && sudo chown -R $USER:$USER /opt \ diff --git a/.docker/build.sh b/.docker/build.sh index 46c68aecf7..a40cb17e35 100755 --- a/.docker/build.sh +++ b/.docker/build.sh @@ -16,6 +16,19 @@ fi echo "Building with target: $BUILD_TARGET, mode: $BUILD_MODE" +# Ensure submodule is initialized and pinned to the recorded commit +if command -v git >/dev/null 2>&1; then + echo "Ensuring SDK submodule is initialized and pinned..." + # Keep local submodule config in sync with .gitmodules (e.g., update=checkout) + git submodule sync --recursive || true + # Clean submodules to discard local changes and untracked files + git submodule foreach --recursive "git reset --hard && git clean -fdx" || true + # Initialize and checkout recorded commits (pinned) + git submodule update --init --recursive --checkout || true + # Enable on-demand fetch for submodules (helps when switching branches) + git config fetch.recurseSubmodules on-demand || true +fi + if [ "$(uname)" = "Darwin" ]; then PLATFORM_FLAG="--platform linux/amd64" else @@ -42,29 +55,67 @@ mkdir -p ./build COMMIT_HASH=$(git rev-parse --short HEAD | cut -c1-7) +# Only pass GITHUB_API_PUBLIC_READONLY_TOKEN as environment variable ENV_ARGS="" -ENV_VARS="GITHUB_API_PUBLIC_READONLY_TOKEN TRELLO_API_KEY \ -TRELLO_TOKEN TRELLO_BOARD_ID TRELLO_LIST_ID \ -FEEDBACK_API_KEY FEEDBACK_PRODUCTION_URL FEEDBACK_TEST_URL \ -COMMIT_HASH" +if [ -n "$GITHUB_API_PUBLIC_READONLY_TOKEN" ]; then + ENV_ARGS="-e GITHUB_API_PUBLIC_READONLY_TOKEN=$GITHUB_API_PUBLIC_READONLY_TOKEN" +fi -for VAR in $ENV_VARS; do - case "$VAR" in - GITHUB_API_PUBLIC_READONLY_TOKEN) VALUE=$GITHUB_API_PUBLIC_READONLY_TOKEN ;; - TRELLO_API_KEY) VALUE=$TRELLO_API_KEY ;; - TRELLO_TOKEN) VALUE=$TRELLO_TOKEN ;; - TRELLO_BOARD_ID) VALUE=$TRELLO_BOARD_ID ;; - TRELLO_LIST_ID) VALUE=$TRELLO_LIST_ID ;; - FEEDBACK_API_KEY) VALUE=$FEEDBACK_API_KEY ;; - FEEDBACK_PRODUCTION_URL) VALUE=$FEEDBACK_PRODUCTION_URL ;; - FEEDBACK_TEST_URL) VALUE=$FEEDBACK_TEST_URL ;; - COMMIT_HASH) VALUE=$COMMIT_HASH ;; - *) VALUE= ;; - esac +# Build command logic +BUILD_COMMAND="flutter build $BUILD_TARGET --no-pub --$BUILD_MODE" +# Prepare build command with feedback service credentials +BUILD_CMD="$BUILD_COMMAND" +# Add commit hash to build command +BUILD_CMD="$BUILD_CMD --dart-define=COMMIT_HASH=$COMMIT_HASH" - [ -n "$VALUE" ] && ENV_ARGS="$ENV_ARGS -e $VAR=$VALUE" -done +# Check and add the shared Trello board and list IDs if they are available +HAVE_TRELLO_IDS=false +if [ -n "$TRELLO_BOARD_ID" ] && [ -n "$TRELLO_LIST_ID" ]; then + HAVE_TRELLO_IDS=true + # Add these shared IDs to the build command + BUILD_CMD="$BUILD_CMD --dart-define=TRELLO_BOARD_ID=$TRELLO_BOARD_ID" + BUILD_CMD="$BUILD_CMD --dart-define=TRELLO_LIST_ID=$TRELLO_LIST_ID" +fi + +# Add Trello feedback service variables if ALL required values are provided +if [ "$HAVE_TRELLO_IDS" = true ] && [ -n "$TRELLO_API_KEY" ] && [ -n "$TRELLO_TOKEN" ]; then + echo "Adding Trello feedback service configuration" + BUILD_CMD="$BUILD_CMD --dart-define=TRELLO_API_KEY=$TRELLO_API_KEY" + BUILD_CMD="$BUILD_CMD --dart-define=TRELLO_TOKEN=$TRELLO_TOKEN" +else + # If any Trello credential is missing, log a message but continue the build + if [ -n "$TRELLO_API_KEY" ] || [ -n "$TRELLO_TOKEN" ] || [ -n "$TRELLO_BOARD_ID" ] || [ -n "$TRELLO_LIST_ID" ]; then + echo "Warning: Incomplete Trello credentials provided. All Trello credentials must be present to include them in the build." + fi +fi +# Add Cloudflare feedback service variables if ALL required values are provided +# Note: Cloudflare also needs the Trello board and list IDs to be available +if [ "$HAVE_TRELLO_IDS" = true ] && [ -n "$FEEDBACK_API_KEY" ] && [ -n "$FEEDBACK_PRODUCTION_URL" ]; then + echo "Adding Cloudflare feedback service configuration" + BUILD_CMD="$BUILD_CMD --dart-define=FEEDBACK_API_KEY=$FEEDBACK_API_KEY" + BUILD_CMD="$BUILD_CMD --dart-define=FEEDBACK_PRODUCTION_URL=$FEEDBACK_PRODUCTION_URL" +else + # If any Cloudflare credential is missing, log a message but continue the build + if [ -n "$FEEDBACK_API_KEY" ] || [ -n "$FEEDBACK_PRODUCTION_URL" ] || + ([ -n "$TRELLO_BOARD_ID" ] || [ -n "$TRELLO_LIST_ID" ]); then + echo "Warning: Incomplete Cloudflare feedback credentials provided. All Cloudflare credentials and Trello board/list IDs must be present to include them in the build." + fi +fi +# Add Matomo tracking variables if ALL required values are provided +# Matomo configuration only used when both are non-empty +if [ -n "$MATOMO_URL" ] && [ -n "$MATOMO_SITE_ID" ]; then + echo "Adding Matomo tracking configuration" + BUILD_CMD="$BUILD_CMD --dart-define=MATOMO_URL=$MATOMO_URL" + BUILD_CMD="$BUILD_CMD --dart-define=MATOMO_SITE_ID=$MATOMO_SITE_ID" +else + echo "Warning: Missing Matomo parameters. Both MATOMO_URL and MATOMO_SITE_ID must be provided." +fi +# Add web-specific build arguments if the target is web +if [ "$BUILD_TARGET" = "web" ]; then + echo "Adding web-specific build arguments: --no-web-resources-cdn" + BUILD_CMD="$BUILD_CMD --no-web-resources-cdn" +fi # Use the provided arguments for flutter build # Build a second time if needed, as asset downloads will require a rebuild on the first attempt docker run $PLATFORM_FLAG --rm -v ./build:/app/build \ @@ -72,4 +123,4 @@ docker run $PLATFORM_FLAG --rm -v ./build:/app/build \ -u "$HOST_UID:$HOST_GID" \ $ENV_ARGS \ komodo/komodo-wallet:latest sh -c \ - "sudo chown -R komodo:komodo /app/build; flutter pub get --enforce-lockfile; flutter build web --no-pub || true; flutter build $BUILD_TARGET --config-only; flutter build $BUILD_TARGET --no-pub --dart-define=COMMIT_HASH=$COMMIT_HASH --$BUILD_MODE" + "sudo chown -R komodo:komodo /app/build; flutter pub get --enforce-lockfile; $BUILD_COMMAND || true; flutter build $BUILD_TARGET --config-only; $BUILD_CMD" diff --git a/.docker/komodo-wallet-android.dockerfile b/.docker/komodo-wallet-android.dockerfile index d4b6690fa4..d690c4cd91 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.32.5" +ENV FLUTTER_VERSION="3.35.3" ENV HOME="/home/komodo" ENV USER="komodo" ENV PATH=$PATH:$HOME/flutter/bin @@ -14,4 +14,4 @@ RUN curl -O https://storage.googleapis.com/flutter_infra_release/releases/stable rm flutter_linux_${FLUTTER_VERSION}-stable.tar.xz && \ flutter config --no-analytics && \ yes "y" | flutter doctor --android-licenses && \ - flutter doctor \ No newline at end of file + flutter doctor diff --git a/.github/actions/flutter-deps/action.yml b/.github/actions/flutter-deps/action.yml index 947a0b6dbd..34cb7741de 100644 --- a/.github/actions/flutter-deps/action.yml +++ b/.github/actions/flutter-deps/action.yml @@ -7,7 +7,7 @@ runs: uses: subosito/flutter-action@v2 with: # NB! Keep up-to-date with the flutter version used for development - flutter-version: "3.32.5" + flutter-version: "3.35.3" channel: "stable" - name: Prepare build directory diff --git a/.github/actions/generate-assets/action.yml b/.github/actions/generate-assets/action.yml index 4cef01aa57..60c0afc69c 100644 --- a/.github/actions/generate-assets/action.yml +++ b/.github/actions/generate-assets/action.yml @@ -1,5 +1,5 @@ -name: "Generates assets" -description: "Runs the flutter build command to transform and generate assets for the deployment build" +name: "Fetch packages, generate assets, and build" +description: "Runs a dry-run flutter build to fetch/register assets, then runs the actual build for deployment" inputs: GITHUB_TOKEN: @@ -37,8 +37,18 @@ inputs: description: "Production URL for Cloudflare feedback service" required: false default: "" - FEEDBACK_TEST_URL: - description: "Test URL for Cloudflare feedback service (debug mode)" + + # Optional Matomo analytics configuration + MATOMO_URL: + description: "Matomo server base URL" + required: false + default: "" + MATOMO_SITE_ID: + description: "Matomo site ID" + required: false + default: "" + ANALYTICS_DISABLED: + description: "Disable analytics collection (true/false)" required: false default: "" @@ -71,7 +81,9 @@ runs: TRELLO_LIST_ID: ${{ inputs.TRELLO_LIST_ID }} FEEDBACK_API_KEY: ${{ inputs.FEEDBACK_API_KEY }} FEEDBACK_PRODUCTION_URL: ${{ inputs.FEEDBACK_PRODUCTION_URL }} - FEEDBACK_TEST_URL: ${{ inputs.FEEDBACK_TEST_URL }} + MATOMO_URL: ${{ inputs.MATOMO_URL }} + MATOMO_SITE_ID: ${{ inputs.MATOMO_SITE_ID }} + ANALYTICS_DISABLED: ${{ inputs.ANALYTICS_DISABLED }} run: | echo "Running \`flutter build\` to generate assets for the deployment build" @@ -113,19 +125,34 @@ runs: # Add Cloudflare feedback service variables if ALL required values are provided # Note: Cloudflare also needs the Trello board and list IDs to be available - if [ "$HAVE_TRELLO_IDS" = true ] && [ -n "$FEEDBACK_API_KEY" ] && [ -n "$FEEDBACK_PRODUCTION_URL" ] && [ -n "$FEEDBACK_TEST_URL" ]; then + if [ "$HAVE_TRELLO_IDS" = true ] && [ -n "$FEEDBACK_API_KEY" ] && [ -n "$FEEDBACK_PRODUCTION_URL" ]; then echo "Adding Cloudflare feedback service configuration" BUILD_CMD="$BUILD_CMD --dart-define=FEEDBACK_API_KEY=$FEEDBACK_API_KEY" BUILD_CMD="$BUILD_CMD --dart-define=FEEDBACK_PRODUCTION_URL=$FEEDBACK_PRODUCTION_URL" - BUILD_CMD="$BUILD_CMD --dart-define=FEEDBACK_TEST_URL=$FEEDBACK_TEST_URL" else # If any Cloudflare credential is missing, log a message but continue the build - if [ -n "$FEEDBACK_API_KEY" ] || [ -n "$FEEDBACK_PRODUCTION_URL" ] || [ -n "$FEEDBACK_TEST_URL" ] || + if [ -n "$FEEDBACK_API_KEY" ] || [ -n "$FEEDBACK_PRODUCTION_URL" ] || ([ -n "$TRELLO_BOARD_ID" ] || [ -n "$TRELLO_LIST_ID" ]); then echo "Warning: Incomplete Cloudflare feedback credentials provided. All Cloudflare credentials and Trello board/list IDs must be present to include them in the build." fi fi + HAS_MATOMO_CONFIG=false + if [ -n "$MATOMO_URL" ] && [ -n "$MATOMO_SITE_ID" ]; then + HAS_MATOMO_CONFIG=true + echo "Adding Matomo analytics configuration" + BUILD_CMD="$BUILD_CMD --dart-define=MATOMO_URL=$MATOMO_URL" + BUILD_CMD="$BUILD_CMD --dart-define=MATOMO_SITE_ID=$MATOMO_SITE_ID" + fi + + if [ "$ANALYTICS_DISABLED" = "true" ]; then + echo "Disabling analytics via ANALYTICS_DISABLED input" + BUILD_CMD="$BUILD_CMD --dart-define=ANALYTICS_DISABLED=true" + elif [ "$CI" = "true" ] && [ "$HAS_MATOMO_CONFIG" != "true" ]; then + echo "CI environment detected without Matomo config. Disabling analytics for this build." + 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 diff --git a/.github/scripts/roll_sdk_packages.sh b/.github/scripts/roll_sdk_packages.sh index b8837f5fb4..6cbd2f2284 100755 --- a/.github/scripts/roll_sdk_packages.sh +++ b/.github/scripts/roll_sdk_packages.sh @@ -4,16 +4,21 @@ # Designed to handle git-based dependencies and work with the GitHub CI workflow # # Usage: -# UPGRADE_ALL_PACKAGES=false TARGET_BRANCH=dev .github/scripts/roll_sdk_packages.sh +# .github/scripts/roll_sdk_packages.sh [-a] [-m] [-t ] # -# Parameters: -# UPGRADE_ALL_PACKAGES: Set to "true" to upgrade all packages, "false" to only upgrade SDK packages -# TARGET_BRANCH: The target branch for PR creation +# Options: +# -a, --upgrade-all Upgrade all packages (use --major-versions) +# -m, --major-sdk-only Upgrade SDK packages allowing major versions only +# -t, --target-branch BR Target branch for PR creation (default: dev) +# -h, --help Show this help and exit # # For more details, see `docs/SDK_DEPENDENCY_MANAGEMENT.md` -# Exit on error, but with proper cleanup -set -e +# Exit on error, but with proper cleanup (robust shell options) +set -Eeuo pipefail + +# Establish REPO_ROOT early for cleanup safety +REPO_ROOT="${REPO_ROOT:-$(pwd)}" # Error handling and cleanup function cleanup() { @@ -23,7 +28,10 @@ cleanup() { if [ $exit_code -ne 0 ] && [ $exit_code -ne 100 ]; then echo "ERROR: Script failed with exit code $exit_code" # Clean up any temporary files - find "$REPO_ROOT" -name "*.bak" -type f -delete + if [ -n "${REPO_ROOT:-}" ] && [ -d "$REPO_ROOT" ]; then + find "$REPO_ROOT" -name "*.bak" -type f -delete || true + find "$REPO_ROOT" -name "*.bak_major" -type f -delete || true + fi fi exit $exit_code @@ -51,15 +59,67 @@ if ! command -v flutter &> /dev/null; then exit 1 fi -# Configuration -# Set to "true" to upgrade all packages, "false" to only upgrade SDK packages -UPGRADE_ALL_PACKAGES=${UPGRADE_ALL_PACKAGES:-false} -# Branch to target for PR creation -TARGET_BRANCH=${TARGET_BRANCH:-"dev"} +# Configuration defaults (overridden via CLI flags) +UPGRADE_ALL_PACKAGES=false +UPGRADE_SDK_MAJOR=false +TARGET_BRANCH="dev" + +# Usage/help printer +print_usage() { + cat <<'USAGE' +Usage: + .github/scripts/roll_sdk_packages.sh [-a] [-m] [-t ] + +Options: + -a, --upgrade-all Upgrade all packages (use --major-versions) + -m, --major-sdk-only Upgrade SDK packages allowing major versions only + -t, --target-branch BR Target branch for PR creation (default: dev) + -h, --help Show this help and exit +USAGE +} + +# Parse CLI arguments +while [[ $# -gt 0 ]]; do + case "$1" in + -a|--upgrade-all|--all) + UPGRADE_ALL_PACKAGES=true + shift + ;; + -m|--major-sdk-only) + UPGRADE_SDK_MAJOR=true + shift + ;; + -t|--target-branch) + if [[ -z "${2:-}" ]]; then + log_error "Missing value for $1" + print_usage + exit 2 + fi + TARGET_BRANCH="$2" + shift 2 + ;; + -h|--help) + print_usage + exit 0 + ;; + --) + shift + break + ;; + -*) + log_error "Unknown option: $1" + print_usage + exit 2 + ;; + *) + break + ;; + esac +done # Get the current date for branch naming and commit messages CURRENT_DATE=$(date '+%Y-%m-%d') -REPO_ROOT=$(pwd) +# REPO_ROOT already set above CHANGES_FILE="$REPO_ROOT/SDK_CHANGELOG.md" # List of external SDK packages to be updated (from KomodoPlatform/komodo-defi-sdk-flutter.git) @@ -82,6 +142,8 @@ SDK_PACKAGES=( "komodo_ui" "komodo_wallet_build_transformer" "komodo_wallet_cli" + "dragon_charts_flutter" + "dragon_logs" ) # Extract version information from the pubspec.lock file @@ -145,25 +207,124 @@ get_package_info_from_lock() { fi } -# Initialize changes file -echo "# SDK Package Rolls" > "$CHANGES_FILE" -echo "" >> "$CHANGES_FILE" -echo "**Date:** $CURRENT_DATE" >> "$CHANGES_FILE" -echo "**Target Branch:** $TARGET_BRANCH" >> "$CHANGES_FILE" -echo "**Upgrade Mode:** $([ "$UPGRADE_ALL_PACKAGES" = "true" ] && echo "All Packages" || echo "SDK Packages Only")" >> "$CHANGES_FILE" -echo "" >> "$CHANGES_FILE" -echo "The following SDK packages were rolled to newer versions:" >> "$CHANGES_FILE" -echo "" >> "$CHANGES_FILE" - -# Find all pubspec.yaml files +# Determine dependency type for a given package in a pubspec.yaml +# Returns one of: git|path|hosted|unknown +get_dependency_type_from_pubspec() { + local package_name=$1 + local pubspec_file=$2 + + if ! grep -q "^[[:space:]]*$package_name:" "$pubspec_file"; then + echo "unknown" + return + fi + + # Capture the package declaration line and the following 10 lines + local section + section=$(awk -v pkg="$package_name" ' + $0 ~ "^[[:space:]]*"pkg":" { print; c=10; next } + c>0 { print; c--; } + ' "$pubspec_file") + + if echo "$section" | grep -q "^[[:space:]]*git:"; then + echo "git" + return + fi + + if echo "$section" | grep -q "^[[:space:]]*path:"; then + echo "path" + return + fi + + # Default to hosted if not git or path + echo "hosted" +} + +# Update a hosted dependency version inline in pubspec.yaml while preserving formatting +# This updates only the version value for the specified package, keeping comments and structure intact +update_hosted_dependency_version_inline() { + local package_name=$1 + local pubspec_file=$2 + local new_version=$3 + + # Find the line number where the package is declared + local start_line=$(grep -n "^[[:space:]]*$package_name:" "$pubspec_file" | head -1 | cut -d: -f1) + if [ -z "$start_line" ]; then + return 0 + fi + + local start_content=$(sed -n "${start_line}p" "$pubspec_file") + + # Case 1: single-line declaration like: `package_name: ^1.2.3` + if echo "$start_content" | grep -q "\\^"; then + sed -i.bak -E "${start_line}s/\\^([0-9]+\\.[0-9]+\\.[0-9]+([\\-+][0-9A-Za-z\\.-]+)*)/\\^${new_version}/" "$pubspec_file" || true + return 0 + fi + + # Case 2: multi-line value; find the first non-empty, non-comment line after the declaration + local rel_target_line=$(tail -n +$((start_line+1)) "$pubspec_file" | awk ' + BEGIN { ln=0 } + { + ln++ + # Skip empty lines and comments + if ($0 ~ /^[[:space:]]*$/) next + if ($0 ~ /^[[:space:]]*#/) next + print ln + exit + }') + + if [ -z "$rel_target_line" ]; then + return 0 + fi + + local target_line=$((start_line + rel_target_line)) + local target_content=$(sed -n "${target_line}p" "$pubspec_file") + + # Only update if the target line contains a caret-version; otherwise leave unchanged + if echo "$target_content" | grep -q "\\^"; then + sed -i.bak -E "${target_line}s/\\^([0-9]+\\.[0-9]+\\.[0-9]+([\\-+][0-9A-Za-z\\.-]+)*)/\\^${new_version}/" "$pubspec_file" || true + fi +} + +# Determine mode text (used in header) +if [ "$UPGRADE_ALL_PACKAGES" = "true" ]; then + MODE_TEXT="All Packages" +elif [ "$UPGRADE_SDK_MAJOR" = "true" ]; then + MODE_TEXT="SDK Packages Only (allow major versions)" +else + MODE_TEXT="SDK Packages Only" +fi + +# Lazily create or update the changes file header only when changes are known +create_or_update_changes_header() { + if [ ! -f "$CHANGES_FILE" ] || ! grep -q "^# SDK Package Rolls" "$CHANGES_FILE"; then + { + echo "# SDK Package Rolls" + echo "" + echo "**Date:** $CURRENT_DATE" + echo "**Target Branch:** $TARGET_BRANCH" + echo "**Upgrade Mode:** $MODE_TEXT" + echo "" + echo "The following SDK packages were rolled to newer versions:" + echo "" + } > "$CHANGES_FILE" + else + sed -i.bak -E "s/^\\*\\*Date:\\*\\*.*/**Date:** $CURRENT_DATE/" "$CHANGES_FILE" || true + sed -i.bak -E "s/^\\*\\*Target Branch:\\*\\*.*/**Target Branch:** $TARGET_BRANCH/" "$CHANGES_FILE" || true + sed -i.bak -E "s/^\\*\\*Upgrade Mode:\\*\\*.*/**Upgrade Mode:** $MODE_TEXT/" "$CHANGES_FILE" || true + rm -f "$CHANGES_FILE.bak" + echo "" >> "$CHANGES_FILE" + fi +} + +# Find all pubspec.yaml files (robust to whitespace in paths) echo "Finding all pubspec.yaml files..." -PUBSPEC_FILES=$(find "$REPO_ROOT" -name "pubspec.yaml" -not -path "*/build/*" -not -path "*/\.*/*" -not -path "*/ios/*" -not -path "*/android/*") +mapfile -d '' PUBSPEC_FILES < <(find "$REPO_ROOT" -name "pubspec.yaml" -not -path "*/build/*" -not -path "*/\.*/*" -not -path "*/ios/*" -not -path "*/android/*" -print0) -echo "Found $(echo "$PUBSPEC_FILES" | wc -l) pubspec.yaml files" +echo "Found ${#PUBSPEC_FILES[@]} pubspec.yaml files" ROLLS_MADE=false -for PUBSPEC in $PUBSPEC_FILES; do +for PUBSPEC in "${PUBSPEC_FILES[@]}"; do PROJECT_DIR=$(dirname "$PUBSPEC") PROJECT_NAME=$(basename "$PROJECT_DIR") @@ -185,21 +346,31 @@ for PUBSPEC in $PUBSPEC_FILES; do # Check if any SDK package is listed as a dependency CONTAINS_SDK_PACKAGE=false SDK_PACKAGES_FOUND=() + SDK_HOSTED_PACKAGES=() + SDK_GIT_PACKAGES=() for PACKAGE in "${SDK_PACKAGES[@]}"; do # More robust pattern matching that allows for comments and other formatting if grep -q "^[[:space:]]*$PACKAGE:" "$PUBSPEC"; then - # Additional check: detect if it's a git-based package from the KomodoPlatform repo - if grep -A 10 "$PACKAGE:" "$PUBSPEC" | grep -q "github.com/KomodoPlatform/komodo-defi-sdk-flutter"; then - echo "Found SDK package $PACKAGE (git-based) in $PROJECT_NAME" - CONTAINS_SDK_PACKAGE=true - SDK_PACKAGES_FOUND+=("$PACKAGE") - else - echo "Package $PACKAGE found but may not be from the SDK repository" - # Still include it, but log for clarity - CONTAINS_SDK_PACKAGE=true - SDK_PACKAGES_FOUND+=("$PACKAGE") - fi + CONTAINS_SDK_PACKAGE=true + SDK_PACKAGES_FOUND+=("$PACKAGE") + DEP_TYPE=$(get_dependency_type_from_pubspec "$PACKAGE" "$PUBSPEC") + case "$DEP_TYPE" in + git) + echo "Found SDK package $PACKAGE (git-based) in $PROJECT_NAME" + SDK_GIT_PACKAGES+=("$PACKAGE") + ;; + hosted) + echo "Found SDK package $PACKAGE (hosted on pub.dev) in $PROJECT_NAME" + SDK_HOSTED_PACKAGES+=("$PACKAGE") + ;; + path) + echo "Found SDK package $PACKAGE (local path) in $PROJECT_NAME - skipping version bump" + ;; + *) + echo "Found SDK package $PACKAGE (unknown type) in $PROJECT_NAME" + ;; + esac fi done @@ -252,14 +423,43 @@ for PUBSPEC in $PUBSPEC_FILES; do fi else log_info "Running flutter pub upgrade for SDK packages only in $PROJECT_NAME" - # Upgrade all SDK packages at once - if [ ${#SDK_PACKAGES_FOUND[@]} -gt 0 ]; then - log_info "Upgrading packages: ${SDK_PACKAGES_FOUND[*]}" - if ! flutter pub upgrade --unlock-transitive ${SDK_PACKAGES_FOUND[@]}; then - log_warning "Failed to upgrade packages in $PROJECT_NAME" + # Upgrade hosted SDK packages + if [ ${#SDK_HOSTED_PACKAGES[@]} -gt 0 ]; then + if [ "$UPGRADE_SDK_MAJOR" = true ]; then + log_info "Upgrading hosted SDK packages (allowing major): ${SDK_HOSTED_PACKAGES[*]}" + # Backup pubspec.yaml to preserve formatting/comments + PUBSPEC_BAK_FILE="$PUBSPEC.bak_major" + cp "$PUBSPEC" "$PUBSPEC_BAK_FILE" + if ! flutter pub upgrade --major-versions "${SDK_HOSTED_PACKAGES[@]}"; then + log_warning "Failed to upgrade hosted packages (major) in $PROJECT_NAME" + # Restore original pubspec.yaml to retain structure + mv -f "$PUBSPEC_BAK_FILE" "$PUBSPEC" + rm -f "$PUBSPEC_BAK_FILE" || true + PACKAGE_UPDATE_FAILED=true + else + # Restore original pubspec.yaml to retain structure; later we'll update versions inline + mv -f "$PUBSPEC_BAK_FILE" "$PUBSPEC" + rm -f "$PUBSPEC_BAK_FILE" || true + fi + else + log_info "Upgrading hosted SDK packages: ${SDK_HOSTED_PACKAGES[*]}" + if ! flutter pub upgrade "${SDK_HOSTED_PACKAGES[@]}"; then + log_warning "Failed to upgrade hosted packages in $PROJECT_NAME" + PACKAGE_UPDATE_FAILED=true + fi + fi + fi + + # Then, upgrade git-based SDK packages to refresh their lock entries + if [ ${#SDK_GIT_PACKAGES[@]} -gt 0 ]; then + log_info "Upgrading git-based SDK packages: ${SDK_GIT_PACKAGES[*]}" + if ! flutter pub upgrade --unlock-transitive "${SDK_GIT_PACKAGES[@]}"; then + log_warning "Failed to upgrade git-based packages in $PROJECT_NAME" PACKAGE_UPDATE_FAILED=true fi - else + fi + + if [ ${#SDK_HOSTED_PACKAGES[@]} -eq 0 ] && [ ${#SDK_GIT_PACKAGES[@]} -eq 0 ]; then log_info "No SDK packages found to upgrade in $PROJECT_NAME" fi fi @@ -280,6 +480,18 @@ for PUBSPEC in $PUBSPEC_FILES; do fi LOCK_AFTER="pubspec.lock" + # For hosted SDK packages, update pubspec.yaml inline version to match resolved lock version while preserving formatting + if [ ${#SDK_HOSTED_PACKAGES[@]} -gt 0 ]; then + for HPKG in "${SDK_HOSTED_PACKAGES[@]}"; do + RESOLVED_VERSION=$(get_package_info_from_lock "$HPKG" "$LOCK_AFTER" | sed -nE 's/.*version: "([^"]+)".*/\1/p') + if [ -n "$RESOLVED_VERSION" ]; then + update_hosted_dependency_version_inline "$HPKG" "$PUBSPEC" "$RESOLVED_VERSION" || true + fi + done + fi + + # Prepare changes file header (only now that changes are known) + create_or_update_changes_header # Add the project to the changes list echo "## $PROJECT_NAME" >> "$CHANGES_FILE" echo "" >> "$CHANGES_FILE" @@ -321,6 +533,8 @@ done # Add the SDK rolls image at the bottom of the changes file if [ "$ROLLS_MADE" = true ]; then + # Ensure header exists before appending image + create_or_update_changes_header echo "![SDK Package Rolls](https://raw.githubusercontent.com/KomodoPlatform/komodo-wallet/aaf19e4605c62854ba176bf1ea75d75b3cb48df9/docs/assets/sdk-rolls.png)" >> "$CHANGES_FILE" echo "" >> "$CHANGES_FILE" @@ -339,6 +553,9 @@ if [ -n "${GITHUB_OUTPUT}" ]; then echo "updates_found=false" >> $GITHUB_OUTPUT log_info "No rolls needed." # Exit with special code 100 to indicate no changes needed (not a failure) + # Ensure any temporary backups are removed even when no changes are detected + find "$REPO_ROOT" -name "*.bak" -type f -delete || true + find "$REPO_ROOT" -name "*.bak_major" -type f -delete || true exit 100 fi else @@ -349,6 +566,9 @@ else else log_info "No rolls needed." # Exit with special code 100 to indicate no changes needed (not a failure) + # Ensure any temporary backups are removed even when no changes are detected + find "$REPO_ROOT" -name "*.bak" -type f -delete || true + find "$REPO_ROOT" -name "*.bak_major" -type f -delete || true exit 100 fi fi diff --git a/.github/workflows/desktop-builds.yml b/.github/workflows/desktop-builds.yml index b59be72d36..7cf76d4bcb 100644 --- a/.github/workflows/desktop-builds.yml +++ b/.github/workflows/desktop-builds.yml @@ -3,7 +3,7 @@ run-name: Building desktop apps 🖥️ on: pull_request: - branches: [dev, main, release/*, hotfix/*, feature/*] + branches: [dev, main, fix/*, add/*, release/*, hotfix/*, feature/*] workflow_dispatch: release: types: [created] @@ -35,6 +35,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + submodules: recursive - name: Install Flutter and dependencies uses: ./.github/actions/flutter-deps @@ -68,7 +70,9 @@ jobs: pfx-base64: ${{ secrets.WINDOWS_PFX_BASE64 }} pfx-password: ${{ secrets.WINDOWS_PFX_PASSWORD }} - - name: Fetch packages and generate assets + - name: Fetch packages, generate assets, and build for ${{ matrix.platform }} + env: + GITHUB_API_PUBLIC_READONLY_TOKEN: ${{ secrets.GITHUB_TOKEN }} uses: ./.github/actions/generate-assets with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -78,17 +82,18 @@ jobs: TRELLO_LIST_ID: ${{ secrets.TRELLO_LIST_ID }} FEEDBACK_API_KEY: ${{ secrets.FEEDBACK_API_KEY }} FEEDBACK_PRODUCTION_URL: ${{ secrets.FEEDBACK_PRODUCTION_URL }} - FEEDBACK_TEST_URL: ${{ secrets.FEEDBACK_TEST_URL }} + MATOMO_URL: ${{ secrets.MATOMO_URL }} + MATOMO_SITE_ID: ${{ secrets.MATOMO_SITE_ID }} BUILD_COMMAND: ${{ matrix.build_command }} - - name: Build for ${{ matrix.platform }} - env: - GITHUB_API_PUBLIC_READONLY_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ${{ matrix.build_command }} + - name: Compute short commit SHA + id: vars + shell: bash + run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT - name: Upload artifact uses: actions/upload-artifact@v4 with: path: ${{ matrix.artifact_path }} - name: ${{ matrix.artifact_name }}.zip + name: ${{ matrix.artifact_name }}-${{ steps.vars.outputs.short_sha }}.zip retention-days: 5 diff --git a/.github/workflows/docker-android-build.yml b/.github/workflows/docker-android-build.yml index 881973bf63..957c58d595 100644 --- a/.github/workflows/docker-android-build.yml +++ b/.github/workflows/docker-android-build.yml @@ -27,6 +27,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + submodules: recursive - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -44,6 +46,27 @@ jobs: - name: Build Android image env: GITHUB_API_PUBLIC_READONLY_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Optional feedback provider secrets to embed dart-defines + TRELLO_API_KEY: ${{ secrets.TRELLO_API_KEY }} + TRELLO_TOKEN: ${{ secrets.TRELLO_TOKEN }} + TRELLO_BOARD_ID: ${{ secrets.TRELLO_BOARD_ID }} + TRELLO_LIST_ID: ${{ secrets.TRELLO_LIST_ID }} + FEEDBACK_API_KEY: ${{ secrets.FEEDBACK_API_KEY }} + FEEDBACK_PRODUCTION_URL: ${{ secrets.FEEDBACK_PRODUCTION_URL }} + MATOMO_URL: ${{ secrets.MATOMO_URL }} + MATOMO_SITE_ID: ${{ secrets.MATOMO_SITE_ID }} run: | chmod +x .docker/build.sh sh .docker/build.sh apk release + + - name: Compute short commit SHA + id: vars + shell: bash + run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT + + - name: Upload Android APK artifact + uses: actions/upload-artifact@v4 + with: + name: komodo-wallet-android-docker-${{ steps.vars.outputs.short_sha }}.apk + path: build/app/outputs/flutter-apk/app-release.apk + retention-days: 5 diff --git a/.github/workflows/docker-linux-build.yml b/.github/workflows/docker-linux-build.yml new file mode 100644 index 0000000000..c631af80f4 --- /dev/null +++ b/.github/workflows/docker-linux-build.yml @@ -0,0 +1,58 @@ +name: Build Linux Dekstop in Docker +# Note: This workflow almost entirely mirrors the existing Jenkins Pipeline. + +on: + pull_request: + branches: + [ + dev, + main, + release/*, + hotfix/*, + bugfix/*, + feature/*, + chore/*, + build/*, + ci/*, + ] + paths: + - ".docker/**" + - "linux/**" + - "pubspec.yaml" + - "pubspec.lock" + workflow_dispatch: + +jobs: + build-linux-docker: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build Linux Desktop + env: + GITHUB_API_PUBLIC_READONLY_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Optional feedback provider secrets to embed dart-defines + TRELLO_API_KEY: ${{ secrets.TRELLO_API_KEY }} + TRELLO_TOKEN: ${{ secrets.TRELLO_TOKEN }} + TRELLO_BOARD_ID: ${{ secrets.TRELLO_BOARD_ID }} + TRELLO_LIST_ID: ${{ secrets.TRELLO_LIST_ID }} + FEEDBACK_API_KEY: ${{ secrets.FEEDBACK_API_KEY }} + FEEDBACK_PRODUCTION_URL: ${{ secrets.FEEDBACK_PRODUCTION_URL }} + MATOMO_URL: ${{ secrets.MATOMO_URL }} + MATOMO_SITE_ID: ${{ secrets.MATOMO_SITE_ID }} + run: | + chmod +x .docker/build.sh + sh .docker/build.sh linux release + + - name: Compute short commit SHA + id: vars + shell: bash + run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT + + - name: Upload Linux bundle artifact + uses: actions/upload-artifact@v4 + with: + name: komodo-wallet-linux-docker-${{ steps.vars.outputs.short_sha }}.zip + path: build/linux/x64/release/bundle/* + retention-days: 5 diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml index d0f68027e0..d670019071 100644 --- a/.github/workflows/firebase-hosting-merge.yml +++ b/.github/workflows/firebase-hosting-merge.yml @@ -24,6 +24,8 @@ jobs: - name: Setup GH Actions uses: actions/checkout@v4 + with: + submodules: recursive - name: Install Flutter and dependencies uses: ./.github/actions/flutter-deps @@ -35,11 +37,13 @@ jobs: # Optionally provide feedback service configuration if available FEEDBACK_API_KEY: ${{ secrets.FEEDBACK_API_KEY }} FEEDBACK_PRODUCTION_URL: ${{ secrets.FEEDBACK_PRODUCTION_URL }} - FEEDBACK_TEST_URL: ${{ secrets.FEEDBACK_TEST_URL }} TRELLO_API_KEY: ${{ secrets.TRELLO_API_KEY }} TRELLO_TOKEN: ${{ secrets.TRELLO_TOKEN }} TRELLO_BOARD_ID: ${{ secrets.TRELLO_BOARD_ID }} TRELLO_LIST_ID: ${{ secrets.TRELLO_LIST_ID }} + # Matomo analytics configuration (optional). If not provided, analytics will be disabled in CI. + MATOMO_URL: ${{ secrets.MATOMO_URL }} + MATOMO_SITE_ID: ${{ secrets.MATOMO_SITE_ID }} - name: Validate build uses: ./.github/actions/validate-build diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml index 52b8774ff0..9c8c438930 100644 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -33,6 +33,8 @@ jobs: - name: Setup GH Actions uses: actions/checkout@v4 + with: + submodules: recursive - name: Install Flutter and dependencies uses: ./.github/actions/flutter-deps @@ -44,11 +46,14 @@ jobs: # Optionally provide feedback service configuration if available FEEDBACK_API_KEY: ${{ secrets.FEEDBACK_API_KEY }} FEEDBACK_PRODUCTION_URL: ${{ secrets.FEEDBACK_PRODUCTION_URL }} - FEEDBACK_TEST_URL: ${{ secrets.FEEDBACK_TEST_URL }} TRELLO_API_KEY: ${{ secrets.TRELLO_API_KEY }} TRELLO_TOKEN: ${{ secrets.TRELLO_TOKEN }} TRELLO_BOARD_ID: ${{ secrets.TRELLO_BOARD_ID }} TRELLO_LIST_ID: ${{ secrets.TRELLO_LIST_ID }} + # Matomo analytics configuration (optional). If not provided, analytics will be disabled in CI. + MATOMO_URL: ${{ secrets.MATOMO_URL }} + MATOMO_SITE_ID: ${{ secrets.MATOMO_SITE_ID }} + ANALYTICS_DISABLED: true - name: Validate build uses: ./.github/actions/validate-build diff --git a/.github/workflows/mobile-builds.yml b/.github/workflows/mobile-builds.yml index 0370405de8..34d9f641c8 100644 --- a/.github/workflows/mobile-builds.yml +++ b/.github/workflows/mobile-builds.yml @@ -31,6 +31,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + submodules: recursive - name: Install Flutter and dependencies uses: ./.github/actions/flutter-deps @@ -56,34 +58,39 @@ jobs: store-password: ${{ secrets.ANDROID_STORE_PASSWORD }} key-password: ${{ secrets.ANDROID_KEY_PASSWORD }} - - name: Fetch packages and generate assets - uses: ./.github/actions/generate-assets - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TRELLO_API_KEY: ${{ secrets.TRELLO_API_KEY }} - TRELLO_TOKEN: ${{ secrets.TRELLO_TOKEN }} - TRELLO_BOARD_ID: ${{ secrets.TRELLO_BOARD_ID }} - TRELLO_LIST_ID: ${{ secrets.TRELLO_LIST_ID }} - FEEDBACK_API_KEY: ${{ secrets.FEEDBACK_API_KEY }} - FEEDBACK_PRODUCTION_URL: ${{ secrets.FEEDBACK_PRODUCTION_URL }} - FEEDBACK_TEST_URL: ${{ secrets.FEEDBACK_TEST_URL }} - # Flutter build with `--no-pub` flag fails on Android due to a # known regression with the build system in 3.32.5. # https://github.com/flutter/flutter/issues/169336 + # Run config-only before the full double-build to ensure success. - name: Temporary workaround for Android build issue if: ${{ matrix.platform == 'Android' }} run: | flutter build apk --config-only - - name: Build for ${{ matrix.platform }} + - name: Fetch packages, generate assets, and build for ${{ matrix.platform }} env: GITHUB_API_PUBLIC_READONLY_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ${{ matrix.build_command }} + uses: ./.github/actions/generate-assets + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TRELLO_API_KEY: ${{ secrets.TRELLO_API_KEY }} + TRELLO_TOKEN: ${{ secrets.TRELLO_TOKEN }} + TRELLO_BOARD_ID: ${{ secrets.TRELLO_BOARD_ID }} + TRELLO_LIST_ID: ${{ secrets.TRELLO_LIST_ID }} + FEEDBACK_API_KEY: ${{ secrets.FEEDBACK_API_KEY }} + FEEDBACK_PRODUCTION_URL: ${{ secrets.FEEDBACK_PRODUCTION_URL }} + MATOMO_URL: ${{ secrets.MATOMO_URL }} + MATOMO_SITE_ID: ${{ secrets.MATOMO_SITE_ID }} + BUILD_COMMAND: ${{ matrix.build_command }} + - name: Compute short commit SHA + id: vars + shell: bash + run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT + - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: ${{ matrix.artifact_name }} + name: ${{ matrix.artifact_name }}-${{ steps.vars.outputs.short_sha }} path: ${{ matrix.artifact_path }} retention-days: 5 diff --git a/.github/workflows/roll-sdk-packages.yml b/.github/workflows/roll-sdk-packages.yml index dc9b9c5dfa..e5ba912c43 100644 --- a/.github/workflows/roll-sdk-packages.yml +++ b/.github/workflows/roll-sdk-packages.yml @@ -40,12 +40,13 @@ jobs: with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 with: # NB! Keep up-to-date with the flutter version used for development - flutter-version: "3.32.5" + flutter-version: "3.35.3" channel: "stable" - name: Determine configuration @@ -88,7 +89,7 @@ jobs: echo "SDK packages were successfully rolled" else echo "ROLLS_FOUND=false" >> $GITHUB_ENV - + if [ -n "${EXIT_CODE}" ] && [ ${EXIT_CODE} -ne 100 ]; then echo "::warning::SDK package roll script failed with exit code ${EXIT_CODE}" else diff --git a/.github/workflows/sdk-integration-preview.yml b/.github/workflows/sdk-integration-preview.yml index d9ba0192ec..a32dbf59d8 100644 --- a/.github/workflows/sdk-integration-preview.yml +++ b/.github/workflows/sdk-integration-preview.yml @@ -41,13 +41,19 @@ jobs: - name: Setup GH Actions uses: actions/checkout@v4 + with: + submodules: recursive - - name: Clone SDK repository + - name: Update SDK to specified branch shell: bash run: | - echo "Cloning komodo-defi-sdk-flutter repository..." - git clone --depth 1 --branch ${{ inputs.sdk_branch }} https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git sdk - echo "SDK cloned to ./sdk directory" + echo "Updating SDK submodule to branch ${{ inputs.sdk_branch }}..." + cd sdk + git fetch origin + git checkout ${{ inputs.sdk_branch }} + git pull origin ${{ inputs.sdk_branch }} + cd .. + echo "SDK updated to branch ${{ inputs.sdk_branch }}" ls -la sdk/ - name: Update KDF API configuration @@ -131,11 +137,13 @@ jobs: # Optionally provide feedback service configuration if available FEEDBACK_API_KEY: ${{ secrets.FEEDBACK_API_KEY }} FEEDBACK_PRODUCTION_URL: ${{ secrets.FEEDBACK_PRODUCTION_URL }} - FEEDBACK_TEST_URL: ${{ secrets.FEEDBACK_TEST_URL }} TRELLO_API_KEY: ${{ secrets.TRELLO_API_KEY }} TRELLO_TOKEN: ${{ secrets.TRELLO_TOKEN }} TRELLO_BOARD_ID: ${{ secrets.TRELLO_BOARD_ID }} TRELLO_LIST_ID: ${{ secrets.TRELLO_LIST_ID }} + # Matomo analytics configuration (optional). If not provided, analytics will be disabled in CI. + MATOMO_URL: ${{ secrets.MATOMO_URL }} + MATOMO_SITE_ID: ${{ secrets.MATOMO_SITE_ID }} - name: Validate build uses: ./.github/actions/validate-build diff --git a/.github/workflows/ui-tests-on-pr.yml b/.github/workflows/ui-tests-on-pr.yml index a2fa4d0edd..163e23531b 100644 --- a/.github/workflows/ui-tests-on-pr.yml +++ b/.github/workflows/ui-tests-on-pr.yml @@ -42,6 +42,8 @@ jobs: steps: - name: Setup GH Actions uses: actions/checkout@v4 + with: + submodules: recursive # Flutter integration test setup - name: Install Chrome and chromedriver diff --git a/.github/workflows/unit-tests-on-pr.yml b/.github/workflows/unit-tests-on-pr.yml index 687ca2bcd1..73393f2a02 100644 --- a/.github/workflows/unit-tests-on-pr.yml +++ b/.github/workflows/unit-tests-on-pr.yml @@ -14,6 +14,8 @@ jobs: steps: - name: Setup GH Actions uses: actions/checkout@v4 + with: + submodules: recursive - name: Install Flutter and dependencies uses: ./.github/actions/flutter-deps diff --git a/.github/workflows/validate-code-guidelines.yml b/.github/workflows/validate-code-guidelines.yml index cf473833d0..26e6ca532c 100644 --- a/.github/workflows/validate-code-guidelines.yml +++ b/.github/workflows/validate-code-guidelines.yml @@ -15,6 +15,8 @@ jobs: steps: - name: Setup GH Actions uses: actions/checkout@v4 + with: + submodules: recursive - name: Install Flutter and dependencies uses: ./.github/actions/flutter-deps diff --git a/.gitignore b/.gitignore index ca5b1dbbf7..d40a175d03 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -# Ignore the SDK symmlink (if it exists) -/sdk/ - # Miscellaneous *.class *.log @@ -11,6 +8,7 @@ .buildlog/ .history .svn/ +.kiro/ # IntelliJ related *.iml @@ -36,7 +34,7 @@ .packages .pub-cache/ .pub/ -/build/ +**/build/ contrib/coins_config.json devtools_options.yaml @@ -84,3 +82,5 @@ venv/ .fvm/ /macos/build AGENTS_1.md +# .dmg Release +dist/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..ad07a1ea79 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,7 @@ +[submodule "sdk"] + path = sdk + url = https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git + branch = dev + update = checkout + fetchRecurseSubmodules = on-demand + ignore = dirty diff --git a/AGENTS.md b/AGENTS.md index 68276a0e41..5a31c1e778 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,12 +12,13 @@ If the above fails due to the offline environment, add the `--offline` flag. ## Static Analysis and Formatting -Run analysis and formatting before committing code: +Run analysis and formatting (only on changed files) before committing code: ```bash flutter analyze -dart format . +dart format [files] + ``` ## Running Tests @@ -48,6 +49,6 @@ The KDF API documentation can be found in the root folder at `/KDF_API_DOCUMENTA ## PR Guidance -Commit messages should be clear and descriptive. When opening a pull request, summarize the purpose of the change and reference related issues when appropriate. Ensure commit messages follow the Conventional Commits standard as described in the standards section below. +Commit messages should be clear and descriptive. When opening a pull request, summarize the purpose of the change and reference related issues when appropriate. Ensure commit messages and PR title follow the Conventional Commits standard as described in the standards section below. diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b389d788..5b8314a006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,211 @@ +# Komodo Wallet v0.9.3 Release Notes + +This release sharpens sign-in convenience, analytics coverage, and operational resilience ahead of the 0.9.3 cut. Highlights include one-click remember-me login, a dual analytics pipeline with Matomo support, a hardened feedback workflow, and improved compliance guardrails across platforms. + +## 🚀 New Features + +- **One-Click Remember Me Sign-In** ([@CharlVS], #3041) - Adds a quick login toggle that remembers hashed wallet metadata, auto-detects password manager autofill, and lets you resume the last wallet in one tap across desktop and mobile. +- **Feedback Portal Overhaul** ([@CharlVS], #3017) - Rebuilds the in-app feedback flow with provider plugins, optional contact opt-out, log attachments capped under 10 MB, and screenshot scrubbing for sensitive dialogs. +- **Dual Analytics Pipeline** ([@CharlVS], #2932) - Runs Firebase and Matomo providers side by side with persistent event queues, CI-aware disables, and configurable Matomo dimensions. +- **Wallet Import Renaming & Validation** ([@CharlVS], #2792) - Validates wallet names on creation or import and lets you rename imports before they enter the manager. +- **Version Insight Panel** ([@takenagain], #3109) - Adds a bloc-driven settings panel that surfaces app, mm2, and coin-config commits with periodic polling. +- **ZHTLC (Zcash HTLC) Coin Support** [SDK] ([@takenagain], [SDK#227](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/227)) - Full integration of ZHTLC coins with enhanced activation strategy, task-based RPCs, Zcash params downloader, orderbook v2 support, and activation progress estimation. +- **Custom Token Support in Coin Config** [SDK] ([@takenagain], [SDK#225](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/225)) - Adds custom token storage and management within the coin config system with Hive-based persistence and recovery mechanisms. +- **CoinPaprika Market Data Provider** [SDK] ([@takenagain], [SDK#215](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/215)) - Integrates CoinPaprika API as a fallback provider for CEX market data, improving data availability and resilience. +- **External SDK Logging Support** [SDK] ([@CharlVS], [SDK#222](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/222)) - Enables passing external logger functions to the SDK for custom logging with managed subscriptions and improved callback error handling. +- **Trading-Related RPCs** [SDK] ([@CharlVS], [SDK#191](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/191)) - Implements comprehensive trading, orderbook, and Lightning RPCs with type-safe interfaces and SDK-level swap manager functionality. +- **Flutter Web WASM Support** [SDK] ([@CharlVS], [SDK#176](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/176)) - Comprehensive WASM support with OPFS interop extensions and unified cross-compatible storage implementation. + +## 🎨 UI & UX + +- **Asset List Loading Guards** ([@takenagain], #3134) - Hides portfolio rows until fiat pricing lands and shows placeholders instead of flickering zeroes. +- **Token Parent Labeling** ([@smk762], #2988) - Marks parent chains as native, adopts SDK display names, and keeps network suffixes visible across wallet views. +- **DEX Address Pill Consistency** ([@smk762], #2974) - Aligns the address pill style between list and detail flows for clearer swap confirmations. +- **Coin Detail Fiat Per Address** ([@takenagain], #3049) - Restores fiat balance context for individual addresses on the coin detail screen. +- **Memo Field Contextualization** ([@smk762], #2998) - Only renders memo inputs for Tendermint and ZHTLC assets so other withdrawals stay clutter-free. +- **Parent Display Name Suffix** [SDK] ([@CharlVS], [SDK#213](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/213)) - Adds token standard suffix support via `CoinSubClass.tokenStandardSuffix` for clearer parent chain identification. + +## 🔒 Security & Compliance + +- **Geo Blocker Bouncer Integration** ([@CharlVS], #3150) - Streams the new trading-status API, filters blocked assets during wallet bootstrap, and exposes an override for regulated builds. +- **Password Policy Hardening** ([@CharlVS], #3149/#3141) - Expands password limits to 128 characters across forms and makes validation consistent between Flutter and SDK layers. +- **Weak Password Flag Fix** ([@smk762], #3101) - Ensures the configuration flag actually respects weak-password bypass scenarios. +- **Pubkey Hygiene on Logout** ([@CharlVS], #3144) - Clears cached pubkeys when switching wallets or signing out to prevent stale address reuse. +- **Bip39 Compatibility Storage Fix** [SDK] ([@takenagain], [SDK#216](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/216)) - Ensures bip39 compatibility is stored regardless of wallet type, fixing HD wallet seed validation errors. +- **Trezor PIN/Passphrase Security** [SDK] ([@takenagain], [SDK#126](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/126)) - Mitigates potential exposure of Trezor PIN/passphrase by adding custom converters to prevent sensitive data in logs. +- **HD Wallet Message Signing** [SDK] ([@CharlVS], [SDK#198](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/198)) - Adds support for message signing with HD wallets including derivation path specification. +- **Trezor Connection Polling** [SDK] ([@takenagain], [SDK#126](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/126)) - Automatic polling of Trezor device connection status with auto sign-out when disconnected, preventing stale sessions. + +## ⚡ Performance & Reliability + +- **Fiat Onramp Debounce** ([@takenagain], #3125) - Debounces fiat amount edits so API calls only fire after user pauses typing. +- **Custom Token Activation Guardrails** ([@takenagain], #3129) - Waits for token propagation, limits retry loops, and deactivates test imports if the dialog closes without confirmation. +- **Legacy Wallet Import Stability** ([@takenagain], #3126) - Re-applies migrated coin lists after legacy imports and filters unsupported assets before activation. +- **SDK Subscription Lifecycle Management** [SDK] ([@takenagain], [SDK#232](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/232)) - Closes balance and pubkey subscriptions on auth state changes to prevent memory leaks and resource waste. +- **Market Metrics Log Reduction** [SDK] ([@takenagain], [SDK#223](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/223)) - Reduces market metrics log verbosity and removes duplicate logging for better performance. +- **Binance Per-Coin Currency Lists** [SDK] ([@takenagain], [SDK#224](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/224)) - Uses per-coin supported quote currency lists with USD stablecoin fallback mappings. +- **Multi-Provider Market Data System** [SDK] ([@takenagain], [SDK#145](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/145)) - Adds support for multiple market data providers with fallback logic and retry mechanisms. +- **Pubkey Watch Stream** [SDK] ([@takenagain], [SDK#178](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/178)) - Adds reactive public key monitoring with automatic auth state handling. + +## 🐛 Bug Fixes + +- **Unauthenticated Coin State Fixes** ([@CharlVS], #3138) - Repairs coin list and sparkline loading when viewing portfolios before logging in. +- **DEX Precision Regression** ([@CharlVS], #3123) - Eliminates precision loss in taker forms and adds tests for large rational conversions. +- **macOS File Picker Entitlements** ([@CharlVS], #3111) - Restores the native file picker by adding the required read-only entitlement and window focus handling. +- **NFT IPFS Fallbacks** ([@takenagain], #3020) - Introduces an IPFS gateway manager with retries, cooldowns, and unit tests so NFT media loads consistently. +- **SDK Disposal Crash** ([@DeckerSU], #3117) - Avoids crashes when mm2 shuts down mid-fetch by guarding the periodic fetch loop. +- **Price/Version Reporting** ([@DeckerSU], #3115) - Ensures the settings screen shows the actual mm2 commit hash instead of a closure string. +- **Custom Token Import & Refresh** [SDK] ([@takenagain], [SDK#220](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/220)) - Fixes custom token refresh, lowercase icon identifiers, and parent-child relationships. +- **ZHTLC Activation Resilience** [SDK] ([@takenagain], [SDK#227](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/227)) - Multiple fixes for ZHTLC activation including config transformation and orderbook v2 alignment. +- **Coingecko OHLC Parsing** [SDK] ([@takenagain], [SDK#203](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/203)) - Fixes OHLC data parsing differences between Coingecko and Binance formats. +- **External Logger Memory Leak** [SDK] ([@CharlVS], [SDK#222](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/222)) - Resolves memory leak from native callback by properly disposing \_kdfOperations. +- **BIP39 Validation for Legacy Wallets** [SDK] ([@takenagain], [SDK#216](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/216)) - Simplifies HD bip39 verification and allows custom seeds for legacy wallets. +- **Runtime Coin Updates Asset Ordering** [SDK] ([@takenagain], [SDK#217](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/217)) - Fixes Linux segfault by sorting assets before returning. +- **WASM JS Call Error Handling** [SDK] ([@takenagain], [SDK#185](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/185)) - Improves JS call error handling on startup for WASM operations. +- **Market Data Price Fetching** [SDK] ([@takenagain], [SDK#167](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/167)) - Prioritizes KomodoPriceRepository over CexRepository for current prices. + +## 🛠️ Build & Developer Experience + +- **Flutter 3.35.1 & SDK Roll** ([@CharlVS], #3108) - Upgrades Flutter, aligns SDK dependencies, and refreshes package overrides for the new mono-repo workspace. +- **SDK Git Submodule Adoption** ([@takenagain], #3110) - Vendors komodo-defi-sdk as a submodule with workspace overrides and adds tooling for deterministic rolls. +- **Matomo Build Validation** ([@DeckerSU], #3165) - Validates Matomo tracking parameters during CI builds to prevent misconfigured releases. +- **Build Script Environment Sanitization** ([@DeckerSU], #3037/#3055/#3058) - Normalises docker env defines, forces web builds off CDN resources, and removes stray .dgph artifacts from iOS outputs. +- **Linux Build Workflow Check** ([@DeckerSU], #3106) - Adds a GitHub workflow that exercises the Linux build script after SDK rolls. +- **Devcontainer Modernisation** ([@CharlVS], #3114) - Switches the devcontainer to lightweight .docker images and consolidates post-create provisioning. +- **SDK Dart Pub Workspaces Migration** [SDK] ([@CharlVS], [SDK#204](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/204)) - Migrates SDK to Dart Pub workspaces, bumps Dart SDK to >=3.9.0, unifies Flutter constraints (>=3.35.0 <3.36.0). +- **SDK Coin Updates Integration** [SDK] ([@takenagain], [SDK#190](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/190)) - Integrates komodo_coin_updates into komodo_coins with Hive CE migration and runtime update capabilities. +- **KDF API Branch Update** [SDK] ([@CharlVS], sdk/72c9de3) - Updates API branch to hotfix-remove-memorydb-size-metric with refreshed checksums for all platform binaries. +- **SDK Package Publishing** [SDK] ([@CharlVS], [SDK#204](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/204)) - Prepares SDK packages for pub.dev with updated dependencies, licenses, and repository links. +- **GitHub Token Authentication** [SDK] ([@CharlVS], [SDK#176](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/176)) - Adds GITHUB_API_PUBLIC_READONLY_TOKEN support to prevent API rate limiting (60→5,000 requests/hour). + +## 📚 Documentation + +- **Matomo Analytics Guide** ([@CharlVS], #2932) - Documents how to enable Matomo alongside Firebase, including CI toggles and queue behaviour. +- **SDK Submodule Management** ([@takenagain], #3110) - Provides end-to-end instructions for updating, hotfixing, and testing the vendored SDK. +- **macOS/iOS Build Prerequisites** ([@takenagain], #3128) - Expands setup docs with Ruby installation steps and refreshed platform notes. +- **SDK Package Documentation Overhaul** [SDK] ([@CharlVS], [SDK#201](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/201)) - Major documentation improvements across all SDK packages with comprehensive guides and API references. +- **ZHTLC Technical Documentation** [SDK] ([@takenagain], [SDK#227](https://github.com/KomodoPlatform/komodo-defi-sdk-flutter/pull/227)) - Adds activation refactoring plan, Firebase deployment setup, and tech debt report for ZHTLC implementations. + +## ⚠️ Breaking Changes + +- **SDK Dart/Flutter Version Requirements** [SDK] - Minimum Dart SDK version is now `^3.9.0` and Flutter version constraint is `>=3.35.0 <3.36.0` +- **ZHTLC Activation Parameters** [SDK] - Breaking changes to activation params serialization and structure for ZHTLC and ERC20 coins +- **SDK Workspace Migration** [SDK] - Legacy pubspec_overrides.yaml files removed in favor of Dart Pub workspace resolution + +## 📦 SDK Changes + +**SDK Version**: Updated from `68429b23dac43eddd53434dda1bd23296523f27d` to `72c9de3b370f1b4169ebbb3150e8adedf4ed3b23` + +**Full Changelog**: [0.9.2...0.9.3](https://github.com/KomodoPlatform/komodo-wallet/compare/0.9.2...0.9.3) + +# Komodo Wallet v0.9.2 Release Notes + +This release brings numerous improvements to wallet functionality, enhanced user experience, and critical bug fixes. Key highlights include HD wallet private key export, improved Trezor support, enhanced UI/UX throughout the application, and platform-specific optimizations. + +## 🚀 New Features + +- **HD & Offline Private Key Export** ([@CharlVS], #2982) - Export private keys from HD wallets for backup or use in other wallets, with pubkey unban functionality +- **Autofill Hints for Wallet Fields** ([@CharlVS], #3012) - Improved form filling experience with proper autofill support for wallet-related fields +- **Wallet-Enabled Coins in Add Assets** ([@takenagain], #2976) - View which coins are already enabled in your wallet directly from the "Add Assets" page +- **Copy Swap Order UUIDs** ([@smk762], #3002) - Easily copy swap order identifiers for reference and support +- **Hide Zero Balance Assets Persistence** ([@CharlVS], #2949) - Your preference to hide zero balance assets is now saved across sessions +- **Trading Duration Analytics** ([@CharlVS], #2931) - Track and analyze trading event durations for better insights +- **Missing Coins Support Link** ([@CharlVS], #2930) - Quick access to help for coins not yet supported in the wallet +- **Contact Details for Support** ([@CharlVS], #2807) - Improved support experience by requiring contact information for better follow-up +- **Geo Blocker API Integration** ([@CharlVS], #2893) - Enhanced compliance with region-based trading restrictions +- **Wallet Deletion** ([@CharlVS], #2843) - Safely remove wallets you no longer need +- **Cross-Platform Close Confirmation** ([@CharlVS], #2853) - Prevent accidental closure with confirmation dialog and proper KDF shutdown +- **Trezor SDK Migration** ([@takenagain], #2836) - Updated Trezor integration with latest SDK and RPC methods +- **User Address Prioritization** ([@takenagain], #2787) - Your addresses appear first in transaction history recipient lists +- **Git Commit Hash Display** ([@DeckerSU], #2796) - View the exact version commit hash in Settings › General +- **Copy Address Functionality** ([@smk762], #2690) - Easily copy addresses throughout the application + +## 🎨 UI/UX Improvements + +- **Show Full Pubkeys Where Copyable** ([@CharlVS], #2955) - Display complete public keys in areas where they can be copied +- **"Seed" to "Seed Phrase" Terminology** ([@smk762], #2972) - Consistent terminology update throughout login and import forms +- **Hide Log Export When Logged Out** ([@CharlVS], #2967) - Cleaner settings interface when not authenticated +- **Skeleton Loading for Address Lists** ([@CharlVS], #2990) - Better visual feedback while addresses are loading +- **EULA Formatting Improvements** ([@smk762], #2993) - Enhanced readability of End User License Agreement +- **Vertical Space Optimization** ([@CharlVS], #2988) - Reduced unnecessary vertical spacing for better content density +- **My Trades Tab Rename** ([@smk762], #2969) - Renamed "My Trades" tab to "Successful" for clarity +- **Trading History Filtering** ([@takenagain], #2856) - Combined search and filter functionality for better trading history navigation +- **Loading Messages for Wallets Page** ([@smk762], #2932) - Informative loading messages while wallet data loads +- **Trezor Login Loading Screen** ([@CharlVS], #2936) - Clear visual feedback during Trezor authentication +- **Portfolio Value Fix** ([@takenagain], #2883) - Corrected portfolio value display calculations +- **Custom Image Alignment** ([@smk762], #2873) - Improved alignment of custom images throughout the app +- **Multiple Asset Activation View** ([@CharlVS], #2860) - Enhanced interface for activating multiple assets simultaneously +- **Dropdown UI Consistency** ([@takenagain], #2849) - Standardized dropdown appearance and behavior +- **Close Dialog Button Accessibility** ([@smk762], #2852) - Improved accessibility for dialog close buttons +- **Swap Confirmation View Updates** ([@takenagain], #2847) - Clearer swap confirmation interface + +## 🐛 Bug Fixes + +- **ARRR Activation Crash** ([@takenagain], #3025) - Fixed application crashes when activating ARRR coin +- **ETH/AVX Transactions Visibility** ([@takenagain], #3033) - Restored missing ETH and AVX transactions in history +- **ETH Token Balance Display** ([@takenagain], #3033) - Fixed incorrect balance display for Ethereum tokens +- **Trezor Login Error Propagation** ([@CharlVS], #3019) - Proper error messages for Trezor authentication failures +- **HTTPS Asset URL Handling** ([@CharlVS], #2997) - Fixed loading of assets served over HTTPS +- **Coin Details Fiat Display** ([@smk762], #2995) - Corrected fiat value calculations on coin detail pages +- **HD Wallet Balance Calculation** ([@CharlVS], #3014) - Fixed balance aggregation for HD wallets +- **Mobile Hardware PIN Input** ([@CharlVS], #3011) - Resolved PIN entry issues on mobile devices +- **Transaction Fee Estimation** ([@takenagain], #3001) - Improved accuracy of fee calculations +- **App Name in Metadata** ([@CharlVS], #2981) - Consistent app naming across platforms +- **Notification Overlay Issues** ([@takenagain], #2975) - Fixed notification display problems +- **Wallet Name Update Persistence** ([@takenagain], #2963) - Wallet name changes now save correctly +- **Address Case Sensitivity** ([@CharlVS], #2959) - Proper handling of case-insensitive addresses +- **Send/Receive Tab Names** ([@smk762], #2935) - Fixed inconsistent tab labeling +- **P2P Price Issues** ([@takenagain], #2918) - Resolved peer-to-peer pricing discrepancies +- **HD Address Import Edge Cases** ([@takenagain], #2916) - Fixed issues with importing specific HD addresses +- **Receive Address Validation** ([@takenagain], #2914) - Improved address validation in receive flow +- **View-Only Hardware Wallet Login** ([@takenagain], #2910) - Fixed login issues for view-only hardware wallets +- **Multi-Path HD Address Display** ([@CharlVS], #2904) - Corrected display of addresses across multiple derivation paths +- **KDF Exit Shutdown** ([@takenagain], #2899) - Proper cleanup when closing the application +- **Address Comparison Logic** ([@CharlVS], #2898) - Fixed address matching for cross-chain comparisons +- **HD Address Visibility in Coin Details** ([@takenagain], #2906) - All HD addresses now visible in coin detail view +- **Web Pinch Zoom on Mobile** ([@smk762], #2880) - Disabled unwanted pinch zoom on mobile web +- **Selected Address Consistency** ([@takenagain], #2857) - Fixed issues with selected address persistence +- **Empty Wallet Creation** ([@takenagain], #2846) - Resolved problems creating wallets without initial coins +- **Hardware Wallet PIN Recovery** ([@CharlVS], #2845) - Fixed PIN entry after failed attempts +- **DEX Order Form Validation** ([@takenagain], #2844) - Improved order form field validation +- **Non-Hardware Wallet Login** ([@CharlVS], #2839) - Fixed standard wallet login regression +- **Orderbook Volume Display** ([@takenagain], #2827) - Corrected volume calculations in orderbook +- **Address Label Display** ([@takenagain], #2804) - Fixed missing address labels in various views +- **Protected Order Confirmation** ([@takenagain], #2790) - Fixed confirmation flow for protected orders + +## 💻 Platform-Specific Changes + +### Android + +- **APK Build Fix** ([@CharlVS], #2798) - Resolved Android build issues for APK generation +- **Icon Corrections** ([@smk762], #2784) - Fixed incorrect app icons on Android devices + +### Web + +- **Bonsai Table Performance** ([@CharlVS], #2894) - Optimized table rendering for better web performance +- **Mobile Pinch Zoom Control** ([@smk762], #2880) - Better touch control on mobile browsers + +### Desktop + +- **Window Close Confirmation** ([@CharlVS], #2853) - Added confirmation dialog before closing application + +## 🔧 Technical Improvements + +- **Dependency Updates** ([@CharlVS], #3034) - Updated flutter_secure_storage to version 11.1.0 +- **SDK Integration Updates** ([@CharlVS], #3036) - Critical KMD API pricing fix in SDK +- **CI Pipeline Improvements** ([@CharlVS]) - Enhanced continuous integration reliability +- **Test Coverage Expansion** ([@takenagain]) - Increased unit and integration test coverage + +## ⚠️ Known Issues + +- Some hardware wallet models may experience intermittent connection issues +- Large portfolios (>100 assets) may experience slower loading times +- Certain ERC-20 tokens may not display proper decimal precision + +**Full Changelog**: [0.9.1...0.9.2](https://github.com/KomodoPlatform/komodo-wallet/compare/0.9.1...0.9.2) + +--- + # Komodo Wallet v0.9.1 Release Notes This is a hotfix release that addresses critical issues with Trezor hardware wallet login functionality. @@ -20,69 +228,69 @@ The codebase is now unified across all platforms, including web, desktop, and mo ## 🚀 New Features -- **HD Address Management & Seed Import** ([@Tolga Ay], #2510) - Support for hierarchical deterministic wallets, allowing users to manage multiple addresses from a single seed phrase. -- **HD Withdrawals + Breaking SDK Changes** ([@Charl (Nitride)], #2520) - Send funds from HD wallets with updated SDK requirements for enhanced security and features. -- **HD Withdrawals & Portfolio Overview** ([@Charl (Nitride)], #2530) - Integrated HD wallet withdrawals with portfolio tracking for better fund management. -- **Cross-platform Fiat On-Ramp** ([@Francois], #170) - Purchase cryptocurrency with fiat currency across all supported platforms with an improved user experience. -- **Private Key Export** ([@Tolga Ay], #183) - Safely export your private keys for backup or use in other compatible wallets. -- **KDF SDK Integration Part 1** ([@Francois], #177 (and many more)) - Enhanced security with new key derivation functions in the SDK for better wallet protection. -- **System Time Check with World Time APIs** ([@Francois], #182) - Prevents transaction issues by ensuring your device clock is properly synchronized with global time standards. -- **Custom Token Import** ([@Francois], #2515) - Import custom tokens with an improved user interface and business logic implementation. +- **HD Address Management & Seed Import** ([@naezith], #2510) - Support for hierarchical deterministic wallets, allowing users to manage multiple addresses from a single seed phrase. +- **HD Withdrawals + Breaking SDK Changes** ([@CharlVS], #2520) - Send funds from HD wallets with updated SDK requirements for enhanced security and features. +- **HD Withdrawals & Portfolio Overview** ([@CharlVS], #2530) - Integrated HD wallet withdrawals with portfolio tracking for better fund management. +- **Cross-platform Fiat On-Ramp** ([@takenagain], #170) - Purchase cryptocurrency with fiat currency across all supported platforms with an improved user experience. +- **Private Key Export** ([@naezith], #183) - Safely export your private keys for backup or use in other compatible wallets. +- **KDF SDK Integration Part 1** ([@takenagain], #177 (and many more)) - Enhanced security with new key derivation functions in the SDK for better wallet protection. +- **System Time Check with World Time APIs** ([@takenagain], #182) - Prevents transaction issues by ensuring your device clock is properly synchronized with global time standards. +- **Custom Token Import** ([@takenagain], #2515) - Import custom tokens with an improved user interface and business logic implementation. - **Multi-address Faucet Support** ([@TazzyMeister], #2533) - Request test coins to multiple addresses from supported faucets for development and testing. -- **Reworked Unauthenticated Assets List** ([@Charl (Nitride)], #2579) - View available assets without logging in for better first-time user experience. -- **HD Wallet Address Selection for Fiat Onramp** ([@Francois], #2570) - Choose specific HD wallet addresses when purchasing crypto with fiat. -- **Internal Feedback Provider** ([@Charl (Nitride)], #2586) - Submit feedback directly from within the app for improved user support and issue reporting. -- **SDK Password Update Migration** ([@Charl (Nitride)], #2580) - Seamless migration to updated password handling in the SDK for better security. +- **Reworked Unauthenticated Assets List** ([@CharlVS], #2579) - View available assets without logging in for better first-time user experience. +- **HD Wallet Address Selection for Fiat Onramp** ([@takenagain], #2570) - Choose specific HD wallet addresses when purchasing crypto with fiat. +- **Internal Feedback Provider** ([@CharlVS], #2586) - Submit feedback directly from within the app for improved user support and issue reporting. +- **SDK Password Update Migration** ([@CharlVS], #2580) - Seamless migration to updated password handling in the SDK for better security. ## 🎨 UI/UX Improvements - **Aligned Column Headers** ([@TazzyMeister], #2577) - Consistent table layouts throughout the application for better readability. - **Localization of Hardcoded Strings** ([@TazzyMeister], #2587) - More text is now translatable, improving experience for international users. -- **Add Assets Coin List Loading Speed** ([@Francois], #2522) - Faster coin list loading when adding new assets to your portfolio. +- **Add Assets Coin List Loading Speed** ([@takenagain], #2522) - Faster coin list loading when adding new assets to your portfolio. - **Wallet Only Logout Confirmation** ([@naezith]) - Additional confirmation step when logging out to prevent accidental data loss. -- **Updated Segwit Badges** ([@Francois], #2545) - Clearer visual indicators for SegWit-compatible addresses and transactions. +- **Updated Segwit Badges** ([@takenagain], #2545) - Clearer visual indicators for SegWit-compatible addresses and transactions. - **Hide Incorrect Time Banner in Wallet-only Mode** ([@CharlVS]) - Removes unnecessary time warnings when operating in wallet-only mode. - **Wallet-only Mode Fixes** ([@CharlVS]) - Various improvements to the wallet-only experience for users who prefer simplified functionality. ## ⚡ Performance Enhancements -- **Coin List Loading Speed** ([@Francois], #2522) - Significantly faster loading of coin lists throughout the application. -- **System Health Check Time Providers** ([@Francois], #2611) - Optimized time synchronization checks for better performance and reliability. +- **Coin List Loading Speed** ([@takenagain], #2522) - Significantly faster loading of coin lists throughout the application. +- **System Health Check Time Providers** ([@takenagain], #2611) - Optimized time synchronization checks for better performance and reliability. ## 🐛 Bug Fixes -- **Fiat Onramp Banxa Flow** ([@Francois], #2608) - Resolved issues with Banxa integration for smoother fiat-to-crypto purchases. -- **DEX Buy Coin Dropdown Crash** ([@Francois], #2624) - Fixed application crashes when using the coin selection dropdown in DEX buy interface. -- **NFT v2 HD Wallet Support** ([@Francois], #2566) - Added compatibility for NFTs with hierarchical deterministic wallets. -- **Withdraw Form Validation and UI Updates** ([@Francois], #2583) - Improved form validation and user interface in the withdrawal process. -- **Coins Bloc Disabled Coins Reactivation** ([@Francois], #2584) - Fixed issues with reactivating previously disabled coins in the portfolio. -- **Transaction History Switching** ([@Francois], #2525) - Corrected problems when viewing transaction history across different coins. -- **Router Frozen Layout** ([@Francois], #2521) - Fixed navigation issues that caused the UI to freeze in certain scenarios. +- **Fiat Onramp Banxa Flow** ([@takenagain], #2608) - Resolved issues with Banxa integration for smoother fiat-to-crypto purchases. +- **DEX Buy Coin Dropdown Crash** ([@takenagain], #2624) - Fixed application crashes when using the coin selection dropdown in DEX buy interface. +- **NFT v2 HD Wallet Support** ([@takenagain], #2566) - Added compatibility for NFTs with hierarchical deterministic wallets. +- **Withdraw Form Validation and UI Updates** ([@takenagain], #2583) - Improved form validation and user interface in the withdrawal process. +- **Coins Bloc Disabled Coins Reactivation** ([@takenagain], #2584) - Fixed issues with reactivating previously disabled coins in the portfolio. +- **Transaction History Switching** ([@takenagain], #2525) - Corrected problems when viewing transaction history across different coins. +- **Router Frozen Layout** ([@takenagain], #2521) - Fixed navigation issues that caused the UI to freeze in certain scenarios. - **Receive Button UI Fix** ([@CharlVS]) - Resolved display issues with the receive payment button. -- **Coin Balance Calculation** ([@Francois]) - Fixed incorrect balance calculations for certain coins and tokens. -- **Electrum Activation Limit** ([@Francois], #195) - Addressed limitations with activating multiple Electrum-based coins. -- **Trezor HD Wallet Balance Status** ([@Francois], #194) - Fixed balance display issues for Trezor hardware wallets using HD addresses. -- **Zero Balance for Tokens Without Parent Coin Gas** ([@Tolga Ay], #186) - Corrected balance display for tokens when parent chain coins are unavailable for gas. -- **LP Tools UX** ([@Francois], #184) - Improved user experience for liquidity provider tools and functions. -- **Log Export Cross Platform** ([@Francois], #174) - Fixed log exporting functionality across all supported platforms. -- **OnPopPage Deprecated** ([@Tolga Ay], #172) - Updated code to remove usage of deprecated navigation methods. -- **DEX Swap URL Parameter Handling** ([@Tolga Ay], #162) - Fixed issues with DEX swap links and URL parameter processing. +- **Coin Balance Calculation** ([@takenagain]) - Fixed incorrect balance calculations for certain coins and tokens. +- **Electrum Activation Limit** ([@takenagain], #195) - Addressed limitations with activating multiple Electrum-based coins. +- **Trezor HD Wallet Balance Status** ([@takenagain], #194) - Fixed balance display issues for Trezor hardware wallets using HD addresses. +- **Zero Balance for Tokens Without Parent Coin Gas** ([@naezith], #186) - Corrected balance display for tokens when parent chain coins are unavailable for gas. +- **LP Tools UX** ([@takenagain], #184) - Improved user experience for liquidity provider tools and functions. +- **Log Export Cross Platform** ([@takenagain], #174) - Fixed log exporting functionality across all supported platforms. +- **OnPopPage Deprecated** ([@naezith], #172) - Updated code to remove usage of deprecated navigation methods. +- **DEX Swap URL Parameter Handling** ([@naezith], #162) - Fixed issues with DEX swap links and URL parameter processing. - many more minor fixes across the codebase. ## 🔒 Security Updates -- **Dependency Upgrades for Security Review** ([@Charl (Nitride)], #2589) - Updated libraries and dependencies to mitigate potential security vulnerabilities. +- **Dependency Upgrades for Security Review** ([@CharlVS], #2589) - Updated libraries and dependencies to mitigate potential security vulnerabilities. ## 💻 Platform-specific Changes ### iOS & macOS -- **Pod File Lock Updates** ([@Francois], #2594) - Updated dependency management for iOS and macOS builds to ensure compatibility. +- **Pod File Lock Updates** ([@takenagain], #2594) - Updated dependency management for iOS and macOS builds to ensure compatibility. ### Web/Desktop/Mobile -- **Build Workflow Upgrades** ([@Francois], #2528, #2531) - Improved build processes for all platforms for more reliable releases. -- **Docker and Dev Container Build Fixes** ([@Francois], #2542) - Fixed issues with Docker and development container environments. +- **Build Workflow Upgrades** ([@takenagain], #2528, #2531) - Improved build processes for all platforms for more reliable releases. +- **Docker and Dev Container Build Fixes** ([@takenagain], #2542) - Fixed issues with Docker and development container environments. ## ⚠️ Breaking Changes diff --git a/analysis_options.yaml b/analysis_options.yaml index 61d8cbb187..f42ea70c44 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,7 +7,14 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml +include: + - package:bloc_lint/recommended.yaml + - package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - "**/*.freezed.dart" + - "**/*.g.dart" linter: # The lint rules applied to this project can be customized in the diff --git a/android/app/build.gradle b/android/app/build.gradle index 5b67a6f4e4..650b99789a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -48,7 +48,7 @@ if (flutterVersionName == null) { android { namespace 'com.komodoplatform.atomicdex' - compileSdk 35 + compileSdk 36 compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 @@ -63,7 +63,7 @@ android { defaultConfig { applicationId "com.komodoplatform.atomicdex" minSdkVersion 28 - targetSdkVersion 35 + targetSdkVersion 36 versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 596a7db5bd..027009df09 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,7 +1,9 @@ - + - - + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index f30783b210..2de56f7773 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,7 @@ - - - + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index dfab2e2f45..66ba7c7185 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon_foreground.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon_foreground.png new file mode 100644 index 0000000000..2cb62a2d34 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/launcher_icon_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 2e892aa481..1ba747340a 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon_foreground.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon_foreground.png new file mode 100644 index 0000000000..58acfc54a8 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/launcher_icon_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 408327f72d..00baaaf395 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon_foreground.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon_foreground.png new file mode 100644 index 0000000000..2cd8ec747a Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/launcher_icon_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 8821d0e149..38ab3355f8 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon_foreground.png new file mode 100644 index 0000000000..48a445a2d0 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index 03417db507..9ccb009c89 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon_foreground.png new file mode 100644 index 0000000000..03522cd18d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon_foreground.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..458f64b828 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #FFFFFF + #FFFFFF + \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index d71047787f..3e781fbad9 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=8d97a97984f6cbd2b85fe4c60a743440a347544bf18818048e611f5288d46c94 -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip +distributionSha256Sum=8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/android/settings.gradle b/android/settings.gradle index 095777b4e3..d9923f9381 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -19,12 +19,14 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" // Gradle plugin version https://developer.android.com/build/releases/gradle-plugin - id "com.android.application" version "8.8.0" apply false - // START: FlutterFire Configuration - id "com.google.gms.google-services" version "4.3.15" apply false + // 8.13 and above requires a build target of 35 or higher, and at least one of + // our dependencies is currently targeting 34: file_picker + id "com.android.application" version "8.11.1" apply false + // START: FlutterFire Configuration + 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.1.10" apply false + id "org.jetbrains.kotlin.android" version "2.2.10" apply false } include ":app" \ No newline at end of file diff --git a/app_theme/pubspec.lock b/app_theme/pubspec.lock deleted file mode 100644 index e7be07d68b..0000000000 --- a/app_theme/pubspec.lock +++ /dev/null @@ -1,64 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - characters: - dependency: transitive - description: - name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.dev" - source: hosted - version: "0.11.1" - meta: - dependency: transitive - description: - name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c - url: "https://pub.dev" - source: hosted - version: "1.16.0" - plugin_platform_interface: - dependency: "direct main" - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" -sdks: - dart: ">=3.7.0-0 <4.0.0" - flutter: ">=3.32.5" diff --git a/app_theme/pubspec.yaml b/app_theme/pubspec.yaml index a1437999e4..c4e2bd83bb 100644 --- a/app_theme/pubspec.yaml +++ b/app_theme/pubspec.yaml @@ -3,9 +3,11 @@ description: App theme. version: 0.0.1 # homepage: +resolution: workspace + environment: - sdk: ">=3.6.0 <4.0.0" - flutter: ^3.32.5 + sdk: ">=3.8.1 <4.0.0" + flutter: ">=3.35.3 <4.0.0" dependencies: flutter: diff --git a/assets/app_icon/logo_icon.png b/assets/app_icon/logo_icon.png deleted file mode 100644 index 445e2ca862..0000000000 Binary files a/assets/app_icon/logo_icon.png and /dev/null differ diff --git a/assets/app_icon/logo_icon.webp b/assets/app_icon/logo_icon.webp new file mode 100644 index 0000000000..86232aa46b Binary files /dev/null and b/assets/app_icon/logo_icon.webp differ diff --git a/assets/translations/en.json b/assets/translations/en.json index fd2fd7b6ce..d83fa5e865 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -115,7 +115,7 @@ "walletCreationTitle": "Create wallet", "walletImportTitle": "Import wallet", "walletImportByFileTitle": "Importing seed phrase file", - "invalidWalletNameError": "Invalid wallet name, please remove special chars", + "invalidWalletNameError": "Invalid wallet name. Allowed: letters, numbers, spaces, underscores (_), hyphens (-)", "invalidWalletFileNameError": "Invalid filename, please rename it to remove special chars", "walletImportCreatePasswordTitle": "Create a password for \"{}\" wallet", "walletImportByFileDescription": "Create a password of your seed phrase file to decrypt it. This password will be used to log in to your wallet", @@ -127,10 +127,16 @@ "walletCreationUploadFile": "Upload seed phrase file", "walletCreationEmptySeedError": "Seed phrase should not be empty", "walletCreationExistNameError": "Wallet name exists", - "walletCreationNameLengthError": "Name length should be between 1 and 40", + "walletCreationNameLengthError": "Name must be 1–40 characters with no leading or trailing spaces", "walletCreationFormatPasswordError": "Password must contain at least 8 characters, with at least one digit, one lower-case, one upper-case and one special symbol. The password can't contain the same character 3 times in a row. The password can't contain the word 'password'", "walletCreationConfirmPasswordError": "Your passwords do not match. Please try again.", + "walletCreationNameCharactersError": "Name can contain letters, numbers, spaces, underscores (_), and hyphens (-)", + "renameWalletDescription": "Wallet name is invalid. Please enter a new name.", + "renameWalletConfirm": "Rename", "incorrectPassword": "Incorrect password", + "oneClickLogin": "Quick Login", + "quickLoginTooltip": "Tip: Save your password to your device's password manager for true one-click login.", + "quickLoginSubtitle": "Opens and pre-selects your wallet when starting the app.", "importSeedEnterSeedPhraseHint": "Enter seed phrase", "passphraseCheckingTitle": "Let's double check your seed phrase", "passphraseCheckingDescription": "Your seed phrase is important - that's why it is important to make sure it is saved correctly. We'll ask you to confirm the position of three words in your seed phrase to make sure it has been saved, so you'll be able to easily restore your wallet wherever and whenever you want.", @@ -206,7 +212,8 @@ "walletCreationBip39SeedError": "BIP39 seed phrase validation failed, try again or select 'Allow custom seed'", "walletCreationHdBip39SeedError": "Your input seed phrase is not BIP39 compliant, and can not be used in multi-address wallet mode. Please try again, or disable multi-address wallet mode and select 'Allow custom seed' to proceed.", "walletPageNoSuchAsset": "No assets match search criteria", - "swapCoin": "Swap", + "swap": "Swap", + "dexAddress": "DEX Address", "fiatBalance": "Fiat balance", "yourBalance": "Your balance", "all": "All", @@ -269,7 +276,6 @@ "buyPrice": "Buy price", "inProgress": "In Progress", "orders": "Orders", - "swap": "Swap", "percentFilled": "{}% filled", "orderType": "Order type", "recover": "Recover", @@ -300,9 +306,28 @@ "feedbackFormDescription": "Our mission to improve the wallet never stops, and your feedback is highly appreciated!", "feedbackFormThanksTitle": "Thank you for your feedback", "feedbackFormThanksDescription": "We will send a response to your email address as soon as possible", + "feedbackFormKindQuestion": "What kind of feedback do you want to give?", + "feedbackFormDescribeTitle": "Your feedback", + "feedbackFormContactRequired": "How can we contact you?", + "feedbackFormContactOptional": "How can we contact you? (Optional)", + "feedbackFormMessageHint": "Enter your feedback here...", + "feedbackFormBugReport": "Bug Report", + "feedbackFormFeatureRequest": "Feature Request", + "feedbackFormSupportRequest": "Support Request", + "feedbackFormOther": "Other", + "feedbackFormDiscord": "Discord", + "feedbackFormMatrix": "Matrix", + "feedbackFormTelegram": "Telegram", + "feedbackFormSelectContactMethod": "Select contact method", + "feedbackFormDiscordHint": "Discord username (e.g., username123)", + "feedbackFormMatrixHint": "Matrix ID (e.g., @user:matrix.org)", + "feedbackFormTelegramHint": "Telegram username (e.g., @username)", + "feedbackFormEmailHint": "Your email address", + "feedbackFormContactHint": "Enter your contact details", + "feedbackFormContactOptOut": "I don't want to share contact info", "email": "Email", "emailValidatorError": "Please enter a valid email address", - "contactRequiredError": "Contact details are required for support requests", + "contactRequiredError": "Please provide both contact method and details", "contactDetailsMaxLengthError": "Contact details must be {} characters or less", "discordUsernameValidatorError": "Please enter a valid Discord username (2-32 characters, letters, numbers, dots, underscores)", "telegramUsernameValidatorError": "Please enter a valid Telegram username (5-32 characters, letters, numbers, underscores)", @@ -335,7 +360,6 @@ "backupSeedPhrase": "Backup seed phrase", "seedOr": "OR", "seedDownload": "Download seed phrase", - "seedSaveAndRemember": "Save and remember", "seedIntroWarning": "This phrase is the main access to your\nassets, save and never share this phrase", "seedSettings": "Seed phrase", "errorDescription": "Error description", @@ -380,6 +404,9 @@ "dexErrorMessage": "Something went wrong!", "seedConfirmInitialText": "Enter the seed phrase", "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.", "usedSamePassword": "This password matches your current one. Please create a different password.", "passwordNotAccepted": "Password not accepted", "confirmNewPassword": "Confirm new password", @@ -432,6 +459,7 @@ "withdrawAmountTooLowError": "{} {} too low, you need > {} {} to send", "withdrawNoSuchCoinError": "Invalid selection, {} does not exist", "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", "txHistoryFetchError": "Error fetching tx history from the endpoint. Unsupported type: {}", "txHistoryNoTransactions": "Transactions are not available", @@ -441,7 +469,7 @@ "protocolNotSupported": "Protocol does not support multiple addresses", "derivationModeNotSupported": "Current wallet mode does not support multiple addresses", "hdWalletModeSwitchTitle": "Multi-address Wallet?", - "hdWalletModeSwitchSubtitle": "Enabling HD wallet allows you to create multiple addresses for each coin. However, your addresses and balances will change. You can easily switch between the modes when logging in.", + "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.", "noActiveWallet": "No active wallet - please sign in first", "memo": "Memo", @@ -462,6 +490,7 @@ "nothingFound": "Nothing found", "half": "Half", "max": "Max", + "exact": "Exact", "reactivating": "Reactivating", "weFailedCoinActivate": "We failed to activate {} :(", "failedActivate": "Failed to activate", @@ -481,10 +510,10 @@ "userActionRequired": "User action required", "unknown": "Unknown", "unableToActiveCoin": "Unable to activate {}", + "coinIsNotActive": "{} is not active", "feedback": "Feedback", "feedbackViewTitle": "Send us your feedback", "feedbackPageDescription": "Help us improve by sharing your suggestions, reporting bugs, or giving general feedback.", - "sendFeedbackButton": "Share your feedback", "feedbackThankyou": "Thank you for your feedback!", "feedbackError": "Failed to submit feedback", "selectAToken": "Select a token", @@ -679,7 +708,6 @@ "decimals": "Decimals", "onlySendToThisAddress": "Only send {} to this address", "scanTheQrCode": "Scan the QR code on any mobile device wallet", - "tradingAddress": "Trading Address", "addresses": "Addresses", "creating": "Creating", "createAddress": "Create Address", @@ -717,6 +745,7 @@ "trend7d": "7d trend", "tradingDisabledTooltip": "Trading features are currently disabled", "tradingDisabled": "Trading unavailable in your location", + "includeBlockedAssets": "Include blocked assets", "unbanPubkeysResults": "Unban Pubkeys Results", "unbannedPubkeys": { "zero": "{} Unbanned Pubkeys", @@ -737,5 +766,32 @@ "fetchingPrivateKeysTitle": "Fetching Private Keys...", "fetchingPrivateKeysMessage": "Please wait while we securely fetch your private keys...", "pubkeyType": "Type", - "securitySettings": "Security Settings" + "securitySettings": "Security Settings", + "zhtlcConfigureTitle": "Configure {} chain sync", + "zhtlcZcashParamsPathLabel": "Zcash parameters path", + "zhtlcPathAutomaticallyDetected": "Path automatically detected", + "zhtlcSaplingParamsFolder": "Folder containing sapling params", + "zhtlcBlocksPerIterationLabel": "Blocks per iteration", + "zhtlcScanIntervalLabel": "Scan interval (ms)", + "zhtlcStartSyncFromLabel": "Start chain sync from:", + "zhtlcEarliestSaplingOption": "Earliest (sapling)", + "zhtlcBlockHeightOption": "Block height", + "zhtlcShieldedAddress": "Shielded", + "zhtlcDateTimeOption": "Date & Time", + "zhtlcSelectDateTimeLabel": "Select date & time", + "zhtlcZcashParamsRequired": "Zcash params path is required", + "zhtlcInvalidBlockHeight": "Enter a valid block height", + "zhtlcSelectDateTimeRequired": "Please select a date and time", + "zhtlcDownloadingZcashParams": "Downloading Zcash Parameters", + "zhtlcPreparingDownload": "Preparing download...", + "zhtlcErrorSettingUpZcash": "Error setting up Zcash parameters: {}", + "zhtlcDateSyncHint": "Selecting a date further in the past can significantly increase the activation time. \nActivation can take a little while the first time to download block cache data.\n\nTransactions and balance prior to the sync date may be missing.\nOften this can be restored by sending in and out new transactions", + "zhtlcActivating": { + "one": "Activating ZHTLC coin. Please do not close the app or tab until complete.", + "other": "Activating ZHTLC coins. Please do not close the app or tab until complete." + }, + "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" } diff --git a/contrib/README.md b/contrib/README.md new file mode 100644 index 0000000000..05076d59ef --- /dev/null +++ b/contrib/README.md @@ -0,0 +1,147 @@ +# Contrib Scripts + +This directory contains utility scripts for building, packaging, and testing the Komodo Wallet application. + +## Scripts Overview + +### `make-dmg.sh` +Creates a professional DMG installer for macOS applications. + +**Purpose:** Builds a disk image (.dmg) file with proper layout for distributing macOS applications. + +**Features:** +- Creates a disk image with the application bundle +- Adds a shortcut to the Applications folder +- Supports custom background images +- Configures proper Finder window layout and icon positioning +- Compresses the final output + +**Usage:** +```bash +# Use default app path +./make-dmg.sh + +# Or specify custom parameters +APP="build/.../Komodo Wallet.app" \ +VOL="Komodo Wallet" \ +OUT="dist/KomodoWallet.dmg" \ +BG="assets/dmg_background.png" \ +./make-dmg.sh +``` + +**Default Parameters:** +- `APP`: `build/macos/Build/Products/Release-production/Komodo Wallet.app` +- `VOL`: `Komodo Wallet` +- `OUT`: `dist/KomodoWallet.dmg` +- `BG`: (optional background image) +- `ICON_SIZE`: `128` +- `WIN_W`: `530` (Finder window width) +- `WIN_H`: `400` (Finder window height) + +**Requirements:** macOS, hdiutil, osascript, ditto + +--- + +### `test-sign-timestamp.sh` +Checks code signing and timestamping for macOS applications. + +**Purpose:** Verifies that all executable Mach-O files in the app bundle are properly signed and timestamped. + +**Features:** +- Scans all executable files in the app bundle +- Checks for valid code signatures +- Verifies timestamping (Apple's timestamp authority) +- Provides colored output for easy reading +- Reports missing timestamps + +**Usage:** +```bash +# Use default app path +./test-sign-timestamp.sh + +# Or specify custom app path +./test-sign-timestamp.sh "path/to/your/app.app" +``` + +**Default App Path:** `build/macos/Build/Products/Release-production/Komodo Wallet.app` + +**Requirements:** macOS, codesign utility + + +## Release Build and Notarization Process for macOS (Non-App Store Distribution) + +To build and notarize the macOS app for release (for distribution outside the Mac App Store), follow these steps. +Note: The `--flavor production` flag is mandatory in the build command to ensure correct provisioning for signing with a Developer ID Application certificate and for proper notarization/distribution outside the App Store. + +```bash +# 1. Clean the project and fetch dependencies +flutter clean +flutter pub get --enforce-lockfile +pushd macos; pod deintegrate; pod install; popd # Generating Pods project + +# 2. Fetch all required artifacts (this runs komodo_wallet_build_transformer) +flutter build web --no-pub -v + +# 3. Build the macOS release application (ensure --flavor production is present) +flutter build macos --no-pub --release -v \ + --dart-define=COMMIT_HASH= \ + --dart-define=FEEDBACK_API_KEY= \ + --dart-define=FEEDBACK_PRODUCTION_URL= \ + --dart-define=TRELLO_BOARD_ID= \ + --dart-define=TRELLO_LIST_ID= \ + --dart-define=MATOMO_URL= \ + --dart-define=MATOMO_SITE_ID= \ + --flavor production +``` +Replace the `<...>` placeholders above with your actual values. + +To view app entitlements used in the resulting .app: +```bash +codesign -d --entitlements :- "build/macos/Build/Products/Release-production/Komodo Wallet.app" | plutil -p - +``` + +Package the application bundle as a ZIP archive (required by notary service): +```bash +APP="build/macos/Build/Products/Release-production/Komodo Wallet.app" +ZIP="KomodoWallet.zip" +ditto -c -k --keepParent "$APP" "$ZIP" +``` + +Submit the ZIP archive to Apple Notary Service: +```bash +xcrun notarytool submit "$ZIP" --keychain-profile "AC_NOTARY" --wait +``` + +Check notarization status for a specific request: +```bash +xcrun notarytool info \ + --apple-id "" \ + --team-id "" \ + --password "" +``` + +Download notarization log: +```bash +xcrun notarytool log --keychain-profile AC_NOTARY > notarization_errors.json +``` + +If there were no errors and the submission status was `Accepted`, you can proceed with the following steps to staple and validate the app: + +```bash +xcrun stapler staple "$APP" +xcrun stapler validate "$APP" +spctl --assess --type execute -vv "$APP" # now the status should be accepted +``` + + + + + + + + + + + + + diff --git a/contrib/make-dmg.sh b/contrib/make-dmg.sh new file mode 100755 index 0000000000..ec26c01f30 --- /dev/null +++ b/contrib/make-dmg.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# make_dmg.sh — Build DMG with layout «App ⇢ Applications» +# +# This script creates a professional DMG installer for macOS applications. +# It creates a disk image with: +# - The application bundle +# - A shortcut to Applications folder +# - Custom background image (optional) +# - Proper Finder window layout and icon positioning +# - Compressed final output +# +# Requires: macOS, hdiutil, osascript, ditto. + +set -euo pipefail + +# ------------------------ PARAMETERS ------------------------ +# Default app path +DEFAULT_APP="build/macos/Build/Products/Release-production/Komodo Wallet.app" + +APP="${APP:-$DEFAULT_APP}" # Path to .app (uses default if not specified) +VOL="${VOL:-Komodo Wallet}" # Volume/window name for DMG +OUT="${OUT:-dist/KomodoWallet.dmg}" # Path to output .dmg +BG="${BG:-}" # Path to PNG background (optional) +ICON_SIZE="${ICON_SIZE:-128}" # Icon size +WIN_W="${WIN_W:-530}" # Finder window width in DMG +WIN_H="${WIN_H:-400}" # Finder window height in DMG +APP_X="${APP_X:-120}" # .app icon position (x) +APP_Y="${APP_Y:-200}" # .app icon position (y) +APPS_X="${APPS_X:-400}" # Applications shortcut position (x) +APPS_Y="${APPS_Y:-200}" # Applications shortcut position (y) + +usage() { + cat <&2 "ERROR: .app not found: ${APP}"; exit 1; } + +APP_BASENAME="$(basename "${APP}")" +OUT_DIR="$(dirname "${OUT}")" +mkdir -p "${OUT_DIR}" + +# Work in local tmp inside project — fewer TCC issues +TMPROOT="${TMPROOT:-$PWD/.dmg_tmp}" +mkdir -p "$TMPROOT" +TMPDIR="$(mktemp -d "$TMPROOT/tmp.XXXXXXXX")" +STAGING="${TMPDIR}/staging" +mkdir -p "${STAGING}" + +cleanup() { + set +e + if [[ -n "${MOUNT_POINT:-}" && -d "${MOUNT_POINT:-}" ]]; then + hdiutil detach "${MOUNT_POINT}" -quiet || true + fi + rm -rf "${TMPDIR}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "==> Preparing staging" +cp -R "${APP}" "${STAGING}/" +ln -s /Applications "${STAGING}/Applications" + +if [[ -n "${BG}" ]]; then + echo "==> Adding background ${BG}" + mkdir -p "${STAGING}/.background" + cp "${BG}" "${STAGING}/.background/background.png" + chflags hidden "${STAGING}/.background" || true +fi + +# --------- IMAGE SIZE CALCULATION (with margin) ---------- +echo "==> Estimating image size" +# Staging size in kilobytes +SIZE_KB=$(du -sk "${STAGING}" | awk '{print $1}') +# Add ~30% margin + minimum 20 MB +HEADROOM_KB=$(( SIZE_KB / 3 )) +[[ ${HEADROOM_KB} -lt 20480 ]] && HEADROOM_KB=20480 +TOTAL_KB=$(( SIZE_KB + HEADROOM_KB )) +# Round to megabytes +TOTAL_MB=$(( (TOTAL_KB + 1023) / 1024 )) +echo " Estimated size: ${TOTAL_MB} MiB" + +TMP_DMG="${TMPDIR}/tmp.dmg" +FINAL_DMG="${OUT}" + +echo "==> Creating empty RW DMG (${TOTAL_MB} MiB)" +hdiutil create -verbose \ + -size "${TOTAL_MB}m" \ + -fs HFS+J -volname "${VOL}" \ + "${TMP_DMG}" + +# if volume with same name is mounted — unmount it +if [[ -d "/Volumes/${VOL}" ]]; then + hdiutil detach "/Volumes/${VOL}" -force -quiet || true +fi + +echo "==> Mounting DMG" +MOUNT_POINT="${TMPDIR}/mnt" +mkdir -p "${MOUNT_POINT}" + +# Mount directly to our directory +if ! hdiutil attach "${TMP_DMG}" \ + -readwrite -noverify -noautoopen \ + -mountpoint "${MOUNT_POINT}" >/dev/null; then + echo >&2 "ERROR: failed to mount DMG (attach returned error)" + exit 1 +fi + +# Check that it's actually mounted +if [[ ! -d "${MOUNT_POINT}" || ! -e "${MOUNT_POINT}/." ]]; then + echo >&2 "ERROR: failed to mount DMG (mountpoint not accessible)" + exit 1 +fi +echo " Mounted at: ${MOUNT_POINT}" + +# --------- CONTENT COPYING (DITTO) ---------- +echo "==> Copying content to volume (ditto)" +# Application +ditto "${STAGING}/${APP_BASENAME}" "${MOUNT_POINT}/${APP_BASENAME}" +# Applications shortcut (recreate on volume side) +rm -f "${MOUNT_POINT}/Applications" 2>/dev/null || true +ln -s /Applications "${MOUNT_POINT}/Applications" +# Background (if exists) +if [[ -f "${STAGING}/.background/background.png" ]]; then + mkdir -p "${MOUNT_POINT}/.background" + ditto "${STAGING}/.background/background.png" "${MOUNT_POINT}/.background/background.png" + chflags hidden "${MOUNT_POINT}/.background" || true +fi + +# --------- FINDER WINDOW STYLING ---------- +echo "==> Setting up Finder layout" +sleep 2 # give Finder a bit more time to see the mounted volume +osascript < Unmounting DMG" +for i in {1..5}; do + if hdiutil detach "${MOUNT_POINT}" -quiet; then + DETACHED=1 + break + fi + echo " Retry attempt (${i})..." + sleep 1 +done +[[ -z "${DETACHED:-}" ]] && { echo >&2 "ERROR: failed to unmount ${MOUNT_POINT}"; exit 1; } + +# Remove old DMG if it already exists +if [[ -f "${FINAL_DMG}" ]]; then + echo "==> Removing old file ${FINAL_DMG}" + rm -f "${FINAL_DMG}" +fi + +echo "==> Converting to compressed UDZO" +hdiutil convert -verbose "${TMP_DMG}" -format UDZO -imagekey zlib-level=9 -o "${FINAL_DMG}" + +echo "==> Done: ${FINAL_DMG}" \ No newline at end of file diff --git a/contrib/test-sign-timestamp.sh b/contrib/test-sign-timestamp.sh new file mode 100755 index 0000000000..b1f3f084d4 --- /dev/null +++ b/contrib/test-sign-timestamp.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# test-sign-timestamp.sh — Check code signing and timestamping for macOS app +# Usage: ./contrib/test-sign-timestamp.sh [APP_PATH] +# If APP_PATH is not provided, uses default build path + +set -euo pipefail + +# Default app path +DEFAULT_APP="build/macos/Build/Products/Release-production/Komodo Wallet.app" + +# Parse command line arguments +APP="${1:-$DEFAULT_APP}" + +# Color codes for output +RED='\033[0;31m'; GRN='\033[0;32m'; YEL='\033[0;33m'; BLU='\033[0;34m'; NC='\033[0m' + +# Welcome message +echo -e "${BLU}========================================${NC}" +echo -e "${BLU} Code Signing & Timestamp Checker${NC}" +echo -e "${BLU}========================================${NC}" +echo "" +echo -e "Checking app: ${YEL}$APP${NC}" +echo "" + +# Check if app exists +if [[ ! -d "$APP" ]]; then + echo -e "${RED}ERROR: App not found at: $APP${NC}" + echo "" + echo "Usage: $0 [APP_PATH]" + echo " APP_PATH - Path to the .app bundle to check" + echo " If not provided, uses: $DEFAULT_APP" + exit 1 +fi + +# Searching for all executable Mach-O files (+x) +while IFS= read -r -d '' f; do + if file -b "$f" | grep -q 'Mach-O'; then + echo "==> $f" + INFO="$(LC_ALL=C /usr/bin/codesign -d --verbose=4 "$f" 2>&1 || true)" + if echo "$INFO" | grep -q '^[[:space:]]*Timestamp='; then + TS="$(echo "$INFO" | sed -n 's/^[[:space:]]*Timestamp=//p' | head -n1)" + echo -e " ${GRN}✔ Signed + timestamp${NC} ($TS)" + + # On newer systems, the 'Timestamp Authority=' line is often missing. + # If you really need to check the TSA, look for 'Apple' in the certificate chain. + if echo "$INFO" | grep -q 'Authority=.*Apple'; then + : # All good, timestamp is most likely from Apple + else + echo -e " ${YEL}▲ Timestamp present, but TSA line not shown by 'codesign' (this is normal).${NC}" + fi + else + echo -e " ${RED}✖ Signed, but NO timestamp${NC}" + fi + fi +done < <(find "$APP" -type f -perm -111 -print0) +# Additional verification for the entire .app bundle: +echo "" +echo -e "${BLU}Performing deep signature verification of the .app bundle...${NC}" +/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP" + +echo "" +echo -e "${BLU}Gatekeeper assessment (spctl) for the .app bundle...${NC}" +/usr/sbin/spctl --assess --type execute -vv "$APP" + +echo "" +echo -e "${BLU}========================================${NC}" +echo -e "${BLU} Check completed${NC}" +echo -e "${BLU}========================================${NC}" diff --git a/docs/ANALYTICS.md b/docs/ANALYTICS.md index aad9797070..befec9244b 100644 --- a/docs/ANALYTICS.md +++ b/docs/ANALYTICS.md @@ -7,10 +7,9 @@ This document describes how to use the analytics system in the application. The analytics system is designed with a clear separation of concerns: 1. `AnalyticsBloc` - Manages the user preference for whether analytics are enabled or disabled -2. `AnalyticsRepo` - Repository layer for logging analytics events +2. `AnalyticsRepository` - Aggregates and manages multiple analytics providers 3. `AnalyticsEvents` - Event data structures -4. `AnalyticsLogger` - Core logger that handles actual event logging -5. `AnalyticsService` - Interface for analytics providers (Firebase, etc.) +4. `AnalyticsApi` providers - Concrete providers (e.g., `FirebaseAnalyticsApi`, `MatomoAnalyticsApi`) ## Event Organization @@ -95,7 +94,7 @@ To add a new analytics event: 1. **Create an Event Data Class**: ```dart - class NewFeatureEventData implements AnalyticsEventData { + class NewFeatureEventData extends AnalyticsEventData { const NewFeatureEventData({ required this.featureId, required this.actionType, @@ -427,6 +426,45 @@ This analytics implementation uses Firebase Analytics as the default provider. T The setup includes generating necessary configuration files for each platform (iOS, Android, web, etc.) and integrating them into the project. +## Multi‑provider architecture and runtime flags + +The app can send analytics to multiple providers simultaneously via `AnalyticsRepository`. + +- Providers are registered and initialized in `AnalyticsRepository` + - Firebase: always registered + - Matomo: registered when both `MATOMO_URL` and `MATOMO_SITE_ID` are provided +- Providers are enabled/disabled at runtime based on user preference and CI/privacy flags + +Runtime flags (defined in `lib/shared/constants.dart`) and typical usage: + +```bash +# Disable analytics entirely (e.g. CI, tests, privacy-first builds) +flutter run \ + --dart-define=ANALYTICS_DISABLED=true + +# Mark CI environment (implicitly disables analytics in code) +flutter run \ + --dart-define=CI=true + +# Enable Matomo provider and configure endpoint +flutter run \ + --dart-define=MATOMO_URL=https://your-matomo.example.com/ \ + --dart-define=MATOMO_SITE_ID=1 +``` + +Notes: + +- Matomo requires both `MATOMO_URL` and `MATOMO_SITE_ID`. Without them, it stays disabled. + +See the Matomo setup guide for full details: `/docs/MATOMO_SETUP.md`. + +## Queue persistence and provider activation + +- Providers manage their own event queues. +- When analytics is disabled (user opt‑out or CI), events are queued. +- On activation, queued events are flushed. +- The Matomo provider periodically persists its queue to `SharedPreferences` and restores it on startup to prevent data loss across app restarts. + ## Data Sync Events Example The `CoinsDataUpdatedEventData` class is an example of a data sync event that tracks when coin configuration data is refreshed: diff --git a/docs/ANALYTICS_EVENT_IMPLEMENTATION_PLAN.md b/docs/ANALYTICS_EVENT_IMPLEMENTATION_PLAN.md index 000e650720..0641113e5c 100644 --- a/docs/ANALYTICS_EVENT_IMPLEMENTATION_PLAN.md +++ b/docs/ANALYTICS_EVENT_IMPLEMENTATION_PLAN.md @@ -49,9 +49,11 @@ The table below mirrors the CSV with an expanded explanation. | `error_displayed` | Global error dialogs | Send when error dialog is shown to user. | | `app_share` | Share/referral actions | Emit when user shares app via share sheet. | | `hd_address_generated` | Receive page address generation | Log when new HD receive address derived. | -| `scroll_attempt_outside_content` | Scrollable widgets | Trigger when user tries to scroll while pointer outside list bounds. | | `wallet_list_half_viewport` | Coins list performance metric | Record time until wallet list scrolls halfway on first load. | | `coins_data_updated` | Coins data refresh | Send when price/metadata update completes at launch. | | `searchbar_input` | Coin search field | Emit on search submission with query stats. | | `theme_selected` | Theme selection page | Log when user chooses light/dark/auto theme. | | `page_interactive_delay` | Page load performance | Record time until spinner hidden after page open. | + +> **Note:** The `scroll_attempt_outside_content` event has been temporarily removed and will be re-implemented when the updated UX +> instrumentation is ready. diff --git a/docs/BUILD_RELEASE.md b/docs/BUILD_RELEASE.md index 29cceb689f..b8dd63a286 100644 --- a/docs/BUILD_RELEASE.md +++ b/docs/BUILD_RELEASE.md @@ -40,6 +40,14 @@ flutter build apk ## Docker builds +Prerequisite (ensure SDK submodule is initialized to the pinned commit): + +```bash +git submodule update --init --recursive +# Recommended once per clone to auto-fetch pinned commits on branch switches +git config fetch.recurseSubmodules on-demand +``` + ### Build for web ```bash diff --git a/docs/BUILD_RUN_APP.md b/docs/BUILD_RUN_APP.md index 68332e4c24..3767737663 100644 --- a/docs/BUILD_RUN_APP.md +++ b/docs/BUILD_RUN_APP.md @@ -30,6 +30,57 @@ flutter build ios ---- +## Ruby setup + +On macOS, Ruby is required for CocoaPods and Xcode tooling. Install Ruby 3.0+ (recommended: 3.4.x) using one of these version managers: + +- rbenv (recommended): lightweight, uses shims. Install via Homebrew and ruby-build. + + ```bash + brew install rbenv + rbenv init # detects current shell and adds init command to shell config + source ~/.zshrc || source ~/.bash_profile + rbenv install 3.4.5 + rbenv global 3.4.5 + ruby -v + ``` + +- RVM: full-featured manager with gemsets. + + ```bash + \curl -sSL https://get.rvm.io | bash -s stable --ruby + rvm install 3.4.5 + rvm use 3.4.5 --default + ruby -v + ``` + +- chruby (with ruby-install): minimal, simple switching. + + ```bash + brew install chruby ruby-install + echo 'source /opt/homebrew/opt/chruby/share/chruby/chruby.sh' >> ~/.zshrc + echo 'source /opt/homebrew/opt/chruby/share/chruby/auto.sh' >> ~/.zshrc + source ~/.zshrc + ruby-install ruby 3.4.5 + chruby 3.4.5 + ruby -v + ``` + +You may also use alternatives like asdf or mise; ensure Ruby 3.0+. + +## CocoaPods installation + +CocoaPods 1.15+ is required for iOS/macOS builds with the latest Xcode. + +NOTE: preferably do not install CocoaPods using `sudo`, as it may lead to permission issues. + +```bash +gem install cocoapods +pod --version +``` + +If you installed Ruby via a version manager, ensure the selected Ruby is active in your shell before installing CocoaPods. + ## Target platforms ### Web @@ -65,6 +116,8 @@ flutter run -d web-server --web-port=8080 In order to build for macOS, you need to use a macOS host. +Prerequisites (macOS): Ensure Ruby 3.0+ and CocoaPods 1.15+ are installed. See [Ruby setup](#ruby-setup) and [CocoaPods installation](#cocoapods-installation). + Before you begin: 1. Open `macos/Runner.xcworkspace` in XCode @@ -277,6 +330,8 @@ flutter build appbundle In order to build for iOS/iPadOS, you need to use a macOS host (Apple silicon recommended) Physical iPhone or iPad required, simulators are not yet supported. +Prerequisites (macOS): Ensure Ruby 3.0+ and CocoaPods 1.15+ are installed. See [Ruby setup](#ruby-setup) and [CocoaPods installation](#cocoapods-installation). + 1. `flutter clean` 2. `flutter pub get` 3. Connect your device to your Mac with a USB cable diff --git a/docs/CLONE_REPOSITORY.md b/docs/CLONE_REPOSITORY.md index 309e5ce5fc..64a3ad17cf 100644 --- a/docs/CLONE_REPOSITORY.md +++ b/docs/CLONE_REPOSITORY.md @@ -2,21 +2,61 @@ There are two options, cloning via HTTPS or via SSH. HTTPS is recommended. -If using HTTPS, run +## HTTPS Clone (Recommended) + +If using HTTPS, run: ```bash git clone https://github.com/KomodoPlatform/komodo-wallet.git +cd komodo-wallet +git submodule update --init --recursive ``` -Alternatively, instruct the IDE to clone: `https://github.com/KomodoPlatform/komodo-wallet.git` +Alternatively, you can clone with submodules in one command: + +```bash +git clone --recurse-submodules https://github.com/KomodoPlatform/komodo-wallet.git +``` -Note that authentication with your password is not possible due to 2FA authentication and repository visibility. -Consider setting up GitHub integration on your IDE or using a personal [access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token). +## SSH Clone For SSH cloning you need to [setup SSH authentication](https://docs.github.com/en/authentication/connecting-to-github-with-ssh) properly. Then you should be able to run: ```bash -cd ~ +git clone --recurse-submodules git@github.com:KomodoPlatform/komodo-wallet.git +``` + +Or if you already cloned without submodules: + +```bash git clone git@github.com:KomodoPlatform/komodo-wallet.git +cd komodo-wallet +git submodule update --init --recursive +``` + +## IDE Integration + +**Important**: After cloning via IDE, you must initialize the submodules manually: + +```bash +cd komodo-wallet +git submodule update --init --recursive ``` + +## Verifying Submodule Setup + +After cloning, verify that the SDK submodule was initialized correctly: + +```bash +ls -la sdk/ +``` + +You should see the komodo-defi-sdk-flutter repository contents in the `sdk/` directory. + +## Next Steps + +After successfully cloning and initializing submodules: + +1. Follow the [Project Setup](PROJECT_SETUP.md) guide for environment configuration +2. Review [SDK Submodule Management](SDK_SUBMODULE_MANAGEMENT.md) for working with the SDK diff --git a/docs/FIREBASE_SETUP.md b/docs/FIREBASE_SETUP.md index 8b8459acd3..8325a111da 100644 --- a/docs/FIREBASE_SETUP.md +++ b/docs/FIREBASE_SETUP.md @@ -17,3 +17,15 @@ git update-index --assume-unchanged ios/firebase_app_id_file.json git update-index --assume-unchanged macos/firebase_app_id_file.json git update-index --assume-unchanged lib/firebase_options.dart ``` + +## Notes + +- Firebase is the default analytics provider and is always registered by the app. +- Analytics collection can be disabled globally using runtime flags (applies to all providers): + +```bash +--dart-define=ANALYTICS_DISABLED=true +--dart-define=CI=true +``` + +See `/docs/ANALYTICS.md` for multi‑provider details, and `/docs/MATOMO_SETUP.md` for enabling Matomo alongside Firebase. diff --git a/docs/FLUTTER_VERSION.md b/docs/FLUTTER_VERSION.md index f77d778ef2..725b92df3e 100644 --- a/docs/FLUTTER_VERSION.md +++ b/docs/FLUTTER_VERSION.md @@ -2,7 +2,7 @@ ## Supported Flutter Version -This project supports Flutter `3.32.5` (latest stable release). We aim to keep the project up-to-date with the most recent stable Flutter versions. +This project supports Flutter `3.35.3` (latest stable release). 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.32.5 +- Known issues with `flutter pub get` when using Flutter 3.35.3 - Difficulty switching between versions for different projects If you still choose to use this method, you can run: ```bash cd ~/flutter -git checkout 3.32.5 +git checkout 3.35.3 flutter doctor ``` diff --git a/docs/MATOMO_SETUP.md b/docs/MATOMO_SETUP.md new file mode 100644 index 0000000000..dea7246e46 --- /dev/null +++ b/docs/MATOMO_SETUP.md @@ -0,0 +1,59 @@ +# Matomo setup + +This guide describes how to enable and configure the optional Matomo Analytics provider alongside Firebase. + +## 1. Prerequisites + +- A Matomo instance URL (self‑hosted or Matomo Cloud) +- A site ID configured in Matomo for the wallet app + +## 2. Enable Matomo provider via build flags + +Matomo is enabled automatically when required configuration is provided. Pass the following `--dart-define` flags: + +```bash +flutter run \ + --dart-define=MATOMO_URL=https://your-matomo.example.com/matomo.php \ + --dart-define=MATOMO_SITE_ID=1 +``` + +Notes: + +- Both `MATOMO_URL` and `MATOMO_SITE_ID` must be provided, otherwise Matomo is disabled. +- The `MATOMO_URL` should include the complete endpoint path (e.g., `/matomo.php`). + +## 3. CI and privacy controls + +The app disables analytics collection in CI or when explicitly configured: + +```bash +# Disable analytics globally (builds/tests) +--dart-define=ANALYTICS_DISABLED=true + +# Mark CI environment (code will also disable analytics) +--dart-define=CI=true +``` + +These flags apply to all providers (Firebase and Matomo). + +## 4. Queueing and persistence + +- When analytics is disabled, events are queued in memory. +- The Matomo provider periodically persists its queue to `SharedPreferences` and restores it on app start. +- After re‑enabling analytics, queued events are flushed. + +## 5. Verifying events locally (debug builds) + +- Run the app with Matomo flags and interact with the app. +- Check debug logs for lines like: + +``` +Matomo Analytics Event: ; Parameters: { ... } +``` + +## 6. Troubleshooting + +- Ensure `MATOMO_URL` includes the complete endpoint path (e.g., `/matomo.php`). +- Verify the site ID exists and is active in your Matomo instance. +- Confirm `ANALYTICS_DISABLED` is not set to `true` for local runs. +- In CI, analytics will be disabled by default due to the `CI` flag. diff --git a/docs/MULTIPLE_FLUTTER_VERSIONS.md b/docs/MULTIPLE_FLUTTER_VERSIONS.md index 530bd2b4f2..561083386a 100644 --- a/docs/MULTIPLE_FLUTTER_VERSIONS.md +++ b/docs/MULTIPLE_FLUTTER_VERSIONS.md @@ -57,11 +57,12 @@ sudo pacman -R flutter # for Arch 2. Launch Flutter Sidekick -3. Click on "Versions" in the sidebar and download Flutter version `3.32.5` +3. Click on "Versions" in the sidebar and download Flutter version `3.35.3` 4. Set this version as the global default by clicking the "Set as Global" button 5. Add Flutter to your PATH: + - Click on "Settings" in the sidebar - Find the path to the FVM installation directory (typically `~/.fvm/default/bin` on macOS/Linux or `%LOCALAPPDATA%\fvm\default\bin` on Windows) - Add this path to your system's PATH environment variable @@ -91,11 +92,11 @@ sudo pacman -R flutter # for Arch curl -fsSL https://fvm.app/install.sh | bash ``` -2. Install and use Flutter 3.32.5: +2. Install and use Flutter 3.35.3: ```bash - fvm install 3.32.5 - fvm global 3.32.5 + fvm install 3.35.3 + fvm global 3.35.3 ``` 3. Add FVM's default Flutter version to your PATH by adding the following to your `~/.bashrc`, `~/.zshrc`, or equivalent: @@ -130,14 +131,15 @@ sudo pacman -R flutter # for Arch choco install fvm ``` -3. Install and use Flutter 3.32.5: +3. Install and use Flutter 3.35.3: ```powershell - fvm install 3.32.5 - fvm global 3.32.5 + fvm install 3.35.3 + fvm global 3.35.3 ``` 4. Add FVM's Flutter version to your PATH: + - Open "Edit environment variables for your account" - Edit the Path variable - Add `%LOCALAPPDATA%\fvm\default\bin` @@ -156,7 +158,7 @@ To use a specific Flutter version for a project: 2. Run: ```bash - fvm use 3.32.5 + fvm use 3.35.3 ``` This will create a `.fvmrc` file in your project, which specifies the Flutter version to use for this project. @@ -174,7 +176,7 @@ For optimal integration with VS Code: ```json { - "dart.flutterSdkPath": ".fvm/flutter_sdk", + "dart.flutterSdkPath": ".fvm/flutter_sdk" // Or if you're using a global FVM version: // "dart.flutterSdkPath": "${userHome}/.fvm/default/bin/flutter" } diff --git a/docs/PROJECT_SETUP.md b/docs/PROJECT_SETUP.md index 56aa5a84bd..2073b564f9 100644 --- a/docs/PROJECT_SETUP.md +++ b/docs/PROJECT_SETUP.md @@ -13,31 +13,40 @@ Komodo Wallet is a cross-platform application, meaning it can be built for multi 1. [Install Flutter, pin Flutter version](INSTALL_FLUTTER.md) 2. Install IDEs - [VS Code](https://code.visualstudio.com/) - - install and enable `Dart` and `Flutter` extensions - - enable `Dart: Use recommended settings` via the Command Pallette - - [Android Studio](https://developer.android.com/studio) - Ladybug | 2024.2.2 - - install and enable `Dart` and `Flutter` plugins - - SDK Manager -> SDK Tools: - - [x] Android SDK Build-Tools - (latest) 35.0.1 - - [x] NDK (Side by side) - (latest) 28.0 - - [x] Android command line tools - (latest) 19.0.0 - - [x] CMake - (latest) 3.31.5 - - [xCode](https://developer.apple.com/xcode/) | 16.2 (macOS only) - - [Visual Studio](https://visualstudio.microsoft.com/vs/community/) | Community 17.13.0 (Windows only) + - Install and enable `Dart` and `Flutter` extensions + - Enable `Dart: Use recommended settings` via the Command Palette + - [Android Studio](https://developer.android.com/studio) + - Install and enable `Dart` and `Flutter` plugins + - Ensure that the following tools are installed and updated in SDK Manager -> SDK Tools: + - Android SDK Build-Tools + - NDK (Side by side) + - Android command line tools + - CMake + - (macOS only) [xCode](https://developer.apple.com/xcode/) + - (Windows only) [Visual Studio](https://visualstudio.microsoft.com/vs/community/) - `Desktop development with C++` workload required - [Nuget CLI](https://www.nuget.org/downloads) required for Windows desktop builds - [Enable long paths in Windows registry](BUILD_RUN_APP.md#windows-desktop) + 3. (macOS only) CocoaPods and Ruby setup: + - Ruby: 3.0+ (recommended: 3.4.x). See [Ruby setup](BUILD_RUN_APP.md#ruby-setup). + - CocoaPods: 1.15+ (assuming latest Xcode). See [CocoaPods installation](BUILD_RUN_APP.md#cocoapods-installation). + 4. Run `flutter doctor` and make sure all checks (except version) pass + 5. [Clone project repository](CLONE_REPOSITORY.md) + 6. **Initialize SDK submodule**: After cloning, initialize the komodo-defi-sdk-flutter submodule: - 3. Run `flutter doctor` and make sure all checks (except version) pass - 4. [Clone project repository](CLONE_REPOSITORY.md) - 5. Build and run the App for each target platform: + ```bash + cd komodo-wallet + git submodule update --init --recursive + ``` + + 7. Build and run the App for each target platform: - [Web](BUILD_RUN_APP.md#web) - [Android mobile](BUILD_RUN_APP.md#android) - [iOS mobile](BUILD_RUN_APP.md#ios) (macOS host only) - [macOS desktop](BUILD_RUN_APP.md#macos-desktop) (macOS host only) - [Windows desktop](BUILD_RUN_APP.md#windows-desktop) (Windows host only) - [Linux desktop](BUILD_RUN_APP.md#linux-desktop) (Linux host only) - 6. [Build release version](BUILD_RELEASE.md) + 8. [Build release version](BUILD_RELEASE.md) ## Dev Container setup (Web and Android builds only) @@ -45,8 +54,8 @@ Komodo Wallet is a cross-platform application, meaning it can be built for multi - Linux: Install [Docker for your distribution](https://docs.docker.com/install/#supported-platforms) and add your user to the group by using terminal to run: `sudo usermod -aG docker $USER`. - Windows/macOS: Install [Docker Desktop for Windows/macOS](https://www.docker.com/products/docker-desktop), and if you are using WSL in Windows, please ensure that the [WSL 2 back-end](https://aka.ms/vscode-remote/containers/docker-wsl2) is installed and configured. 2. Install [VS Code](https://code.visualstudio.com/) - - install and enable `Dart` and `Flutter` extensions - - enable `Dart: Use recommended settings` via the Command Pallette + - Install and enable `Dart` and `Flutter` extensions + - Enable `Dart: Use recommended settings` via the Command Palette 3. Install the VSCode [Dev Container extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) 4. Open the command palette (Ctrl+Shift+P) and run `Remote-Containers: Reopen in Container` @@ -65,7 +74,7 @@ export GITHUB_API_PUBLIC_READONLY_TOKEN= Example of the 403 error message (more likely after multiple repeated builds): ```bash -test@test komodo-wallet % flutter build web +test@test komodo-wallet % flutter build web Expected to find fonts for (MaterialIcons, packages/komodo_ui_kit/Custom, packages/cupertino_icons/CupertinoIcons), but found (MaterialIcons, packages/komodo_ui_kit/Custom). This usually means you are referring to font families in an IconData class but not including them in the assets section of your pubspec.yaml, are missing the package that would include them, or are missing "uses-material-design: true". diff --git a/docs/SDK_DEPENDENCY_MANAGEMENT.md b/docs/SDK_DEPENDENCY_MANAGEMENT.md index 3cf069313b..82142f2dac 100644 --- a/docs/SDK_DEPENDENCY_MANAGEMENT.md +++ b/docs/SDK_DEPENDENCY_MANAGEMENT.md @@ -77,6 +77,7 @@ For development or testing purposes, you can run the SDK roll script manually on - Check the changes in the `pubspec.yaml` and `pubspec.lock` files 5. If you want to commit these changes: + ```bash git add **/pubspec.yaml **/pubspec.lock git commit -m "chore: roll SDK packages" @@ -125,9 +126,17 @@ flutter doctor ## SDK Package Identification -The script identifies SDK packages by looking for packages with names matching those in the `SDK_PACKAGES` array in the script, which refer to external packages from the KomodoPlatform SDK repository. These are typically defined as git dependencies in your `pubspec.yaml` files. +The script identifies SDK packages by looking for packages with names matching those in the `SDK_PACKAGES` array in the script. These refer to external packages from the KomodoPlatform SDK repository and can be declared in your `pubspec.yaml` files in two ways: + +- Git dependency (Option 2): declared with a `git:` section pointing to the `KomodoPlatform/komodo-defi-sdk-flutter` repository +- Hosted dependency (Option 3): declared as a standard hosted package on pub.dev with a version constraint (for example, `^0.3.0`) + +Local packages that are part of this repository (like `komodo_ui_kit` and `komodo_persistence_layer`) are not considered external SDK packages and will not be updated by this script unless they themselves depend on SDK packages. + +When running in SDK-only mode (`UPGRADE_ALL_PACKAGES=false`): -Local packages that are part of this repository (like `komodo_ui_kit` and `komodo_persistence_layer`) are not considered SDK packages and will not be updated by this script unless they depend on SDK packages themselves. +- Hosted SDK dependencies are bumped to the latest available version on pub.dev by executing `flutter pub add `, which updates the version constraint in `pubspec.yaml` and refreshes the lockfile. +- Git-based SDK dependencies are refreshed with `flutter pub upgrade --unlock-transitive ` to update the lockfile according to the configured `ref`. ## Error Handling diff --git a/docs/SDK_SUBMODULE_MANAGEMENT.md b/docs/SDK_SUBMODULE_MANAGEMENT.md new file mode 100644 index 0000000000..d45cd8c689 --- /dev/null +++ b/docs/SDK_SUBMODULE_MANAGEMENT.md @@ -0,0 +1,226 @@ +# SDK Submodule Management + +This document provides guidance on working with the Komodo DeFi SDK Flutter submodule in the Komodo Wallet project. + +## Overview + +The Komodo Wallet project uses the komodo-defi-sdk-flutter repository as a git submodule located in the `sdk/` directory. This allows us to: + +- Track specific SDK versions +- Test changes locally before they're published +- Create hotfixes for urgent SDK issues +- Maintain dependency consistency across development environments + +## Initial Setup + +The `sdk/` submodule is configured to track the `dev` branch but stays pinned to a specific commit in this repository. Cloning should initialize it to the recorded (pinned) commit, not the latest. + +Recommended (initializes submodules at the pinned commits while cloning): + +```bash +git clone --recurse-submodules +``` + +If you already cloned without submodules: + +```bash +git submodule update --init --recursive +``` + +Either approach ensures the SDK in `sdk/` is checked out at the pinned commit recorded by the wallet repository (the submodule tracks `dev`, but does not auto-advance). + +## Working with the SDK Submodule + +### Updating to Latest SDK Changes (explicit) + +The SDK submodule only updates when explicitly requested. To advance the pinned commit to the latest on the tracked `dev` branch: + +```bash +git submodule update --remote --checkout sdk +git add sdk +git commit -m "chore(sdk): update submodule to latest dev" +``` + +This updates the submodule to the latest remote `dev` commit and records that pinned commit in the wallet repo. It does not merge or rebase inside the submodule. + +### Making SDK Changes (Hotfix Workflow) + +When you need to make changes to the SDK: + +1. **Create a hotfix branch in the SDK submodule:** + + ```bash + cd sdk + git checkout dev + git pull origin dev + git checkout -b hotfix/your-fix-name + ``` + +2. **Make your changes and test locally:** + + ```bash + # Make your code changes... + git add . + git commit -m "Hotfix: describe your fix" + ``` + +3. **Test the changes in the wallet:** + + ```bash + cd .. # Back to wallet root + flutter clean + flutter pub get + flutter test # Run your tests + flutter build web # Ensure builds work + ``` + +4. **Push the hotfix branch:** + + ```bash + cd sdk + git push -u origin hotfix/your-fix-name + ``` + +5. **Create a PR in the SDK repository** from `hotfix/your-fix-name` to `dev` + +6. **After the hotfix is merged**, update the wallet to track the new commit: + + ```bash + cd sdk + git checkout dev + git pull origin dev + cd .. + git add sdk + git commit -m "Update SDK submodule to include hotfix" + ``` + +### Switching SDK Branches + +To temporarily switch to a different SDK branch for testing: + +```bash +cd sdk +git fetch origin +git checkout feature/some-feature-branch +cd .. +flutter clean +flutter pub get +``` + +**Note:** Remember to commit the submodule state change if you want to track this branch: + +```bash +git add sdk +git commit -m "Switch SDK to feature/some-feature-branch for testing" +``` + +If you only want to test locally without updating the pinned commit in the wallet repo, do not commit changes to the `sdk/` path. + +### Auto-fetch pinned SDK commit on branch checkout + +When you checkout or switch wallet branches, the recorded submodule commit may change. To automatically update the `sdk/` working tree to that pinned commit and fetch it if missing (recommended once per clone): + +```bash +git config submodule.recurse true +git config fetch.recurseSubmodules on-demand +``` + +With these settings, `git switch`/`git checkout` and `git pull` will recurse into submodules and fetch as needed so the `sdk/` working tree matches the pinned commit. + +## Best Practices + +### Do's ✅ + +- **Always commit submodule changes** in the wallet repository when updating the SDK +- **Test thoroughly** before pushing submodule updates +- **Use descriptive commit messages** when updating the submodule (e.g., "Update SDK to v2.4.0 with new trading features") +- **Keep the submodule on `dev` branch** for production builds +- **Use hotfix branches** for urgent SDK fixes +- **Document breaking changes** when updating the SDK + +### Don'ts ❌ + +- **Don't work in detached HEAD state** - always checkout a branch in the submodule +- **Don't push wallet changes** that reference unpublished SDK commits (others won't be able to build) +- **Don't bypass the submodule** - avoid direct modifications to `sdk/` folder +- **Don't ignore dependency overrides** - ensure `pubspec.yaml` overrides are maintained + +### Dependency Management + +The wallet project uses dependency overrides to ensure all packages use the local SDK versions: + +```yaml +dependency_overrides: + komodo_defi_sdk: + path: sdk/packages/komodo_defi_sdk + komodo_defi_types: + path: sdk/packages/komodo_defi_types + # ... other SDK packages +``` + +These overrides ensure that even if SDK packages internally reference hosted versions, the wallet will use the local path versions. + +## Troubleshooting + +### Submodule Not Initialized + +If you see errors about missing SDK packages: + +```bash +git submodule update --init --recursive +flutter clean +flutter pub get +``` + +### Dependency Resolution Conflicts + +If you get dependency resolution conflicts after updating the SDK: + +1. Ensure dependency overrides are present in `pubspec.yaml` +2. Clean and reinstall dependencies: + + ```bash + flutter clean + flutter pub get + ``` + +3. For nested packages, ensure overrides are also in their `pubspec.yaml` files + +### Detached HEAD State + +If the submodule is in detached HEAD state: + +```bash +cd sdk +git checkout dev # or appropriate branch +git pull origin dev +cd .. +git add sdk +git commit -m "Fix SDK submodule branch tracking" +``` + +### Build Failures After SDK Update + +1. Check if there are breaking changes in the SDK +2. Update import statements if packages were restructured +3. Review SDK changelog/release notes +4. Consider rolling back to previous working commit temporarily + +## CI/CD Considerations + +CI must use the pinned commit for `sdk/` (never auto-advance). Ensure checkout initializes submodules to the recorded commits: + +```yaml +- uses: actions/checkout@v4 + with: + submodules: recursive # initialize submodules at the recorded (pinned) commits + fetch-depth: 0 # optional, ensures tags/history if needed +``` + +Do not run `git submodule update --remote` in CI, as that would advance the submodule beyond the pinned commit. + +## Related Documentation + +- [PROJECT_SETUP.md](PROJECT_SETUP.md) - Initial project setup including submodule initialization +- [CLONE_REPOSITORY.md](CLONE_REPOSITORY.md) - Repository cloning instructions +- [SDK_DEPENDENCY_MANAGEMENT.md](SDK_DEPENDENCY_MANAGEMENT.md) - General dependency management guidelines diff --git a/ios/.gitignore b/ios/.gitignore index 0f1df0fdd4..788808f955 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -24,6 +24,7 @@ Flutter/flutter_assets/ Flutter/flutter_export_environment.sh ServiceDefinitions.json Runner/GeneratedPluginRegistrant.* +**/dgph # Exceptions to above rules. !default.mode1v3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 8c6e56146e..d57061dd6b 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 12.0 + 13.0 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 391a080413..9cbad57888 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - device_info_plus (0.0.1): + - Flutter - DKImagePickerController/Core (4.3.9): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource @@ -33,48 +35,48 @@ PODS: - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter - - Firebase/Analytics (11.10.0): + - Firebase/Analytics (11.15.0): - Firebase/Core - - Firebase/Core (11.10.0): + - Firebase/Core (11.15.0): - Firebase/CoreOnly - - FirebaseAnalytics (~> 11.10.0) - - Firebase/CoreOnly (11.10.0): - - FirebaseCore (~> 11.10.0) - - firebase_analytics (11.4.5): - - Firebase/Analytics (= 11.10.0) + - FirebaseAnalytics (~> 11.15.0) + - Firebase/CoreOnly (11.15.0): + - FirebaseCore (~> 11.15.0) + - firebase_analytics (11.6.0): + - Firebase/Analytics (= 11.15.0) - firebase_core - Flutter - - firebase_core (3.13.0): - - Firebase/CoreOnly (= 11.10.0) + - firebase_core (3.15.2): + - Firebase/CoreOnly (= 11.15.0) - Flutter - - FirebaseAnalytics (11.10.0): - - FirebaseAnalytics/AdIdSupport (= 11.10.0) - - FirebaseCore (~> 11.10.0) + - FirebaseAnalytics (11.15.0): + - FirebaseAnalytics/Default (= 11.15.0) + - FirebaseCore (~> 11.15.0) - FirebaseInstallations (~> 11.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/MethodSwizzler (~> 8.0) - - GoogleUtilities/Network (~> 8.0) - - "GoogleUtilities/NSData+zlib (~> 8.0)" + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - FirebaseAnalytics/AdIdSupport (11.10.0): - - FirebaseCore (~> 11.10.0) + - FirebaseAnalytics/Default (11.15.0): + - FirebaseCore (~> 11.15.0) - FirebaseInstallations (~> 11.0) - - GoogleAppMeasurement (= 11.10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/MethodSwizzler (~> 8.0) - - GoogleUtilities/Network (~> 8.0) - - "GoogleUtilities/NSData+zlib (~> 8.0)" + - GoogleAppMeasurement/Default (= 11.15.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - FirebaseCore (11.10.0): - - FirebaseCoreInternal (~> 11.10.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/Logger (~> 8.0) - - FirebaseCoreInternal (11.10.0): - - "GoogleUtilities/NSData+zlib (~> 8.0)" - - FirebaseInstallations (11.10.0): - - FirebaseCore (~> 11.10.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - FirebaseCore (11.15.0): + - FirebaseCoreInternal (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (11.15.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (11.15.0): + - FirebaseCore (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - Flutter (1.0.0) - flutter_inappwebview_ios (0.0.1): @@ -87,51 +89,57 @@ PODS: - flutter_secure_storage_darwin (10.0.0): - Flutter - FlutterMacOS - - GoogleAppMeasurement (11.10.0): - - GoogleAppMeasurement/AdIdSupport (= 11.10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/MethodSwizzler (~> 8.0) - - GoogleUtilities/Network (~> 8.0) - - "GoogleUtilities/NSData+zlib (~> 8.0)" + - GoogleAdsOnDeviceConversion (2.1.0): + - GoogleUtilities/Logger (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/Core (11.15.0): + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/AdIdSupport (11.10.0): - - GoogleAppMeasurement/WithoutAdIdSupport (= 11.10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/MethodSwizzler (~> 8.0) - - GoogleUtilities/Network (~> 8.0) - - "GoogleUtilities/NSData+zlib (~> 8.0)" + - GoogleAppMeasurement/Default (11.15.0): + - GoogleAdsOnDeviceConversion (= 2.1.0) + - GoogleAppMeasurement/Core (= 11.15.0) + - GoogleAppMeasurement/IdentitySupport (= 11.15.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/WithoutAdIdSupport (11.10.0): - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/MethodSwizzler (~> 8.0) - - GoogleUtilities/Network (~> 8.0) - - "GoogleUtilities/NSData+zlib (~> 8.0)" + - GoogleAppMeasurement/IdentitySupport (11.15.0): + - GoogleAppMeasurement/Core (= 11.15.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleUtilities/AppDelegateSwizzler (8.0.2): + - GoogleUtilities/AppDelegateSwizzler (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - GoogleUtilities/Privacy - - GoogleUtilities/Environment (8.0.2): + - GoogleUtilities/Environment (8.1.0): - GoogleUtilities/Privacy - - GoogleUtilities/Logger (8.0.2): + - GoogleUtilities/Logger (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Privacy - - GoogleUtilities/MethodSwizzler (8.0.2): + - GoogleUtilities/MethodSwizzler (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/Network (8.0.2): + - GoogleUtilities/Network (8.1.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Privacy - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (8.0.2)": + - "GoogleUtilities/NSData+zlib (8.1.0)": - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (8.0.2) - - GoogleUtilities/Reachability (8.0.2): + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (8.0.2): + - GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - integration_test (0.0.1): @@ -156,9 +164,9 @@ PODS: - Flutter - FlutterMacOS - PromisesObjC (2.4.0) - - SDWebImage (5.21.0): - - SDWebImage/Core (= 5.21.0) - - SDWebImage/Core (5.21.0) + - SDWebImage (5.21.2): + - SDWebImage/Core (= 5.21.2) + - SDWebImage/Core (5.21.2) - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -172,6 +180,7 @@ PODS: - FlutterMacOS DEPENDENCIES: + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) @@ -198,6 +207,7 @@ SPEC REPOS: - FirebaseCore - FirebaseCoreInternal - FirebaseInstallations + - GoogleAdsOnDeviceConversion - GoogleAppMeasurement - GoogleUtilities - nanopb @@ -207,6 +217,8 @@ SPEC REPOS: - SwiftyGif EXTERNAL SOURCES: + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" firebase_analytics: @@ -241,21 +253,23 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/video_player_avfoundation/darwin" SPEC CHECKSUMS: + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be - Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2 - firebase_analytics: 1998960b8fa16fd0cd9e77a6f9fd35a2009ad65e - firebase_core: 2d4534e7b489907dcede540c835b48981d890943 - FirebaseAnalytics: 4e42333f02cf78ed93703a5c36f36dd518aebdef - FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7 - FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679 - FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3 - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e + firebase_analytics: 0e25ca1d4001ccedd40b4e5b74c0ec34e18f6425 + firebase_core: 995454a784ff288be5689b796deb9e9fa3601818 + FirebaseAnalytics: 6433dfd311ba78084fc93bdfc145e8cb75740eae + FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e + FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4 + FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 flutter_secure_storage_darwin: ce237a8775b39723566dc72571190a3769d70468 - GoogleAppMeasurement: 36684bfb3ee034e2b42b4321eb19da3a1b81e65d - GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d + GoogleAdsOnDeviceConversion: 2be6297a4f048459e0ae17fad9bfd2844e10cf64 + GoogleAppMeasurement: 700dce7541804bec33db590a5c496b663fbe2539 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e komodo_defi_framework: b6929645df13ccb8d2c1c177ccf8b7bbb81f6859 local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19 @@ -265,7 +279,7 @@ SPEC CHECKSUMS: package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868 + SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 4557b6e61f..f84a4524e3 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -371,7 +371,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -462,7 +462,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -512,7 +512,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index e9c9b88ef7..53611299a8 100644 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,9 +1,117 @@ { "images" : [ + { + "filename" : "Icon-App-20x20@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-20x20@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-29x29@1x.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-29x29@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-29x29@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-40x40@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-40x40@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-60x60@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "Icon-App-60x60@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "Icon-App-20x20@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-20x20@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-29x29@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-29x29@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-40x40@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-40x40@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-76x76@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "Icon-App-76x76@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "Icon-App-83.5x83.5@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, { "filename" : "Icon-App-1024x1024@1x.png", - "idiom" : "universal", - "platform" : "ios", + "idiom" : "ios-marketing", + "scale" : "1x", "size" : "1024x1024" } ], diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 7b1f3db446..e2da2fc8c3 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000000..a8bb66bb1f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000..c5aec86f5d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000..264da18d4f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000..1e0cbaeb82 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000..bb4d4879e2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000000..a4eb7468f4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000000..c5aec86f5d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000..2a0507afd2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000000..5404b1d53b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000..5404b1d53b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000..0a3707491c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000000..4448ea5901 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000000..d4295456e1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000..0de3cfb512 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppLogo.imageset/AppLogo.png b/ios/Runner/Assets.xcassets/AppLogo.imageset/AppLogo.png index a6614de14c..2264c3422a 100644 Binary files a/ios/Runner/Assets.xcassets/AppLogo.imageset/AppLogo.png and b/ios/Runner/Assets.xcassets/AppLogo.imageset/AppLogo.png differ diff --git a/ios/Runner/Assets.xcassets/AppLogo.imageset/AppLogo@2x.png b/ios/Runner/Assets.xcassets/AppLogo.imageset/AppLogo@2x.png index 7fec80eb9c..2264c3422a 100644 Binary files a/ios/Runner/Assets.xcassets/AppLogo.imageset/AppLogo@2x.png and b/ios/Runner/Assets.xcassets/AppLogo.imageset/AppLogo@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppLogo.imageset/Contents.json b/ios/Runner/Assets.xcassets/AppLogo.imageset/Contents.json index 77f1ae1dc6..6b32ebfea8 100644 --- a/ios/Runner/Assets.xcassets/AppLogo.imageset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppLogo.imageset/Contents.json @@ -1,22 +1,22 @@ { - "images" : [ + "images": [ { - "filename" : "AppLogo.png", - "idiom" : "universal", - "scale" : "1x" + "filename": "AppLogo.png", + "idiom": "universal", + "scale": "1x" }, { - "filename" : "AppLogo@2x.png", - "idiom" : "universal", - "scale" : "2x" + "filename": "AppLogo@2x.png", + "idiom": "universal", + "scale": "2x" }, { - "idiom" : "universal", - "scale" : "3x" + "idiom": "universal", + "scale": "3x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } -} +} \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard index 8f6ee0b5fe..3c255af714 100644 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -1,9 +1,9 @@ - + - + diff --git a/ios/build/Pods.build/Release-iphonesimulator/Flutter.build/dgph b/ios/build/Pods.build/Release-iphonesimulator/Flutter.build/dgph deleted file mode 100644 index e5b31cc6e3..0000000000 Binary files a/ios/build/Pods.build/Release-iphonesimulator/Flutter.build/dgph and /dev/null differ diff --git a/ios/build/Pods.build/Release-iphonesimulator/Pods-Runner.build/dgph b/ios/build/Pods.build/Release-iphonesimulator/Pods-Runner.build/dgph deleted file mode 100644 index e5b31cc6e3..0000000000 Binary files a/ios/build/Pods.build/Release-iphonesimulator/Pods-Runner.build/dgph and /dev/null differ diff --git a/ios/build/Pods.build/Release-iphonesimulator/integration_test.build/dgph b/ios/build/Pods.build/Release-iphonesimulator/integration_test.build/dgph deleted file mode 100644 index 989ccb51d0..0000000000 Binary files a/ios/build/Pods.build/Release-iphonesimulator/integration_test.build/dgph and /dev/null differ diff --git a/ios/build/Pods.build/Release-iphonesimulator/path_provider.build/dgph b/ios/build/Pods.build/Release-iphonesimulator/path_provider.build/dgph deleted file mode 100644 index e5b31cc6e3..0000000000 Binary files a/ios/build/Pods.build/Release-iphonesimulator/path_provider.build/dgph and /dev/null differ diff --git a/ios/build/Pods.build/Release-iphonesimulator/shared_preferences.build/dgph b/ios/build/Pods.build/Release-iphonesimulator/shared_preferences.build/dgph deleted file mode 100644 index e5b31cc6e3..0000000000 Binary files a/ios/build/Pods.build/Release-iphonesimulator/shared_preferences.build/dgph and /dev/null differ diff --git a/ios/build/Pods.build/Release-iphonesimulator/url_launcher.build/dgph b/ios/build/Pods.build/Release-iphonesimulator/url_launcher.build/dgph deleted file mode 100644 index e5b31cc6e3..0000000000 Binary files a/ios/build/Pods.build/Release-iphonesimulator/url_launcher.build/dgph and /dev/null differ diff --git a/lib/analytics/analytics_factory.dart b/lib/analytics/analytics_factory.dart deleted file mode 100644 index 513250302f..0000000000 --- a/lib/analytics/analytics_factory.dart +++ /dev/null @@ -1,881 +0,0 @@ -import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; - -import '../bloc/analytics/analytics_repo.dart'; - -class PortfolioPnlViewedEvent extends AnalyticsEventData { - PortfolioPnlViewedEvent({ - required this.timeframe, - required this.realizedPnl, - required this.unrealizedPnl, - }); - - @override - String get name => 'portfolio_pnl_viewed'; - - final String timeframe; - final double realizedPnl; - final double unrealizedPnl; - - @override - Map get parameters => { - 'timeframe': timeframe, - 'realized_pnl': realizedPnl, - 'unrealized_pnl': unrealizedPnl, - }; -} - -class AppOpenedEvent extends AnalyticsEventData { - AppOpenedEvent({required this.platform, required this.appVersion}); - - @override - String get name => 'app_open'; - - final String platform; - final String appVersion; - - @override - JsonMap get parameters => { - 'platform': platform, - 'app_version': appVersion, - }; -} - -class OnboardingStartedEvent extends AnalyticsEventData { - OnboardingStartedEvent({required this.method, this.referralSource}); - - @override - String get name => 'onboarding_start'; - - final String method; - final String? referralSource; - - @override - JsonMap get parameters => { - 'method': method, - if (referralSource != null) 'referral_source': referralSource!, - }; -} - -class WalletCreatedEvent extends AnalyticsEventData { - WalletCreatedEvent({required this.source, required this.walletType}); - - @override - String get name => 'wallet_created'; - - final String source; - final String walletType; - - @override - JsonMap get parameters => { - 'source': source, - 'wallet_type': walletType, - }; -} - -class WalletImportedEvent extends AnalyticsEventData { - WalletImportedEvent({ - required this.source, - required this.importType, - required this.walletType, - }); - - @override - String get name => 'wallet_imported'; - - final String source; - final String importType; - final String walletType; - - @override - JsonMap get parameters => { - 'source': source, - 'import_type': importType, - 'wallet_type': walletType, - }; -} - -class BackupCompletedEvent extends AnalyticsEventData { - BackupCompletedEvent({ - required this.backupTime, - required this.method, - required this.walletType, - }); - - @override - String get name => 'backup_complete'; - - final int backupTime; - final String method; - final String walletType; - - @override - JsonMap get parameters => { - 'backup_time': backupTime, - 'method': method, - 'wallet_type': walletType, - }; -} - -class BackupSkippedEvent extends AnalyticsEventData { - BackupSkippedEvent({required this.stageSkipped, required this.walletType}); - - @override - String get name => 'backup_skipped'; - - final String stageSkipped; - final String walletType; - - @override - JsonMap get parameters => { - 'stage_skipped': stageSkipped, - 'wallet_type': walletType, - }; -} - -class AnalyticsEvents { - const AnalyticsEvents._(); - - /// Portfolio P&L viewed event - static PortfolioPnlViewedEvent portfolioPnlViewed({ - required String timeframe, - required double realizedPnl, - required double unrealizedPnl, - }) { - return PortfolioPnlViewedEvent( - timeframe: timeframe, - realizedPnl: realizedPnl, - unrealizedPnl: unrealizedPnl, - ); - } - - /// App opened / foregrounded event - static AppOpenedEvent appOpened({ - required String platform, - required String appVersion, - }) { - return AppOpenedEvent(platform: platform, appVersion: appVersion); - } - - /// Onboarding started event - static OnboardingStartedEvent onboardingStarted({ - required String method, - String? referralSource, - }) { - return OnboardingStartedEvent( - method: method, - referralSource: referralSource, - ); - } - - /// Wallet created event - static WalletCreatedEvent walletCreated({ - required String source, - required String walletType, - }) { - return WalletCreatedEvent(source: source, walletType: walletType); - } - - /// Wallet imported event - static WalletImportedEvent walletImported({ - required String source, - required String importType, - required String walletType, - }) { - return WalletImportedEvent( - source: source, - importType: importType, - walletType: walletType, - ); - } - - /// Seed backup completed event - static BackupCompletedEvent backupCompleted({ - required int backupTime, - required String method, - required String walletType, - }) { - return BackupCompletedEvent( - backupTime: backupTime, - method: method, - walletType: walletType, - ); - } - - /// Backup skipped event - static BackupSkippedEvent backupSkipped({ - required String stageSkipped, - required String walletType, - }) { - return BackupSkippedEvent( - stageSkipped: stageSkipped, - walletType: walletType, - ); - } - - /// Bridge initiated event - static BridgeInitiatedEvent bridgeInitiated({ - required String fromChain, - required String toChain, - required String asset, - }) { - return BridgeInitiatedEvent( - fromChain: fromChain, - toChain: toChain, - asset: asset, - ); - } - - /// Bridge success event - static BridgeSuccessEvent bridgeSuccess({ - required String fromChain, - required String toChain, - required String asset, - required double amount, - int? durationMs, - }) { - return BridgeSuccessEvent( - fromChain: fromChain, - toChain: toChain, - asset: asset, - amount: amount, - durationMs: durationMs, - ); - } - - /// Bridge failure event - static BridgeFailureEvent bridgeFailure({ - required String fromChain, - required String toChain, - required String failError, - int? durationMs, - }) { - return BridgeFailureEvent( - fromChain: fromChain, - toChain: toChain, - failError: failError, - durationMs: durationMs, - ); - } - - /// NFT gallery opened event - static NftGalleryOpenedEvent nftGalleryOpened({ - required int nftCount, - required int loadTimeMs, - }) { - return NftGalleryOpenedEvent( - nftCount: nftCount, - loadTimeMs: loadTimeMs, - ); - } - - /// NFT transfer initiated - static NftTransferInitiatedEvent nftTransferInitiated({ - required String collectionName, - required String tokenId, - required String hdType, - }) { - return NftTransferInitiatedEvent( - collectionName: collectionName, - tokenId: tokenId, - hdType: hdType, - ); - } - - /// NFT transfer success - static NftTransferSuccessEvent nftTransferSuccess({ - required String collectionName, - required String tokenId, - required double fee, - required String hdType, - }) { - return NftTransferSuccessEvent( - collectionName: collectionName, - tokenId: tokenId, - fee: fee, - hdType: hdType, - ); - } - - /// NFT transfer failure - static NftTransferFailureEvent nftTransferFailure({ - required String collectionName, - required String failReason, - required String hdType, - }) { - return NftTransferFailureEvent( - collectionName: collectionName, - failReason: failReason, - hdType: hdType, - ); - } - - /// Marketbot setup started - static MarketbotSetupStartEvent marketbotSetupStart({ - required String strategyType, - required int pairsCount, - }) { - return MarketbotSetupStartEvent( - strategyType: strategyType, - pairsCount: pairsCount, - ); - } - - /// Marketbot setup complete - static MarketbotSetupCompleteEvent marketbotSetupComplete({ - required String strategyType, - required double baseCapital, - }) { - return MarketbotSetupCompleteEvent( - strategyType: strategyType, - baseCapital: baseCapital, - ); - } - - /// Marketbot trade executed - static MarketbotTradeExecutedEvent marketbotTradeExecuted({ - required String pair, - required double tradeSize, - required double profitUsd, - }) { - return MarketbotTradeExecutedEvent( - pair: pair, - tradeSize: tradeSize, - profitUsd: profitUsd, - ); - } - - /// Marketbot error - static MarketbotErrorEvent marketbotError({ - required String errorCode, - required String strategyType, - }) { - return MarketbotErrorEvent( - errorCode: errorCode, - strategyType: strategyType, - ); - } - - /// Reward claim initiated - static RewardClaimInitiatedEvent rewardClaimInitiated({ - required String asset, - required double expectedRewardAmount, - }) { - return RewardClaimInitiatedEvent( - asset: asset, - expectedRewardAmount: expectedRewardAmount, - ); - } - - /// Reward claim success - static RewardClaimSuccessEvent rewardClaimSuccess({ - required String asset, - required double rewardAmount, - }) { - return RewardClaimSuccessEvent( - asset: asset, - rewardAmount: rewardAmount, - ); - } - - /// Reward claim failure - static RewardClaimFailureEvent rewardClaimFailure({ - required String asset, - required String failReason, - }) { - return RewardClaimFailureEvent( - asset: asset, - failReason: failReason, - ); - } - - /// DApp connected - static DappConnectEvent dappConnect({ - required String dappName, - required String network, - }) { - return DappConnectEvent( - dappName: dappName, - network: network, - ); - } - - /// Settings change - static SettingsChangeEvent settingsChange({ - required String settingName, - required String newValue, - }) { - return SettingsChangeEvent( - settingName: settingName, - newValue: newValue, - ); - } - - /// Error displayed - static ErrorDisplayedEvent errorDisplayed({ - required String errorCode, - required String screenContext, - }) { - return ErrorDisplayedEvent( - errorCode: errorCode, - screenContext: screenContext, - ); - } - - /// App shared - static AppShareEvent appShare({ - required String channel, - }) { - return AppShareEvent(channel: channel); - } - - /// Scroll attempt outside content - static ScrollAttemptOutsideContentEvent scrollAttemptOutsideContent({ - required String screenContext, - required double scrollDelta, - }) { - return ScrollAttemptOutsideContentEvent( - screenContext: screenContext, - scrollDelta: scrollDelta, - ); - } - - /// Searchbar input - static SearchbarInputEvent searchbarInput({ - required int queryLength, - String? assetSymbol, - }) { - return SearchbarInputEvent( - queryLength: queryLength, - assetSymbol: assetSymbol, - ); - } - - /// Theme selected - static ThemeSelectedEvent themeSelected({ - required String themeName, - }) { - return ThemeSelectedEvent(themeName: themeName); - } -} - -class BridgeInitiatedEvent extends AnalyticsEventData { - BridgeInitiatedEvent({ - required this.fromChain, - required this.toChain, - required this.asset, - }); - - @override - String get name => 'bridge_initiated'; - - final String fromChain; - final String toChain; - final String asset; - - @override - JsonMap get parameters => { - 'from_chain': fromChain, - 'to_chain': toChain, - 'asset': asset, - }; -} - -class BridgeSuccessEvent extends AnalyticsEventData { - BridgeSuccessEvent({ - required this.fromChain, - required this.toChain, - required this.asset, - required this.amount, - this.durationMs, - }); - - @override - String get name => 'bridge_success'; - - final String fromChain; - final String toChain; - final String asset; - final double amount; - final int? durationMs; - - @override - JsonMap get parameters => { - 'from_chain': fromChain, - 'to_chain': toChain, - 'asset': asset, - 'amount': amount, - if (durationMs != null) 'duration_ms': durationMs, - }; -} - -class BridgeFailureEvent extends AnalyticsEventData { - BridgeFailureEvent({ - required this.fromChain, - required this.toChain, - required this.failError, - this.durationMs, - }); - - @override - String get name => 'bridge_failure'; - - final String fromChain; - final String toChain; - final String failError; - final int? durationMs; - - @override - JsonMap get parameters => { - 'from_chain': fromChain, - 'to_chain': toChain, - 'fail_error': failError, - if (durationMs != null) 'duration_ms': durationMs, - }; -} - -class NftGalleryOpenedEvent extends AnalyticsEventData { - NftGalleryOpenedEvent({ - required this.nftCount, - required this.loadTimeMs, - }); - - @override - String get name => 'nft_gallery_opened'; - - final int nftCount; - final int loadTimeMs; - - @override - JsonMap get parameters => { - 'nft_count': nftCount, - 'load_time_ms': loadTimeMs, - }; -} - -class NftTransferInitiatedEvent extends AnalyticsEventData { - NftTransferInitiatedEvent({ - required this.collectionName, - required this.tokenId, - required this.hdType, - }); - - @override - String get name => 'nft_transfer_initiated'; - - final String collectionName; - final String tokenId; - final String hdType; - - @override - JsonMap get parameters => { - 'collection_name': collectionName, - 'token_id': tokenId, - 'hd_type': hdType, - }; -} - -class NftTransferSuccessEvent extends AnalyticsEventData { - NftTransferSuccessEvent({ - required this.collectionName, - required this.tokenId, - required this.fee, - required this.hdType, - }); - - @override - String get name => 'nft_transfer_success'; - - final String collectionName; - final String tokenId; - final double fee; - final String hdType; - - @override - JsonMap get parameters => { - 'collection_name': collectionName, - 'token_id': tokenId, - 'fee': fee, - 'hd_type': hdType, - }; -} - -class NftTransferFailureEvent extends AnalyticsEventData { - NftTransferFailureEvent({ - required this.collectionName, - required this.failReason, - required this.hdType, - }); - - @override - String get name => 'nft_transfer_failure'; - - final String collectionName; - final String failReason; - final String hdType; - - @override - JsonMap get parameters => { - 'collection_name': collectionName, - 'fail_reason': failReason, - 'hd_type': hdType, - }; -} - -class MarketbotSetupStartEvent extends AnalyticsEventData { - MarketbotSetupStartEvent({ - required this.strategyType, - required this.pairsCount, - }); - - @override - String get name => 'marketbot_setup_start'; - - final String strategyType; - final int pairsCount; - - @override - JsonMap get parameters => { - 'strategy_type': strategyType, - 'pairs_count': pairsCount, - }; -} - -class MarketbotSetupCompleteEvent extends AnalyticsEventData { - MarketbotSetupCompleteEvent({ - required this.strategyType, - required this.baseCapital, - }); - - @override - String get name => 'marketbot_setup_complete'; - - final String strategyType; - final double baseCapital; - - @override - JsonMap get parameters => { - 'strategy_type': strategyType, - 'base_capital': baseCapital, - }; -} - -class MarketbotTradeExecutedEvent extends AnalyticsEventData { - MarketbotTradeExecutedEvent({ - required this.pair, - required this.tradeSize, - required this.profitUsd, - }); - - @override - String get name => 'marketbot_trade_executed'; - - final String pair; - final double tradeSize; - final double profitUsd; - - @override - JsonMap get parameters => { - 'pair': pair, - 'trade_size': tradeSize, - 'profit_usd': profitUsd, - }; -} - -class MarketbotErrorEvent extends AnalyticsEventData { - MarketbotErrorEvent({ - required this.errorCode, - required this.strategyType, - }); - - @override - String get name => 'marketbot_error'; - - final String errorCode; - final String strategyType; - - @override - JsonMap get parameters => { - 'error_code': errorCode, - 'strategy_type': strategyType, - }; -} - -class RewardClaimInitiatedEvent extends AnalyticsEventData { - RewardClaimInitiatedEvent({ - required this.asset, - required this.expectedRewardAmount, - }); - - @override - String get name => 'reward_claim_initiated'; - - final String asset; - final double expectedRewardAmount; - - @override - JsonMap get parameters => { - 'asset': asset, - 'expected_reward_amount': expectedRewardAmount, - }; -} - -class RewardClaimSuccessEvent extends AnalyticsEventData { - RewardClaimSuccessEvent({ - required this.asset, - required this.rewardAmount, - }); - - @override - String get name => 'reward_claim_success'; - - final String asset; - final double rewardAmount; - - @override - JsonMap get parameters => { - 'asset': asset, - 'reward_amount': rewardAmount, - }; -} - -class RewardClaimFailureEvent extends AnalyticsEventData { - RewardClaimFailureEvent({ - required this.asset, - required this.failReason, - }); - - @override - String get name => 'reward_claim_failure'; - - final String asset; - final String failReason; - - @override - JsonMap get parameters => { - 'asset': asset, - 'fail_reason': failReason, - }; -} - -class DappConnectEvent extends AnalyticsEventData { - DappConnectEvent({ - required this.dappName, - required this.network, - }); - - @override - String get name => 'dapp_connect'; - - final String dappName; - final String network; - - @override - JsonMap get parameters => { - 'dapp_name': dappName, - 'network': network, - }; -} - -class SettingsChangeEvent extends AnalyticsEventData { - SettingsChangeEvent({ - required this.settingName, - required this.newValue, - }); - - @override - String get name => 'settings_change'; - - final String settingName; - final String newValue; - - @override - JsonMap get parameters => { - 'setting_name': settingName, - 'new_value': newValue, - }; -} - -class ErrorDisplayedEvent extends AnalyticsEventData { - ErrorDisplayedEvent({ - required this.errorCode, - required this.screenContext, - }); - - @override - String get name => 'error_displayed'; - - final String errorCode; - final String screenContext; - - @override - JsonMap get parameters => { - 'error_code': errorCode, - 'screen_context': screenContext, - }; -} - -class AppShareEvent extends AnalyticsEventData { - AppShareEvent({required this.channel}); - - @override - String get name => 'app_share'; - - final String channel; - - @override - JsonMap get parameters => { - 'channel': channel, - }; -} - -class ScrollAttemptOutsideContentEvent extends AnalyticsEventData { - ScrollAttemptOutsideContentEvent({ - required this.screenContext, - required this.scrollDelta, - }); - - @override - String get name => 'scroll_attempt_outside_content'; - - final String screenContext; - final double scrollDelta; - - @override - JsonMap get parameters => { - 'screen_context': screenContext, - 'scroll_delta': scrollDelta, - }; -} - -class SearchbarInputEvent extends AnalyticsEventData { - SearchbarInputEvent({ - required this.queryLength, - this.assetSymbol, - }); - - @override - String get name => 'searchbar_input'; - - final int queryLength; - final String? assetSymbol; - - @override - JsonMap get parameters => { - 'query_length': queryLength, - if (assetSymbol != null) 'asset_symbol': assetSymbol!, - }; -} - -class ThemeSelectedEvent extends AnalyticsEventData { - ThemeSelectedEvent({required this.themeName}); - - @override - String get name => 'theme_selected'; - - final String themeName; - - @override - JsonMap get parameters => { - 'theme_name': themeName, - }; -} diff --git a/lib/analytics/events.dart b/lib/analytics/events.dart index 4c7be3075c..7f0901325a 100644 --- a/lib/analytics/events.dart +++ b/lib/analytics/events.dart @@ -15,26 +15,26 @@ import 'package:web_dex/bloc/analytics/analytics_event.dart'; /// E38: Fresh receive address derived /// Measures when a fresh HD wallet address is generated. Business category: HD Wallet Operations. /// Provides insights on address-reuse risk and payment UX. -class HdAddressGeneratedEventData implements AnalyticsEventData { +class HdAddressGeneratedEventData extends AnalyticsEventData { const HdAddressGeneratedEventData({ required this.accountIndex, required this.addressIndex, - required this.assetSymbol, + required this.asset, }); final int accountIndex; final int addressIndex; - final String assetSymbol; + final String asset; @override String get name => 'hd_address_generated'; @override Map get parameters => { - 'account_index': accountIndex, - 'address_index': addressIndex, - 'asset_symbol': assetSymbol, - }; + 'account_index': accountIndex, + 'address_index': addressIndex, + 'asset': asset, + }; } /// E38: Fresh receive address derived @@ -42,12 +42,14 @@ class AnalyticsHdAddressGeneratedEvent extends AnalyticsSendDataEvent { AnalyticsHdAddressGeneratedEvent({ required int accountIndex, required int addressIndex, - required String assetSymbol, - }) : super(HdAddressGeneratedEventData( - accountIndex: accountIndex, - addressIndex: addressIndex, - assetSymbol: assetSymbol, - )); + required String asset, + }) : super( + HdAddressGeneratedEventData( + accountIndex: accountIndex, + addressIndex: addressIndex, + asset: asset, + ), + ); } // UI USABILITY @@ -56,7 +58,7 @@ class AnalyticsHdAddressGeneratedEvent extends AnalyticsSendDataEvent { /// E40: Time until the top of the coins list crosses 50% of viewport /// Measures the time it takes for the coins list to reach halfway through the viewport. Business category: UI Usability. /// Provides insights on whether users struggle to reach balances and helps optimize list layout. -class WalletListHalfViewportReachedEventData implements AnalyticsEventData { +class WalletListHalfViewportReachedEventData extends AnalyticsEventData { const WalletListHalfViewportReachedEventData({ required this.timeToHalfMs, required this.walletSize, @@ -70,9 +72,9 @@ class WalletListHalfViewportReachedEventData implements AnalyticsEventData { @override Map get parameters => { - 'time_to_half_ms': timeToHalfMs, - 'wallet_size': walletSize, - }; + 'time_to_half_ms': timeToHalfMs, + 'wallet_size': walletSize, + }; } /// E40: Time until the top of the coins list crosses 50% of viewport @@ -81,10 +83,12 @@ class AnalyticsWalletListHalfViewportReachedEvent AnalyticsWalletListHalfViewportReachedEvent({ required int timeToHalfMs, required int walletSize, - }) : super(WalletListHalfViewportReachedEventData( - timeToHalfMs: timeToHalfMs, - walletSize: walletSize, - )); + }) : super( + WalletListHalfViewportReachedEventData( + timeToHalfMs: timeToHalfMs, + walletSize: walletSize, + ), + ); } // DATA SYNC @@ -93,7 +97,7 @@ class AnalyticsWalletListHalfViewportReachedEvent /// E41: Coins config refresh completed on launch /// Measures when coins configuration data is refreshed upon app launch. Business category: Data Sync. /// Provides insights on data freshness and helps monitor failed or slow syncs. -class CoinsDataUpdatedEventData implements AnalyticsEventData { +class CoinsDataUpdatedEventData extends AnalyticsEventData { const CoinsDataUpdatedEventData({ required this.coinsCount, required this.updateSource, @@ -109,10 +113,10 @@ class CoinsDataUpdatedEventData implements AnalyticsEventData { @override Map get parameters => { - 'coins_count': coinsCount, - 'update_source': updateSource, - 'update_duration_ms': updateDurationMs, - }; + 'coins_count': coinsCount, + 'update_source': updateSource, + 'update_duration_ms': updateDurationMs, + }; } /// E41: Coins config refresh completed on launch @@ -121,11 +125,13 @@ class AnalyticsCoinsDataUpdatedEvent extends AnalyticsSendDataEvent { required String updateSource, required int updateDurationMs, required int coinsCount, - }) : super(CoinsDataUpdatedEventData( - updateSource: updateSource, - updateDurationMs: updateDurationMs, - coinsCount: coinsCount, - )); + }) : super( + CoinsDataUpdatedEventData( + updateSource: updateSource, + updateDurationMs: updateDurationMs, + coinsCount: coinsCount, + ), + ); } // PERFORMANCE @@ -134,7 +140,7 @@ class AnalyticsCoinsDataUpdatedEvent extends AnalyticsSendDataEvent { /// E44: Delay from page open until interactive (Loading logo hidden) /// Measures the delay between opening a page and when it becomes interactive. Business category: Performance. /// Provides insights on performance bottlenecks that impact user experience. -class PageInteractiveDelayEventData implements AnalyticsEventData { +class PageInteractiveDelayEventData extends AnalyticsEventData { const PageInteractiveDelayEventData({ required this.pageName, required this.interactiveDelayMs, @@ -150,10 +156,10 @@ class PageInteractiveDelayEventData implements AnalyticsEventData { @override Map get parameters => { - 'page_name': pageName, - 'interactive_delay_ms': interactiveDelayMs, - 'spinner_time_ms': spinnerTimeMs, - }; + 'page_name': pageName, + 'interactive_delay_ms': interactiveDelayMs, + 'spinner_time_ms': spinnerTimeMs, + }; } /// E44: Delay from page open until interactive (Loading logo hidden) @@ -162,11 +168,13 @@ class AnalyticsPageInteractiveDelayEvent extends AnalyticsSendDataEvent { required String pageName, required int interactiveDelayMs, required int spinnerTimeMs, - }) : super(PageInteractiveDelayEventData( - pageName: pageName, - interactiveDelayMs: interactiveDelayMs, - spinnerTimeMs: spinnerTimeMs, - )); + }) : super( + PageInteractiveDelayEventData( + pageName: pageName, + interactiveDelayMs: interactiveDelayMs, + spinnerTimeMs: spinnerTimeMs, + ), + ); } ({int accountIndex, int addressIndex}) parseDerivationPath(String path) { diff --git a/lib/analytics/events/cross_chain_events.dart b/lib/analytics/events/cross_chain_events.dart index c34fba5bb3..28dfd65ed9 100644 --- a/lib/analytics/events/cross_chain_events.dart +++ b/lib/analytics/events/cross_chain_events.dart @@ -4,137 +4,203 @@ import 'package:web_dex/bloc/analytics/analytics_repo.dart'; /// E20: Bridge transfer started /// Business category: Cross-Chain. -class BridgeInitiatedEventData implements AnalyticsEventData { +class BridgeInitiatedEventData extends AnalyticsEventData { const BridgeInitiatedEventData({ - required this.fromChain, - required this.toChain, required this.asset, - required this.walletType, + required this.secondaryAsset, + required this.network, + required this.secondaryNetwork, + required this.hdType, }); - final String fromChain; - final String toChain; final String asset; - final String walletType; + final String secondaryAsset; + final String network; + final String secondaryNetwork; + final String hdType; @override String get name => 'bridge_initiated'; @override JsonMap get parameters => { - 'from_chain': fromChain, - 'to_chain': toChain, - 'asset': asset, - 'wallet_type': walletType, - }; + 'asset': asset, + 'secondary_asset': secondaryAsset, + 'network': network, + 'secondary_network': secondaryNetwork, + 'hd_type': hdType, + }; } /// E20: Bridge transfer started class AnalyticsBridgeInitiatedEvent extends AnalyticsSendDataEvent { AnalyticsBridgeInitiatedEvent({ - required String fromChain, - required String toChain, required String asset, - required String walletType, + required String secondaryAsset, + required String network, + required String secondaryNetwork, + required String hdType, }) : super( - BridgeInitiatedEventData( - fromChain: fromChain, - toChain: toChain, - asset: asset, - walletType: walletType, - ), - ); + BridgeInitiatedEventData( + asset: asset, + secondaryAsset: secondaryAsset, + network: network, + secondaryNetwork: secondaryNetwork, + hdType: hdType, + ), + ); } /// E21: Bridge completed /// Business category: Cross-Chain. -class BridgeSucceededEventData implements AnalyticsEventData { +class BridgeSucceededEventData extends AnalyticsEventData { const BridgeSucceededEventData({ - required this.fromChain, - required this.toChain, required this.asset, + required this.secondaryAsset, + required this.network, + required this.secondaryNetwork, required this.amount, - required this.walletType, + required this.hdType, + this.durationMs, }); - final String fromChain; - final String toChain; final String asset; + final String secondaryAsset; + final String network; + final String secondaryNetwork; final double amount; - final String walletType; + final String hdType; + final int? durationMs; @override String get name => 'bridge_success'; @override JsonMap get parameters => { - 'from_chain': fromChain, - 'to_chain': toChain, - 'asset': asset, - 'amount': amount, - 'wallet_type': walletType, - }; + 'asset': asset, + 'secondary_asset': secondaryAsset, + 'network': network, + 'secondary_network': secondaryNetwork, + 'amount': amount, + 'hd_type': hdType, + if (durationMs != null) 'duration_ms': durationMs, + }; } /// E21: Bridge completed class AnalyticsBridgeSucceededEvent extends AnalyticsSendDataEvent { AnalyticsBridgeSucceededEvent({ - required String fromChain, - required String toChain, required String asset, + required String secondaryAsset, + required String network, + required String secondaryNetwork, required double amount, - required String walletType, + required String hdType, + int? durationMs, }) : super( - BridgeSucceededEventData( - fromChain: fromChain, - toChain: toChain, - asset: asset, - amount: amount, - walletType: walletType, - ), - ); + BridgeSucceededEventData( + asset: asset, + secondaryAsset: secondaryAsset, + network: network, + secondaryNetwork: secondaryNetwork, + amount: amount, + hdType: hdType, + durationMs: durationMs, + ), + ); } /// E22: Bridge failed /// Business category: Cross-Chain. -class BridgeFailedEventData implements AnalyticsEventData { +class BridgeFailedEventData extends AnalyticsEventData { const BridgeFailedEventData({ - required this.fromChain, - required this.toChain, - required this.failError, - required this.walletType, + required this.asset, + required this.secondaryAsset, + required this.network, + required this.secondaryNetwork, + required this.failureStage, + this.failureDetail, + required this.hdType, + this.durationMs, }); - final String fromChain; - final String toChain; - final String failError; - final String walletType; + final String asset; + final String secondaryAsset; + final String network; + final String secondaryNetwork; + final String failureStage; + final String? failureDetail; + final String hdType; + final int? durationMs; @override String get name => 'bridge_failure'; @override JsonMap get parameters => { - 'from_chain': fromChain, - 'to_chain': toChain, - 'fail_error': failError, - 'wallet_type': walletType, - }; + 'asset': asset, + 'secondary_asset': secondaryAsset, + 'network': network, + 'secondary_network': secondaryNetwork, + 'failure_reason': _formatFailureReason( + stage: failureStage, + reason: failureDetail, + ), + 'hd_type': hdType, + if (durationMs != null) 'duration_ms': durationMs, + }; } /// E22: Bridge failed class AnalyticsBridgeFailedEvent extends AnalyticsSendDataEvent { AnalyticsBridgeFailedEvent({ - required String fromChain, - required String toChain, - required String failError, - required String walletType, + required String asset, + required String secondaryAsset, + required String network, + required String secondaryNetwork, + required String failureStage, + String? failureDetail, + required String hdType, + int? durationMs, }) : super( - BridgeFailedEventData( - fromChain: fromChain, - toChain: toChain, - failError: failError, - walletType: walletType, - ), - ); + BridgeFailedEventData( + asset: asset, + secondaryAsset: secondaryAsset, + network: network, + secondaryNetwork: secondaryNetwork, + failureStage: failureStage, + failureDetail: failureDetail, + hdType: hdType, + durationMs: durationMs, + ), + ); +} + +String _formatFailureReason({String? stage, String? reason, String? code}) { + final parts = []; + + String? sanitize(String? value) { + if (value == null) return null; + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + + final sanitizedStage = sanitize(stage); + final sanitizedReason = sanitize(reason); + final sanitizedCode = sanitize(code); + + if (sanitizedStage != null) { + parts.add('stage:$sanitizedStage'); + } + if (sanitizedReason != null) { + parts.add('reason:$sanitizedReason'); + } + if (sanitizedCode != null && sanitizedCode != sanitizedReason) { + parts.add('code:$sanitizedCode'); + } + + if (parts.isEmpty) { + return 'reason:unknown'; + } + return parts.join('|'); } diff --git a/lib/analytics/events/market_bot_events.dart b/lib/analytics/events/market_bot_events.dart index 5d7535f359..b737eb5ee5 100644 --- a/lib/analytics/events/market_bot_events.dart +++ b/lib/analytics/events/market_bot_events.dart @@ -3,7 +3,7 @@ import 'package:web_dex/bloc/analytics/analytics_event.dart'; import 'package:web_dex/bloc/analytics/analytics_repo.dart'; /// E27: Bot config wizard opened -class MarketbotSetupStartedEventData implements AnalyticsEventData { +class MarketbotSetupStartedEventData extends AnalyticsEventData { const MarketbotSetupStartedEventData({ required this.strategyType, required this.pairsCount, @@ -17,9 +17,9 @@ class MarketbotSetupStartedEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'strategy_type': strategyType, - 'pairs_count': pairsCount, - }; + 'strategy_type': strategyType, + 'pairs_count': pairsCount, + }; } class AnalyticsMarketbotSetupStartedEvent extends AnalyticsSendDataEvent { @@ -27,15 +27,15 @@ class AnalyticsMarketbotSetupStartedEvent extends AnalyticsSendDataEvent { required String strategyType, required int pairsCount, }) : super( - MarketbotSetupStartedEventData( - strategyType: strategyType, - pairsCount: pairsCount, - ), - ); + MarketbotSetupStartedEventData( + strategyType: strategyType, + pairsCount: pairsCount, + ), + ); } /// E28: Bot configured & saved -class MarketbotSetupCompleteEventData implements AnalyticsEventData { +class MarketbotSetupCompleteEventData extends AnalyticsEventData { const MarketbotSetupCompleteEventData({ required this.strategyType, required this.baseCapital, @@ -49,9 +49,9 @@ class MarketbotSetupCompleteEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'strategy_type': strategyType, - 'base_capital': baseCapital, - }; + 'strategy_type': strategyType, + 'base_capital': baseCapital, + }; } class AnalyticsMarketbotSetupCompleteEvent extends AnalyticsSendDataEvent { @@ -59,23 +59,23 @@ class AnalyticsMarketbotSetupCompleteEvent extends AnalyticsSendDataEvent { required String strategyType, required double baseCapital, }) : super( - MarketbotSetupCompleteEventData( - strategyType: strategyType, - baseCapital: baseCapital, - ), - ); + MarketbotSetupCompleteEventData( + strategyType: strategyType, + baseCapital: baseCapital, + ), + ); } /// E29: Bot placed a trade -class MarketbotTradeExecutedEventData implements AnalyticsEventData { +class MarketbotTradeExecutedEventData extends AnalyticsEventData { const MarketbotTradeExecutedEventData({ required this.pair, - required this.tradeSize, + required this.amount, required this.profitUsd, }); final String pair; - final double tradeSize; + final double amount; final double profitUsd; @override @@ -83,34 +83,34 @@ class MarketbotTradeExecutedEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'pair': pair, - 'trade_size': tradeSize, - 'profit_usd': profitUsd, - }; + 'pair': pair, + 'amount': amount, + 'profit_usd': profitUsd, + }; } class AnalyticsMarketbotTradeExecutedEvent extends AnalyticsSendDataEvent { AnalyticsMarketbotTradeExecutedEvent({ required String pair, - required double tradeSize, + required double amount, required double profitUsd, }) : super( - MarketbotTradeExecutedEventData( - pair: pair, - tradeSize: tradeSize, - profitUsd: profitUsd, - ), - ); + MarketbotTradeExecutedEventData( + pair: pair, + amount: amount, + profitUsd: profitUsd, + ), + ); } /// E30: Bot error encountered -class MarketbotErrorEventData implements AnalyticsEventData { +class MarketbotErrorEventData extends AnalyticsEventData { const MarketbotErrorEventData({ - required this.errorCode, + required this.failureDetail, required this.strategyType, }); - final String errorCode; + final String failureDetail; final String strategyType; @override @@ -118,19 +118,48 @@ class MarketbotErrorEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'error_code': errorCode, - 'strategy_type': strategyType, - }; + 'failure_reason': _formatFailureReason(reason: failureDetail), + 'strategy_type': strategyType, + }; } class AnalyticsMarketbotErrorEvent extends AnalyticsSendDataEvent { AnalyticsMarketbotErrorEvent({ - required String errorCode, + required String failureDetail, required String strategyType, }) : super( - MarketbotErrorEventData( - errorCode: errorCode, - strategyType: strategyType, - ), - ); + MarketbotErrorEventData( + failureDetail: failureDetail, + strategyType: strategyType, + ), + ); +} + +String _formatFailureReason({String? stage, String? reason, String? code}) { + final parts = []; + + String? sanitize(String? value) { + if (value == null) return null; + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + + final sanitizedStage = sanitize(stage); + final sanitizedReason = sanitize(reason); + final sanitizedCode = sanitize(code); + + if (sanitizedStage != null) { + parts.add('stage:$sanitizedStage'); + } + if (sanitizedReason != null) { + parts.add('reason:$sanitizedReason'); + } + if (sanitizedCode != null && sanitizedCode != sanitizedReason) { + parts.add('code:$sanitizedCode'); + } + + if (parts.isEmpty) { + return 'reason:unknown'; + } + return parts.join('|'); } diff --git a/lib/analytics/events/misc_events.dart b/lib/analytics/events/misc_events.dart index 12e741d43d..12fade7972 100644 --- a/lib/analytics/events/misc_events.dart +++ b/lib/analytics/events/misc_events.dart @@ -3,11 +3,8 @@ import 'package:web_dex/bloc/analytics/analytics_event.dart'; import 'package:web_dex/bloc/analytics/analytics_repo.dart'; /// E34: External DApp connection -class DappConnectEventData implements AnalyticsEventData { - const DappConnectEventData({ - required this.dappName, - required this.network, - }); +class DappConnectEventData extends AnalyticsEventData { + const DappConnectEventData({required this.dappName, required this.network}); final String dappName; final String network; @@ -16,26 +13,16 @@ class DappConnectEventData implements AnalyticsEventData { String get name => 'dapp_connect'; @override - JsonMap get parameters => { - 'dapp_name': dappName, - 'network': network, - }; + JsonMap get parameters => {'dapp_name': dappName, 'network': network}; } class AnalyticsDappConnectEvent extends AnalyticsSendDataEvent { - AnalyticsDappConnectEvent({ - required String dappName, - required String network, - }) : super( - DappConnectEventData( - dappName: dappName, - network: network, - ), - ); + AnalyticsDappConnectEvent({required String dappName, required String network}) + : super(DappConnectEventData(dappName: dappName, network: network)); } /// E35: Setting toggled -class SettingsChangeEventData implements AnalyticsEventData { +class SettingsChangeEventData extends AnalyticsEventData { const SettingsChangeEventData({ required this.settingName, required this.newValue, @@ -49,9 +36,9 @@ class SettingsChangeEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'setting_name': settingName, - 'new_value': newValue, - }; + 'setting_name': settingName, + 'new_value': newValue, + }; } class AnalyticsSettingsChangeEvent extends AnalyticsSendDataEvent { @@ -59,15 +46,12 @@ class AnalyticsSettingsChangeEvent extends AnalyticsSendDataEvent { required String settingName, required String newValue, }) : super( - SettingsChangeEventData( - settingName: settingName, - newValue: newValue, - ), - ); + SettingsChangeEventData(settingName: settingName, newValue: newValue), + ); } /// E36: Error dialog shown -class ErrorDisplayedEventData implements AnalyticsEventData { +class ErrorDisplayedEventData extends AnalyticsEventData { const ErrorDisplayedEventData({ required this.errorCode, required this.screenContext, @@ -81,9 +65,9 @@ class ErrorDisplayedEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'error_code': errorCode, - 'screen_context': screenContext, - }; + 'error_code': errorCode, + 'screen_context': screenContext, + }; } class AnalyticsErrorDisplayedEvent extends AnalyticsSendDataEvent { @@ -91,15 +75,15 @@ class AnalyticsErrorDisplayedEvent extends AnalyticsSendDataEvent { required String errorCode, required String screenContext, }) : super( - ErrorDisplayedEventData( - errorCode: errorCode, - screenContext: screenContext, - ), - ); + ErrorDisplayedEventData( + errorCode: errorCode, + screenContext: screenContext, + ), + ); } /// E37: App / referral shared -class AppShareEventData implements AnalyticsEventData { +class AppShareEventData extends AnalyticsEventData { const AppShareEventData({required this.channel}); final String channel; @@ -108,56 +92,17 @@ class AppShareEventData implements AnalyticsEventData { String get name => 'app_share'; @override - JsonMap get parameters => { - 'channel': channel, - }; + JsonMap get parameters => {'channel': channel}; } class AnalyticsAppShareEvent extends AnalyticsSendDataEvent { AnalyticsAppShareEvent({required String channel}) - : super( - AppShareEventData(channel: channel), - ); -} - -/// E40: User scroll attempt outside content -class ScrollAttemptOutsideContentEventData implements AnalyticsEventData { - const ScrollAttemptOutsideContentEventData({ - required this.screenContext, - required this.scrollDelta, - }); - - final String screenContext; - final double scrollDelta; - - @override - String get name => 'scroll_attempt_outside_content'; - - @override - JsonMap get parameters => { - 'screen_context': screenContext, - 'scroll_delta': scrollDelta, - }; -} - -class AnalyticsScrollAttemptOutsideContentEvent extends AnalyticsSendDataEvent { - AnalyticsScrollAttemptOutsideContentEvent({ - required String screenContext, - required double scrollDelta, - }) : super( - ScrollAttemptOutsideContentEventData( - screenContext: screenContext, - scrollDelta: scrollDelta, - ), - ); + : super(AppShareEventData(channel: channel)); } /// E42: Searchbar input submitted -class SearchbarInputEventData implements AnalyticsEventData { - const SearchbarInputEventData({ - required this.queryLength, - this.assetSymbol, - }); +class SearchbarInputEventData extends AnalyticsEventData { + const SearchbarInputEventData({required this.queryLength, this.assetSymbol}); final int queryLength; final String? assetSymbol; @@ -167,25 +112,23 @@ class SearchbarInputEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'query_length': queryLength, - if (assetSymbol != null) 'asset_symbol': assetSymbol!, - }; + 'query_length': queryLength, + if (assetSymbol != null) 'asset': assetSymbol!, + }; } class AnalyticsSearchbarInputEvent extends AnalyticsSendDataEvent { - AnalyticsSearchbarInputEvent({ - required int queryLength, - String? assetSymbol, - }) : super( - SearchbarInputEventData( - queryLength: queryLength, - assetSymbol: assetSymbol, - ), - ); + AnalyticsSearchbarInputEvent({required int queryLength, String? assetSymbol}) + : super( + SearchbarInputEventData( + queryLength: queryLength, + assetSymbol: assetSymbol, + ), + ); } /// E43: Theme selected -class ThemeSelectedEventData implements AnalyticsEventData { +class ThemeSelectedEventData extends AnalyticsEventData { const ThemeSelectedEventData({required this.themeName}); final String themeName; @@ -194,14 +137,10 @@ class ThemeSelectedEventData implements AnalyticsEventData { String get name => 'theme_selected'; @override - JsonMap get parameters => { - 'theme_name': themeName, - }; + JsonMap get parameters => {'theme_name': themeName}; } class AnalyticsThemeSelectedEvent extends AnalyticsSendDataEvent { AnalyticsThemeSelectedEvent({required String themeName}) - : super( - ThemeSelectedEventData(themeName: themeName), - ); + : super(ThemeSelectedEventData(themeName: themeName)); } diff --git a/lib/analytics/events/nft_events.dart b/lib/analytics/events/nft_events.dart index 02e323294a..73852f34f1 100644 --- a/lib/analytics/events/nft_events.dart +++ b/lib/analytics/events/nft_events.dart @@ -3,7 +3,7 @@ import 'package:web_dex/bloc/analytics/analytics_event.dart'; import 'package:web_dex/bloc/analytics/analytics_repo.dart'; /// E23: NFT gallery opened -class NftGalleryOpenedEventData implements AnalyticsEventData { +class NftGalleryOpenedEventData extends AnalyticsEventData { const NftGalleryOpenedEventData({ required this.nftCount, required this.loadTimeMs, @@ -16,10 +16,7 @@ class NftGalleryOpenedEventData implements AnalyticsEventData { String get name => 'nft_gallery_opened'; @override - JsonMap get parameters => { - 'nft_count': nftCount, - 'load_time_ms': loadTimeMs, - }; + JsonMap get parameters => {'nft_count': nftCount, 'load_time_ms': loadTimeMs}; } class AnalyticsNftGalleryOpenedEvent extends AnalyticsSendDataEvent { @@ -27,15 +24,12 @@ class AnalyticsNftGalleryOpenedEvent extends AnalyticsSendDataEvent { required int nftCount, required int loadTimeMs, }) : super( - NftGalleryOpenedEventData( - nftCount: nftCount, - loadTimeMs: loadTimeMs, - ), - ); + NftGalleryOpenedEventData(nftCount: nftCount, loadTimeMs: loadTimeMs), + ); } /// E24: NFT send flow started -class NftTransferInitiatedEventData implements AnalyticsEventData { +class NftTransferInitiatedEventData extends AnalyticsEventData { const NftTransferInitiatedEventData({ required this.collectionName, required this.tokenId, @@ -51,10 +45,10 @@ class NftTransferInitiatedEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'collection_name': collectionName, - 'token_id': tokenId, - 'hd_type': hdType, - }; + 'collection_name': collectionName, + 'token_id': tokenId, + 'hd_type': hdType, + }; } class AnalyticsNftTransferInitiatedEvent extends AnalyticsSendDataEvent { @@ -63,16 +57,16 @@ class AnalyticsNftTransferInitiatedEvent extends AnalyticsSendDataEvent { required String tokenId, required String hdType, }) : super( - NftTransferInitiatedEventData( - collectionName: collectionName, - tokenId: tokenId, - hdType: hdType, - ), - ); + NftTransferInitiatedEventData( + collectionName: collectionName, + tokenId: tokenId, + hdType: hdType, + ), + ); } /// E25: NFT sent successfully -class NftTransferSuccessEventData implements AnalyticsEventData { +class NftTransferSuccessEventData extends AnalyticsEventData { const NftTransferSuccessEventData({ required this.collectionName, required this.tokenId, @@ -90,11 +84,11 @@ class NftTransferSuccessEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'collection_name': collectionName, - 'token_id': tokenId, - 'fee': fee, - 'hd_type': hdType, - }; + 'collection_name': collectionName, + 'token_id': tokenId, + 'fee': fee, + 'hd_type': hdType, + }; } class AnalyticsNftTransferSuccessEvent extends AnalyticsSendDataEvent { @@ -104,25 +98,25 @@ class AnalyticsNftTransferSuccessEvent extends AnalyticsSendDataEvent { required double fee, required String hdType, }) : super( - NftTransferSuccessEventData( - collectionName: collectionName, - tokenId: tokenId, - fee: fee, - hdType: hdType, - ), - ); + NftTransferSuccessEventData( + collectionName: collectionName, + tokenId: tokenId, + fee: fee, + hdType: hdType, + ), + ); } /// E26: NFT send failed -class NftTransferFailureEventData implements AnalyticsEventData { +class NftTransferFailureEventData extends AnalyticsEventData { const NftTransferFailureEventData({ required this.collectionName, - required this.failReason, + required this.failureDetail, required this.hdType, }); final String collectionName; - final String failReason; + final String failureDetail; final String hdType; @override @@ -130,22 +124,51 @@ class NftTransferFailureEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'collection_name': collectionName, - 'fail_reason': failReason, - 'hd_type': hdType, - }; + 'collection_name': collectionName, + 'failure_reason': _formatFailureReason(reason: failureDetail), + 'hd_type': hdType, + }; } class AnalyticsNftTransferFailureEvent extends AnalyticsSendDataEvent { AnalyticsNftTransferFailureEvent({ required String collectionName, - required String failReason, + required String failureDetail, required String hdType, }) : super( - NftTransferFailureEventData( - collectionName: collectionName, - failReason: failReason, - hdType: hdType, - ), - ); + NftTransferFailureEventData( + collectionName: collectionName, + failureDetail: failureDetail, + hdType: hdType, + ), + ); +} + +String _formatFailureReason({String? stage, String? reason, String? code}) { + final parts = []; + + String? sanitize(String? value) { + if (value == null) return null; + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + + final sanitizedStage = sanitize(stage); + final sanitizedReason = sanitize(reason); + final sanitizedCode = sanitize(code); + + if (sanitizedStage != null) { + parts.add('stage:$sanitizedStage'); + } + if (sanitizedReason != null) { + parts.add('reason:$sanitizedReason'); + } + if (sanitizedCode != null && sanitizedCode != sanitizedReason) { + parts.add('code:$sanitizedCode'); + } + + if (parts.isEmpty) { + return 'reason:unknown'; + } + return parts.join('|'); } diff --git a/lib/analytics/events/portfolio_events.dart b/lib/analytics/events/portfolio_events.dart index 736fc6e1b9..bf05f094ca 100644 --- a/lib/analytics/events/portfolio_events.dart +++ b/lib/analytics/events/portfolio_events.dart @@ -8,7 +8,7 @@ import 'package:web_dex/bloc/analytics/analytics_repo.dart'; /// E07: Portfolio overview opened /// Measures when the portfolio overview is viewed. Business category: Portfolio. /// Provides insights on balance-check engagement. -class PortfolioViewedEventData implements AnalyticsEventData { +class PortfolioViewedEventData extends AnalyticsEventData { const PortfolioViewedEventData({ required this.totalCoins, required this.totalValueUsd, @@ -22,9 +22,9 @@ class PortfolioViewedEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'total_coins': totalCoins, - 'total_value_usd': totalValueUsd, - }; + 'total_coins': totalCoins, + 'total_value_usd': totalValueUsd, + }; } /// E07: Portfolio overview opened @@ -33,11 +33,11 @@ class AnalyticsPortfolioViewedEvent extends AnalyticsSendDataEvent { required int totalCoins, required double totalValueUsd, }) : super( - PortfolioViewedEventData( - totalCoins: totalCoins, - totalValueUsd: totalValueUsd, - ), - ); + PortfolioViewedEventData( + totalCoins: totalCoins, + totalValueUsd: totalValueUsd, + ), + ); } // E08: Growth chart opened @@ -46,7 +46,7 @@ class AnalyticsPortfolioViewedEvent extends AnalyticsSendDataEvent { /// E08: Growth chart opened /// Measures when a user opens the growth chart. Business category: Portfolio. /// Provides insights on long-term performance interest. -class PortfolioGrowthViewedEventData implements AnalyticsEventData { +class PortfolioGrowthViewedEventData extends AnalyticsEventData { const PortfolioGrowthViewedEventData({ required this.period, required this.growthPct, @@ -59,10 +59,7 @@ class PortfolioGrowthViewedEventData implements AnalyticsEventData { String get name => 'portfolio_growth_viewed'; @override - JsonMap get parameters => { - 'period': period, - 'growth_pct': growthPct, - }; + JsonMap get parameters => {'period': period, 'growth_pct': growthPct}; } /// E08: Growth chart opened @@ -71,11 +68,8 @@ class AnalyticsPortfolioGrowthViewedEvent extends AnalyticsSendDataEvent { required String period, required double growthPct, }) : super( - PortfolioGrowthViewedEventData( - period: period, - growthPct: growthPct, - ), - ); + PortfolioGrowthViewedEventData(period: period, growthPct: growthPct), + ); } // E09: P&L breakdown viewed @@ -84,7 +78,7 @@ class AnalyticsPortfolioGrowthViewedEvent extends AnalyticsSendDataEvent { /// E09: P&L breakdown viewed /// Measures when a user views the P&L breakdown. Business category: Portfolio. /// Provides insights on trading insight demand and upsell cues. -class PortfolioPnlViewedEventData implements AnalyticsEventData { +class PortfolioPnlViewedEventData extends AnalyticsEventData { const PortfolioPnlViewedEventData({ required this.timeframe, required this.realizedPnl, @@ -100,10 +94,10 @@ class PortfolioPnlViewedEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'timeframe': timeframe, - 'realized_pnl': realizedPnl, - 'unrealized_pnl': unrealizedPnl, - }; + 'timeframe': timeframe, + 'realized_pnl': realizedPnl, + 'unrealized_pnl': unrealizedPnl, + }; } /// E09: P&L breakdown viewed @@ -113,12 +107,12 @@ class AnalyticsPortfolioPnlViewedEvent extends AnalyticsSendDataEvent { required double realizedPnl, required double unrealizedPnl, }) : super( - PortfolioPnlViewedEventData( - timeframe: timeframe, - realizedPnl: realizedPnl, - unrealizedPnl: unrealizedPnl, - ), - ); + PortfolioPnlViewedEventData( + timeframe: timeframe, + realizedPnl: realizedPnl, + unrealizedPnl: unrealizedPnl, + ), + ); } // E10: Custom token added @@ -127,41 +121,37 @@ class AnalyticsPortfolioPnlViewedEvent extends AnalyticsSendDataEvent { /// E10: Custom token added /// Measures when a user adds a custom token. Business category: Asset Management. /// Provides insights on token diversity and network popularity. -class AssetAddedEventData implements AnalyticsEventData { +class AssetAddedEventData extends AnalyticsEventData { const AssetAddedEventData({ - required this.assetSymbol, - required this.assetNetwork, - required this.walletType, + required this.asset, + required this.network, + required this.hdType, }); - final String assetSymbol; - final String assetNetwork; - final String walletType; + final String asset; + final String network; + final String hdType; @override String get name => 'add_asset'; @override JsonMap get parameters => { - 'asset_symbol': assetSymbol, - 'asset_network': assetNetwork, - 'wallet_type': walletType, - }; + 'asset': asset, + 'network': network, + 'hd_type': hdType, + }; } /// E10: Custom token added class AnalyticsAssetAddedEvent extends AnalyticsSendDataEvent { AnalyticsAssetAddedEvent({ - required String assetSymbol, - required String assetNetwork, - required String walletType, + required String asset, + required String network, + required String hdType, }) : super( - AssetAddedEventData( - assetSymbol: assetSymbol, - assetNetwork: assetNetwork, - walletType: walletType, - ), - ); + AssetAddedEventData(asset: asset, network: network, hdType: hdType), + ); } // E11: Asset detail viewed @@ -170,41 +160,37 @@ class AnalyticsAssetAddedEvent extends AnalyticsSendDataEvent { /// E11: Asset detail viewed /// Measures when a user views the detailed information of an asset. Business category: Asset Management. /// Provides insights on asset popularity and research depth. -class AssetViewedEventData implements AnalyticsEventData { +class AssetViewedEventData extends AnalyticsEventData { const AssetViewedEventData({ - required this.assetSymbol, - required this.assetNetwork, - required this.walletType, + required this.asset, + required this.network, + required this.hdType, }); - final String assetSymbol; - final String assetNetwork; - final String walletType; + final String asset; + final String network; + final String hdType; @override String get name => 'view_asset'; @override JsonMap get parameters => { - 'asset_symbol': assetSymbol, - 'asset_network': assetNetwork, - 'wallet_type': walletType, - }; + 'asset': asset, + 'network': network, + 'hd_type': hdType, + }; } /// E11: Asset detail viewed class AnalyticsAssetViewedEvent extends AnalyticsSendDataEvent { AnalyticsAssetViewedEvent({ - required String assetSymbol, - required String assetNetwork, - required String walletType, + required String asset, + required String network, + required String hdType, }) : super( - AssetViewedEventData( - assetSymbol: assetSymbol, - assetNetwork: assetNetwork, - walletType: walletType, - ), - ); + AssetViewedEventData(asset: asset, network: network, hdType: hdType), + ); } // E12: Existing asset toggled on / made visible @@ -213,41 +199,37 @@ class AnalyticsAssetViewedEvent extends AnalyticsSendDataEvent { /// E12: Existing asset toggled on / made visible /// Measures when a user enables an existing asset. Business category: Asset Management. /// Provides insights on which assets users want on dashboard and feature adoption. -class AssetEnabledEventData implements AnalyticsEventData { +class AssetEnabledEventData extends AnalyticsEventData { const AssetEnabledEventData({ - required this.assetSymbol, - required this.assetNetwork, - required this.walletType, + required this.asset, + required this.network, + required this.hdType, }); - final String assetSymbol; - final String assetNetwork; - final String walletType; + final String asset; + final String network; + final String hdType; @override String get name => 'asset_enabled'; @override JsonMap get parameters => { - 'asset_symbol': assetSymbol, - 'asset_network': assetNetwork, - 'wallet_type': walletType, - }; + 'asset': asset, + 'network': network, + 'hd_type': hdType, + }; } /// E12: Existing asset toggled on / made visible class AnalyticsAssetEnabledEvent extends AnalyticsSendDataEvent { AnalyticsAssetEnabledEvent({ - required String assetSymbol, - required String assetNetwork, - required String walletType, + required String asset, + required String network, + required String hdType, }) : super( - AssetEnabledEventData( - assetSymbol: assetSymbol, - assetNetwork: assetNetwork, - walletType: walletType, - ), - ); + AssetEnabledEventData(asset: asset, network: network, hdType: hdType), + ); } // E13: Token toggled off / hidden @@ -256,39 +238,35 @@ class AnalyticsAssetEnabledEvent extends AnalyticsSendDataEvent { /// E13: Token toggled off / hidden /// Measures when a user disables or hides a token. Business category: Asset Management. /// Provides insights on portfolio-cleanup behavior and waning asset interest. -class AssetDisabledEventData implements AnalyticsEventData { +class AssetDisabledEventData extends AnalyticsEventData { const AssetDisabledEventData({ - required this.assetSymbol, - required this.assetNetwork, - required this.walletType, + required this.asset, + required this.network, + required this.hdType, }); - final String assetSymbol; - final String assetNetwork; - final String walletType; + final String asset; + final String network; + final String hdType; @override String get name => 'asset_disabled'; @override JsonMap get parameters => { - 'asset_symbol': assetSymbol, - 'asset_network': assetNetwork, - 'wallet_type': walletType, - }; + 'asset': asset, + 'network': network, + 'hd_type': hdType, + }; } /// E13: Token toggled off / hidden class AnalyticsAssetDisabledEvent extends AnalyticsSendDataEvent { AnalyticsAssetDisabledEvent({ - required String assetSymbol, - required String assetNetwork, - required String walletType, + required String asset, + required String network, + required String hdType, }) : super( - AssetDisabledEventData( - assetSymbol: assetSymbol, - assetNetwork: assetNetwork, - walletType: walletType, - ), - ); + AssetDisabledEventData(asset: asset, network: network, hdType: hdType), + ); } diff --git a/lib/analytics/events/reward_events.dart b/lib/analytics/events/reward_events.dart index b78ca1bf9b..34ccc2ac1f 100644 --- a/lib/analytics/events/reward_events.dart +++ b/lib/analytics/events/reward_events.dart @@ -3,7 +3,7 @@ import 'package:web_dex/bloc/analytics/analytics_event.dart'; import 'package:web_dex/bloc/analytics/analytics_repo.dart'; /// E31: KMD reward claim started -class RewardClaimInitiatedEventData implements AnalyticsEventData { +class RewardClaimInitiatedEventData extends AnalyticsEventData { const RewardClaimInitiatedEventData({ required this.asset, required this.expectedRewardAmount, @@ -17,9 +17,9 @@ class RewardClaimInitiatedEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'asset': asset, - 'expected_reward_amount': expectedRewardAmount, - }; + 'asset': asset, + 'expected_reward_amount': expectedRewardAmount, + }; } class AnalyticsRewardClaimInitiatedEvent extends AnalyticsSendDataEvent { @@ -27,15 +27,15 @@ class AnalyticsRewardClaimInitiatedEvent extends AnalyticsSendDataEvent { required String asset, required double expectedRewardAmount, }) : super( - RewardClaimInitiatedEventData( - asset: asset, - expectedRewardAmount: expectedRewardAmount, - ), - ); + RewardClaimInitiatedEventData( + asset: asset, + expectedRewardAmount: expectedRewardAmount, + ), + ); } /// E32: KMD reward claim succeeded -class RewardClaimSuccessEventData implements AnalyticsEventData { +class RewardClaimSuccessEventData extends AnalyticsEventData { const RewardClaimSuccessEventData({ required this.asset, required this.rewardAmount, @@ -48,10 +48,7 @@ class RewardClaimSuccessEventData implements AnalyticsEventData { String get name => 'reward_claim_success'; @override - JsonMap get parameters => { - 'asset': asset, - 'reward_amount': rewardAmount, - }; + JsonMap get parameters => {'asset': asset, 'amount': rewardAmount}; } class AnalyticsRewardClaimSuccessEvent extends AnalyticsSendDataEvent { @@ -59,15 +56,12 @@ class AnalyticsRewardClaimSuccessEvent extends AnalyticsSendDataEvent { required String asset, required double rewardAmount, }) : super( - RewardClaimSuccessEventData( - asset: asset, - rewardAmount: rewardAmount, - ), - ); + RewardClaimSuccessEventData(asset: asset, rewardAmount: rewardAmount), + ); } /// E33: Reward claim failed -class RewardClaimFailureEventData implements AnalyticsEventData { +class RewardClaimFailureEventData extends AnalyticsEventData { const RewardClaimFailureEventData({ required this.asset, required this.failReason, @@ -81,19 +75,43 @@ class RewardClaimFailureEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'asset': asset, - 'fail_reason': failReason, - }; + 'asset': asset, + 'failure_reason': _formatFailureReason(reason: failReason), + }; +} + +String _formatFailureReason({String? stage, String? reason, String? code}) { + final parts = []; + + String? sanitize(String? value) { + if (value == null) return null; + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + + final sanitizedStage = sanitize(stage); + final sanitizedReason = sanitize(reason); + final sanitizedCode = sanitize(code); + + if (sanitizedStage != null) { + parts.add('stage:$sanitizedStage'); + } + if (sanitizedReason != null) { + parts.add('reason:$sanitizedReason'); + } + if (sanitizedCode != null && sanitizedCode != sanitizedReason) { + parts.add('code:$sanitizedCode'); + } + + if (parts.isEmpty) { + return 'reason:unknown'; + } + return parts.join('|'); } class AnalyticsRewardClaimFailureEvent extends AnalyticsSendDataEvent { AnalyticsRewardClaimFailureEvent({ required String asset, required String failReason, - }) : super( - RewardClaimFailureEventData( - asset: asset, - failReason: failReason, - ), - ); + }) : super(RewardClaimFailureEventData(asset: asset, failReason: failReason)); } diff --git a/lib/analytics/events/security_events.dart b/lib/analytics/events/security_events.dart index c4dbdf41ab..1ce78e1bc2 100644 --- a/lib/analytics/events/security_events.dart +++ b/lib/analytics/events/security_events.dart @@ -4,26 +4,26 @@ import 'package:web_dex/bloc/analytics/analytics_repo.dart'; /// E05: Seed backup finished /// Business category: Security. -class BackupCompletedEventData implements AnalyticsEventData { +class BackupCompletedEventData extends AnalyticsEventData { const BackupCompletedEventData({ required this.backupTime, required this.method, - required this.walletType, + required this.hdType, }); final int backupTime; final String method; - final String walletType; + final String hdType; @override String get name => 'backup_complete'; @override JsonMap get parameters => { - 'backup_time': backupTime, - 'method': method, - 'wallet_type': walletType, - }; + 'backup_time': backupTime, + 'method': method, + 'hd_type': hdType, + }; } /// E05: Seed backup finished @@ -31,46 +31,40 @@ class AnalyticsBackupCompletedEvent extends AnalyticsSendDataEvent { AnalyticsBackupCompletedEvent({ required int backupTime, required String method, - required String walletType, + required String hdType, }) : super( - BackupCompletedEventData( - backupTime: backupTime, - method: method, - walletType: walletType, - ), - ); + BackupCompletedEventData( + backupTime: backupTime, + method: method, + hdType: hdType, + ), + ); } /// E06: Backup skipped / postponed /// Business category: Security. -class BackupSkippedEventData implements AnalyticsEventData { +class BackupSkippedEventData extends AnalyticsEventData { const BackupSkippedEventData({ required this.stageSkipped, - required this.walletType, + required this.hdType, }); final String stageSkipped; - final String walletType; + final String hdType; @override String get name => 'backup_skipped'; @override - JsonMap get parameters => { - 'stage_skipped': stageSkipped, - 'wallet_type': walletType, - }; + JsonMap get parameters => {'stage_skipped': stageSkipped, 'hd_type': hdType}; } /// E06: Backup skipped / postponed class AnalyticsBackupSkippedEvent extends AnalyticsSendDataEvent { AnalyticsBackupSkippedEvent({ required String stageSkipped, - required String walletType, + required String hdType, }) : super( - BackupSkippedEventData( - stageSkipped: stageSkipped, - walletType: walletType, - ), - ); + BackupSkippedEventData(stageSkipped: stageSkipped, hdType: hdType), + ); } diff --git a/lib/analytics/events/transaction_events.dart b/lib/analytics/events/transaction_events.dart index 57c6f0b3ce..8d099f14be 100644 --- a/lib/analytics/events/transaction_events.dart +++ b/lib/analytics/events/transaction_events.dart @@ -9,44 +9,46 @@ import '../../bloc/analytics/analytics_repo.dart'; /// E14: Send flow started /// Measures when a user initiates a send transaction. Business category: Transactions. /// Provides insights on transaction funnel start and popular send assets. -class SendInitiatedEventData implements AnalyticsEventData { +class SendInitiatedEventData extends AnalyticsEventData { const SendInitiatedEventData({ - required this.assetSymbol, + required this.asset, required this.network, required this.amount, - required this.walletType, + required this.hdType, }); - final String assetSymbol; + final String asset; final String network; final double amount; - final String walletType; + final String hdType; @override String get name => 'send_initiated'; @override JsonMap get parameters => { - 'asset_symbol': assetSymbol, - 'network': network, - 'amount': amount, - 'wallet_type': walletType, - }; + 'asset': asset, + 'network': network, + 'amount': amount, + 'hd_type': hdType, + }; } /// E14: Send flow started class AnalyticsSendInitiatedEvent extends AnalyticsSendDataEvent { AnalyticsSendInitiatedEvent({ - required String assetSymbol, + required String asset, required String network, required double amount, - required String walletType, - }) : super(SendInitiatedEventData( - assetSymbol: assetSymbol, - network: network, - amount: amount, - walletType: walletType, - )); + required String hdType, + }) : super( + SendInitiatedEventData( + asset: asset, + network: network, + amount: amount, + hdType: hdType, + ), + ); } // E15: On-chain send completed @@ -55,44 +57,46 @@ class AnalyticsSendInitiatedEvent extends AnalyticsSendDataEvent { /// E15: On-chain send completed /// Measures when an on-chain send transaction is completed successfully. Business category: Transactions. /// Provides insights on successful sends, volume, and average size. -class SendSucceededEventData implements AnalyticsEventData { +class SendSucceededEventData extends AnalyticsEventData { const SendSucceededEventData({ - required this.assetSymbol, + required this.asset, required this.network, required this.amount, - required this.walletType, + required this.hdType, }); - final String assetSymbol; + final String asset; final String network; final double amount; - final String walletType; + final String hdType; @override String get name => 'send_success'; @override JsonMap get parameters => { - 'asset_symbol': assetSymbol, - 'network': network, - 'amount': amount, - 'wallet_type': walletType, - }; + 'asset': asset, + 'network': network, + 'amount': amount, + 'hd_type': hdType, + }; } /// E15: On-chain send completed class AnalyticsSendSucceededEvent extends AnalyticsSendDataEvent { AnalyticsSendSucceededEvent({ - required String assetSymbol, + required String asset, required String network, required double amount, - required String walletType, - }) : super(SendSucceededEventData( - assetSymbol: assetSymbol, - network: network, - amount: amount, - walletType: walletType, - )); + required String hdType, + }) : super( + SendSucceededEventData( + asset: asset, + network: network, + amount: amount, + hdType: hdType, + ), + ); } // E16: Send failed / cancelled @@ -101,44 +105,46 @@ class AnalyticsSendSucceededEvent extends AnalyticsSendDataEvent { /// E16: Send failed / cancelled /// Measures when a send transaction fails or is cancelled. Business category: Transactions. /// Provides insights on error hotspots and UX/network issues. -class SendFailedEventData implements AnalyticsEventData { +class SendFailedEventData extends AnalyticsEventData { const SendFailedEventData({ - required this.assetSymbol, + required this.asset, required this.network, - required this.failReason, - required this.walletType, + required this.failureReason, + required this.hdType, }); - final String assetSymbol; + final String asset; final String network; - final String failReason; - final String walletType; + final String failureReason; + final String hdType; @override String get name => 'send_failure'; @override JsonMap get parameters => { - 'asset_symbol': assetSymbol, - 'network': network, - 'fail_reason': failReason, - 'wallet_type': walletType, - }; + 'asset': asset, + 'network': network, + 'failure_reason': _formatFailureReason(reason: failureReason), + 'hd_type': hdType, + }; } /// E16: Send failed / cancelled class AnalyticsSendFailedEvent extends AnalyticsSendDataEvent { AnalyticsSendFailedEvent({ - required String assetSymbol, + required String asset, required String network, - required String failReason, - required String walletType, - }) : super(SendFailedEventData( - assetSymbol: assetSymbol, - network: network, - failReason: failReason, - walletType: walletType, - )); + required String failureReason, + required String hdType, + }) : super( + SendFailedEventData( + asset: asset, + network: network, + failureReason: failureReason, + hdType: hdType, + ), + ); } // E17: Swap order submitted @@ -147,44 +153,51 @@ class AnalyticsSendFailedEvent extends AnalyticsSendDataEvent { /// E17: Swap order submitted /// Measures when a swap order is submitted. Business category: Trading (DEX). /// Provides insights on DEX funnel start and pair demand. -class SwapInitiatedEventData implements AnalyticsEventData { +class SwapInitiatedEventData extends AnalyticsEventData { const SwapInitiatedEventData({ - required this.fromAsset, - required this.toAsset, - required this.networks, - required this.walletType, + required this.asset, + required this.secondaryAsset, + required this.network, + required this.secondaryNetwork, + required this.hdType, }); - final String fromAsset; - final String toAsset; - final String networks; - final String walletType; + final String asset; + final String secondaryAsset; + final String network; + final String secondaryNetwork; + final String hdType; @override String get name => 'swap_initiated'; @override JsonMap get parameters => { - 'from_asset': fromAsset, - 'to_asset': toAsset, - 'networks': networks, - 'wallet_type': walletType, - }; + 'asset': asset, + 'secondary_asset': secondaryAsset, + 'network': network, + 'secondary_network': secondaryNetwork, + 'hd_type': hdType, + }; } /// E17: Swap order submitted class AnalyticsSwapInitiatedEvent extends AnalyticsSendDataEvent { AnalyticsSwapInitiatedEvent({ - required String fromAsset, - required String toAsset, - required String networks, - required String walletType, - }) : super(SwapInitiatedEventData( - fromAsset: fromAsset, - toAsset: toAsset, - networks: networks, - walletType: walletType, - )); + required String asset, + required String secondaryAsset, + required String network, + required String secondaryNetwork, + required String hdType, + }) : super( + SwapInitiatedEventData( + asset: asset, + secondaryAsset: secondaryAsset, + network: network, + secondaryNetwork: secondaryNetwork, + hdType: hdType, + ), + ); } // E18: Atomic swap succeeded @@ -193,21 +206,25 @@ class AnalyticsSwapInitiatedEvent extends AnalyticsSendDataEvent { /// E18: Atomic swap succeeded /// Measures when an atomic swap is completed successfully. Business category: Trading (DEX). /// Provides insights on trading volume and fee revenue. -class SwapSucceededEventData implements AnalyticsEventData { +class SwapSucceededEventData extends AnalyticsEventData { const SwapSucceededEventData({ - required this.fromAsset, - required this.toAsset, + required this.asset, + required this.secondaryAsset, + required this.network, + required this.secondaryNetwork, required this.amount, required this.fee, - required this.walletType, + required this.hdType, this.durationMs, }); - final String fromAsset; - final String toAsset; + final String asset; + final String secondaryAsset; + final String network; + final String secondaryNetwork; final double amount; final double fee; - final String walletType; + final String hdType; final int? durationMs; @override @@ -215,32 +232,40 @@ class SwapSucceededEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'from_asset': fromAsset, - 'to_asset': toAsset, - 'amount': amount, - 'fee': fee, - 'wallet_type': walletType, - if (durationMs != null) 'duration_ms': durationMs, - }; + 'asset': asset, + 'secondary_asset': secondaryAsset, + 'network': network, + 'secondary_network': secondaryNetwork, + 'amount': amount, + 'fee': fee, + 'hd_type': hdType, + if (durationMs != null) 'duration_ms': durationMs, + }; } /// E18: Atomic swap succeeded class AnalyticsSwapSucceededEvent extends AnalyticsSendDataEvent { AnalyticsSwapSucceededEvent({ - required String fromAsset, - required String toAsset, + required String asset, + required String secondaryAsset, + required String network, + required String secondaryNetwork, required double amount, required double fee, - required String walletType, + required String hdType, int? durationMs, - }) : super(SwapSucceededEventData( - fromAsset: fromAsset, - toAsset: toAsset, - amount: amount, - fee: fee, - walletType: walletType, - durationMs: durationMs, - )); + }) : super( + SwapSucceededEventData( + asset: asset, + secondaryAsset: secondaryAsset, + network: network, + secondaryNetwork: secondaryNetwork, + amount: amount, + fee: fee, + hdType: hdType, + durationMs: durationMs, + ), + ); } // E19: Swap failed @@ -249,19 +274,25 @@ class AnalyticsSwapSucceededEvent extends AnalyticsSendDataEvent { /// E19: Swap failed /// Measures when an atomic swap fails. Business category: Trading (DEX). /// Provides insights on liquidity gaps and technical/UX blockers. -class SwapFailedEventData implements AnalyticsEventData { +class SwapFailedEventData extends AnalyticsEventData { const SwapFailedEventData({ - required this.fromAsset, - required this.toAsset, - required this.failStage, - required this.walletType, + required this.asset, + required this.secondaryAsset, + required this.network, + required this.secondaryNetwork, + required this.failureStage, + this.failureDetail, + required this.hdType, this.durationMs, }); - final String fromAsset; - final String toAsset; - final String failStage; - final String walletType; + final String asset; + final String secondaryAsset; + final String network; + final String secondaryNetwork; + final String failureStage; + final String? failureDetail; + final String hdType; final int? durationMs; @override @@ -269,27 +300,69 @@ class SwapFailedEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'from_asset': fromAsset, - 'to_asset': toAsset, - 'fail_stage': failStage, - 'wallet_type': walletType, - if (durationMs != null) 'duration_ms': durationMs, - }; + 'asset': asset, + 'secondary_asset': secondaryAsset, + 'network': network, + 'secondary_network': secondaryNetwork, + 'failure_reason': _formatFailureReason( + stage: failureStage, + reason: failureDetail, + ), + 'hd_type': hdType, + if (durationMs != null) 'duration_ms': durationMs, + }; } /// E19: Swap failed class AnalyticsSwapFailedEvent extends AnalyticsSendDataEvent { AnalyticsSwapFailedEvent({ - required String fromAsset, - required String toAsset, - required String failStage, - required String walletType, + required String asset, + required String secondaryAsset, + required String network, + required String secondaryNetwork, + required String failureStage, + String? failureDetail, + required String hdType, int? durationMs, - }) : super(SwapFailedEventData( - fromAsset: fromAsset, - toAsset: toAsset, - failStage: failStage, - walletType: walletType, - durationMs: durationMs, - )); + }) : super( + SwapFailedEventData( + asset: asset, + secondaryAsset: secondaryAsset, + network: network, + secondaryNetwork: secondaryNetwork, + failureStage: failureStage, + failureDetail: failureDetail, + hdType: hdType, + durationMs: durationMs, + ), + ); +} + +String _formatFailureReason({String? stage, String? reason, String? code}) { + final parts = []; + + String? sanitize(String? value) { + if (value == null) return null; + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + + final sanitizedStage = sanitize(stage); + final sanitizedReason = sanitize(reason); + final sanitizedCode = sanitize(code); + + if (sanitizedStage != null) { + parts.add('stage:$sanitizedStage'); + } + if (sanitizedReason != null) { + parts.add('reason:$sanitizedReason'); + } + if (sanitizedCode != null && sanitizedCode != sanitizedReason) { + parts.add('code:$sanitizedCode'); + } + + if (parts.isEmpty) { + return 'reason:unknown'; + } + return parts.join('|'); } diff --git a/lib/analytics/events/user_acquisition_events.dart b/lib/analytics/events/user_acquisition_events.dart index 695a8ce1c4..0535f91231 100644 --- a/lib/analytics/events/user_acquisition_events.dart +++ b/lib/analytics/events/user_acquisition_events.dart @@ -5,11 +5,8 @@ import 'package:web_dex/bloc/analytics/analytics_repo.dart'; /// E02: Wallet setup flow begins /// Measures when the onboarding flow starts. /// Business category: User Acquisition. -class OnboardingStartedEventData implements AnalyticsEventData { - const OnboardingStartedEventData({ - required this.method, - this.referralSource, - }); +class OnboardingStartedEventData extends AnalyticsEventData { + const OnboardingStartedEventData({required this.method, this.referralSource}); final String method; final String? referralSource; @@ -19,9 +16,9 @@ class OnboardingStartedEventData implements AnalyticsEventData { @override JsonMap get parameters => { - 'method': method, - if (referralSource != null) 'referral_source': referralSource!, - }; + 'method': method, + if (referralSource != null) 'referral_source': referralSource!, + }; } /// E02: Wallet setup flow begins @@ -30,69 +27,56 @@ class AnalyticsOnboardingStartedEvent extends AnalyticsSendDataEvent { required String method, String? referralSource, }) : super( - OnboardingStartedEventData( - method: method, - referralSource: referralSource, - ), - ); + OnboardingStartedEventData( + method: method, + referralSource: referralSource, + ), + ); } /// E03: New wallet generated /// Business category: User Acquisition. -class WalletCreatedEventData implements AnalyticsEventData { - const WalletCreatedEventData({ - required this.source, - required this.walletType, - }); +class WalletCreatedEventData extends AnalyticsEventData { + const WalletCreatedEventData({required this.source, required this.hdType}); final String source; - final String walletType; + final String hdType; @override String get name => 'wallet_created'; @override - JsonMap get parameters => { - 'source': source, - 'wallet_type': walletType, - }; + JsonMap get parameters => {'source': source, 'hd_type': hdType}; } /// E03: New wallet generated class AnalyticsWalletCreatedEvent extends AnalyticsSendDataEvent { - AnalyticsWalletCreatedEvent({ - required String source, - required String walletType, - }) : super( - WalletCreatedEventData( - source: source, - walletType: walletType, - ), - ); + AnalyticsWalletCreatedEvent({required String source, required String hdType}) + : super(WalletCreatedEventData(source: source, hdType: hdType)); } /// E04: Existing wallet imported /// Business category: User Acquisition. -class WalletImportedEventData implements AnalyticsEventData { +class WalletImportedEventData extends AnalyticsEventData { const WalletImportedEventData({ required this.source, required this.importType, - required this.walletType, + required this.hdType, }); final String source; final String importType; - final String walletType; + final String hdType; @override String get name => 'wallet_imported'; @override JsonMap get parameters => { - 'source': source, - 'import_type': importType, - 'wallet_type': walletType, - }; + 'source': source, + 'import_type': importType, + 'hd_type': hdType, + }; } /// E04: Existing wallet imported @@ -100,12 +84,12 @@ class AnalyticsWalletImportedEvent extends AnalyticsSendDataEvent { AnalyticsWalletImportedEvent({ required String source, required String importType, - required String walletType, + required String hdType, }) : super( - WalletImportedEventData( - source: source, - importType: importType, - walletType: walletType, - ), - ); + WalletImportedEventData( + source: source, + importType: importType, + hdType: hdType, + ), + ); } diff --git a/lib/analytics/events/user_engagement_events.dart b/lib/analytics/events/user_engagement_events.dart index 1550a1245a..c63fbe2797 100644 --- a/lib/analytics/events/user_engagement_events.dart +++ b/lib/analytics/events/user_engagement_events.dart @@ -5,11 +5,8 @@ import 'package:web_dex/bloc/analytics/analytics_repo.dart'; /// E01: App launched / foregrounded /// Measures when the application is opened or returns to foreground. /// Business category: User Engagement. -class AppOpenedEventData implements AnalyticsEventData { - const AppOpenedEventData({ - required this.platform, - required this.appVersion, - }); +class AppOpenedEventData extends AnalyticsEventData { + const AppOpenedEventData({required this.platform, required this.appVersion}); final String platform; final String appVersion; @@ -18,10 +15,7 @@ class AppOpenedEventData implements AnalyticsEventData { String get name => 'app_open'; @override - JsonMap get parameters => { - 'platform': platform, - 'app_version': appVersion, - }; + JsonMap get parameters => {'platform': platform, 'app_version': appVersion}; } /// E01: App launched / foregrounded @@ -29,10 +23,5 @@ class AnalyticsAppOpenedEvent extends AnalyticsSendDataEvent { AnalyticsAppOpenedEvent({ required String platform, required String appVersion, - }) : super( - AppOpenedEventData( - platform: platform, - appVersion: appVersion, - ), - ); + }) : super(AppOpenedEventData(platform: platform, appVersion: appVersion)); } diff --git a/lib/analytics/required_analytics_events.csv b/lib/analytics/required_analytics_events.csv index ee4d333d85..f6893a35aa 100644 --- a/lib/analytics/required_analytics_events.csv +++ b/lib/analytics/required_analytics_events.csv @@ -1,45 +1,45 @@ -What This Measures,GA4 Event Name,Business Category,Key Parameters (examples),Business Insights,Priority,Completed,Trigger Location,Implementation Notes -App launched / foregrounded,app_open,User Engagement,"platform, app_version","DAU / MAU, usage frequency, platform mix",High (v0.9.0),FALSE,App start & foreground events in main.dart,Send AnalyticsEvents.appOpened when AppLifecycleState.resumed using platform & version info. -Wallet setup flow begins,onboarding_start,User Acquisition,"method (create/import), referral_source","Funnel start, referral impact",High (v0.9.0),FALSE,Wallet setup intro screen,Dispatch on create/import wallet button tap to record onboarding source. -New wallet generated,wallet_created,User Acquisition,source (mobile/desktop),"New-user conversion, platform pref",High (v0.9.0),FALSE,Wallet creation flow completion,Call AnalyticsEvents.walletCreated after new wallet seed generated. -Existing wallet imported,wallet_imported,User Acquisition,"source, import_type","Power-user acquisition, migration rate",High (v0.9.0),FALSE,Wallet import success handler,Invoke AnalyticsEvents.walletImported with import type and wallet type. -Seed backup finished,backup_complete,Security Adoption,"backup_time, method","Security uptake, UX health",High (v0.9.0),FALSE,Backup verification screen,Send event when user confirms seed phrase backup. -Backup skipped / postponed,backup_skipped,Security Risk,stage_skipped,"At-risk cohort size, friction stage",High (v0.9.0),FALSE,Backup reminder prompt,Fire when user skips or postpones backup step. -Portfolio overview opened,portfolio_viewed,Portfolio,"total_coins, total_value_usd",Balance-check engagement,High (v0.9.0),FALSE,Wallet main dashboard,Log when wallet overview page builds with totals. -Growth chart opened,portfolio_growth_viewed,Portfolio,"period (1h/1d/7d/1M/1Y), growth_pct",Long-term performance interest,High (v0.9.0),FALSE,Portfolio growth chart widget,Trigger when growth chart tab opened with selected period. -P&L breakdown viewed,portfolio_pnl_viewed,Portfolio,"timeframe, realized_pnl, unrealized_pnl","Trading insight demand, upsell cues",High (v0.9.0),FALSE,Profit & loss chart widget,Send event when PnL breakdown screen displayed. -Custom token added,add_asset,Asset Mgmt,"asset_symbol, asset_network","Token diversity, network popularity",High (v0.9.0),FALSE,Add custom token flow,Emit after asset successfully added to wallet list. -Asset detail viewed,view_asset,Asset Mgmt,"asset_symbol, asset_network","Asset popularity, research depth",High (v0.9.0),FALSE,Coin details page open,Send when navigating to asset details screen. -Existing asset toggled on / made visible,asset_enabled,Asset Mgmt,"asset_symbol, asset_network","Which assets users want on dashboard, feature adoption",High (v0.9.0),FALSE,Token visibility toggle,Trigger when user enables an existing asset in portfolio. -Token toggled off / hidden,asset_disabled,Asset Mgmt,"asset_symbol, asset_network","Portfolio-cleanup behaviour, waning asset interest",High (v0.9.0),FALSE,Token visibility toggle,Trigger when user hides an asset from portfolio. -Send flow started,send_initiated,Transactions,"asset_symbol, network, amount","Tx funnel start, popular send assets",High (v0.9.0),FALSE,Send form start,Log when user opens send flow with asset and amount. -On-chain send completed,send_success,Transactions,"asset_symbol, network, amount","Successful sends, volume, avg size",High (v0.9.0),FALSE,Send confirmation,Fire after a transaction broadcast succeeds. -Send failed / cancelled,send_failure,Transactions,"asset_symbol, network, fail_reason","Error hotspots, UX / network issues",High (v0.9.0),FALSE,Send error handling,Emit when send flow fails or is cancelled. -Swap order submitted,swap_initiated,Trading (DEX),"from_asset, to_asset, networks","DEX funnel start, pair demand",High (v0.9.0),TRUE,Swap order submit,Dispatch when atomic swap order created. -Atomic swap succeeded,swap_success,Trading (DEX),"from_asset, to_asset, amount, fee","Trading volume, fee revenue",High (v0.9.0),TRUE,Swap completion,Send on successful atomic swap completion. -Swap failed,swap_failure,Trading (DEX),"from_asset, to_asset, fail_stage","Liquidity gaps, tech/UX blockers",High (v0.9.0),TRUE,Swap error,Log when swap fails at any stage. -Bridge transfer started,bridge_initiated,Cross-Chain,"from_chain, to_chain, asset","Bridge demand, chain pairs",Medium (v0.9.1),TRUE,Bridge transfer start,Emit when cross-chain bridge initiated. -Bridge completed,bridge_success,Cross-Chain,"from_chain, to_chain, asset, amount","Cross-chain volume, success rate",Medium (v0.9.1),TRUE,Bridge completion,Send when bridge transfer succeeds. -Bridge failed,bridge_failure,Cross-Chain,"from_chain, to_chain, fail_error","Reliability issues, risk analysis",Medium (v0.9.1),TRUE,Bridge error,Fire when bridge transfer fails. -NFT gallery opened (measure load perf),nft_gallery_opened,NFT Wallet,"nft_count, load_time_ms","NFT engagement, gallery performance",Medium (v0.9.1),FALSE,NFT gallery screen,Record load time and count when gallery opened. -NFT send flow started,nft_transfer_initiated,NFT Wallet,"collection_name, token_id, hd_type","NFT tx funnel start, collection popularity",Medium (v0.9.1),FALSE,NFT send screen,Trigger when user opens NFT transfer flow. -NFT sent successfully,nft_transfer_success,NFT Wallet,"collection_name, token_id, fee, hd_type","NFT volume, user confidence",Medium (v0.9.1),FALSE,NFT send confirmation,Log when NFT transfer completes successfully. -NFT send failed,nft_transfer_failure,NFT Wallet,"collection_name, fail_reason, hd_type",Pain points in NFT UX or network,Medium (v0.9.1),FALSE,NFT send error,Emit when NFT transfer fails. -Bot config wizard opened,marketbot_setup_start,Market Bot,"strategy_type, pairs_count",Interest in automated trading,Medium (v0.9.1),FALSE,MarketBot setup wizard,Record when user opens bot configuration. -Bot configured & saved,marketbot_setup_complete,Market Bot,"strategy_type, base_capital","Bot adoption, barriers cleared",Medium (v0.9.1),FALSE,MarketBot wizard finish,Send when user saves bot settings. -Bot placed a trade,marketbot_trade_executed,Market Bot,"pair, trade_size, profit_usd","Bot performance, revenue impact",Medium (v0.9.1),FALSE,MarketBot trade callback,Log each automated trade executed by the bot. -Bot error encountered,marketbot_error,Market Bot,"error_code, strategy_type","Bot reliability, engineering focus",Medium (v0.9.1),FALSE,MarketBot error handler,Emit when bot encounters an error. -KMD active user reward claim started,reward_claim_initiated,Rewards,"asset, expected_reward_amount",Yield feature curiosity,Medium (v0.9.1),FALSE,KMD rewards screen,Send when user starts claiming active user rewards. -KMD active user reward claimed,reward_claim_success,Rewards,"asset, reward_amount","Reward uptake, total payout",Medium (v0.9.1),FALSE,KMD rewards success,Trigger after reward claim transaction success. -Reward claim failed,reward_claim_failure,Rewards,"asset, fail_reason",Reward friction points,Medium (v0.9.1),FALSE,KMD rewards failure,Emit when claim fails or is rejected. -External DApp connection,dapp_connect,Ecosystem,"dapp_name, network",Ecosystem engagement,Low (Backlog),FALSE,DApp connection prompt,Log when external DApp handshake approved. -Setting toggled,settings_change,Preferences,"setting_name, new_value",User preference trends,Low (Backlog),FALSE,Settings toggles,Fire whenever a user toggles a setting value. -Error dialog shown,error_displayed,Stability,"error_code, screen_context",Crash/error hotspots,Medium (v0.9.1),FALSE,Global error dialogs,Send when error dialog is shown to user. -App / referral shared,app_share,Growth,channel,Viral growth potential,Medium (v0.9.1),FALSE,Share/referral actions,Emit when user shares app via share sheet. -Fresh receive address derived,hd_address_generated,HD Wallet Ops,"account_index, address_index, asset_symbol","Address-reuse risk, payment UX",High (v0.9.0),FALSE,Receive page address generation,Log when new HD receive address derived. -User scroll attempt when cursor/touch outside scrollable content,scroll_attempt_outside_content,UX Interaction,"screen_context, scroll_delta",Quantify unintended scroll patterns; validate scroll‑lock decision,Low (Backlog),FALSE,Scrollable widgets,Trigger when user tries to scroll while pointer outside list bounds. -Time until the top of the coins list crosses 50 % of viewport,wallet_list_half_viewport,UI Usability,"time_to_half_ms, wallet_size",Detect if users struggle to reach balances; optimise list layout,Low (Backlog),FALSE,Coins list performance metric,Record time until wallet list scrolls halfway on first load. -Price/metadata refresh completed on launch,coins_data_updated,Data Sync,"coins_count, update_source, update_duration_ms",Ensure data freshness; monitor failed or slow syncs,High (v0.9.0),FALSE,Coins data refresh,Send when price/metadata update completes at launch. -User submits text in coin‑search bar,searchbar_input,Search,"query_length, asset_symbol (if matched)","Gauge coin interest, search success; inform default lists",Medium (v0.9.1),FALSE,Coin search field,Emit on search submission with query stats. -User selects app theme,theme_selected,Preferences,theme_name (light/dark/auto),Theme preference statistics; influence dev priorities,Low (Backlog),FALSE,Theme selection page,Log when user chooses light/dark/auto theme. -Delay from page open until interactive (spinner hidden),page_interactive_delay,Performance,"page_name, interactive_delay_ms, spinner_time_ms",Identify performance bottlenecks impacting UX,High (v0.9.0),FALSE,Page load performance,Record time until spinner hidden after page open. +What This Measures,GA4 Event Name,Business Category,Key Parameters (examples),Business Insights,Priority,Completed,Trigger Location,Implementation Notes +"NOTE: Matomo Dimension Scopes - Visit-level dimensions (dimension1-5): platform, app_version, referral_source, theme_name, update_source. These are set ONCE per session via trackDimensions(). Action-level dimensions (dimension6+): All other parameters. These are sent WITH each event via the dimensions parameter of trackEvent().",,,,,,,, +App launched / foregrounded,app_open,User Engagement,"platform, app_version","DAU / MAU, usage frequency, platform mix",High (v0.9.0),TRUE,App start & foreground events in main.dart,"Set visit dimensions via trackDimensions({dimension1: platform, dimension2: app_version}); then trackEvent() for the event itself." +Wallet setup flow begins,onboarding_start,User Acquisition,"method (create/import), referral_source","Funnel start, referral impact",High (v0.9.0),TRUE,Wallet setup intro screen,Set visit dimension3 (referral_source) via trackDimensions() if available; send method as action dimension via trackEvent(). +New wallet generated,wallet_created,User Acquisition,platform,"New-user conversion, platform pref",High (v0.9.0),TRUE,Wallet creation flow completion,"Call AnalyticsEvents.walletCreated; platform is visit dimension1 (already set at session start, not sent with event)." +Existing wallet imported,wallet_imported,User Acquisition,"platform, import_type","Power-user acquisition, migration rate",High (v0.9.0),TRUE,Wallet import success handler,"Send import_type as action dimension; platform is visit dimension1 (already set, not sent with event)." +Seed backup finished,backup_complete,Security Adoption,"backup_time, method","Security uptake, UX health",High (v0.9.0),TRUE,Backup verification screen,Send event when user confirms seed phrase backup. +Backup skipped / postponed,backup_skipped,Security Risk,stage_skipped,"At-risk cohort size, friction stage",High (v0.9.0),TRUE,Backup reminder prompt,Fire when user skips or postpones backup step. +Portfolio overview opened,portfolio_viewed,Portfolio,"total_coins, total_value_usd",Balance-check engagement,High (v0.9.0),TRUE,Wallet main dashboard,Log when wallet overview page builds with totals. +Growth chart opened,portfolio_growth_viewed,Portfolio,"period (1h/1d/7d/1M/1Y), growth_pct",Long-term performance interest,High (v0.9.0),TRUE,Portfolio growth chart widget,Trigger when growth chart tab opened with selected period. +P&L breakdown viewed,portfolio_pnl_viewed,Portfolio,"timeframe, realized_pnl, unrealized_pnl","Trading insight demand, upsell cues",High (v0.9.0),TRUE,Profit & loss chart widget,Send event when PnL breakdown screen displayed. +Custom token added,add_asset,Asset Mgmt,"asset, network","Token diversity, network popularity",High (v0.9.0),TRUE,Add custom token flow,Emit after asset successfully added to wallet list. +Asset detail viewed,view_asset,Asset Mgmt,"asset, network","Asset popularity, research depth",High (v0.9.0),TRUE,Coin details page open,Send when navigating to asset details screen. +Existing asset toggled on / made visible,asset_enabled,Asset Mgmt,"asset, network","Which assets users want on dashboard, feature adoption",High (v0.9.0),TRUE,Token visibility toggle,Trigger when user enables an existing asset in portfolio. +Token toggled off / hidden,asset_disabled,Asset Mgmt,"asset, network","Portfolio-cleanup behaviour, waning asset interest",High (v0.9.0),TRUE,Token visibility toggle,Trigger when user hides an asset from portfolio. +Send flow started,send_initiated,Transactions,"asset, network, amount","Tx funnel start, popular send assets",High (v0.9.0),TRUE,Send form start,Log when user opens send flow with asset and amount. +On-chain send completed,send_success,Transactions,"asset, network, amount","Successful sends, volume, avg size",High (v0.9.0),TRUE,Send confirmation,Fire after a transaction broadcast succeeds. +Send failed / cancelled,send_failure,Transactions,"asset, network, failure_reason","Error hotspots, UX / network issues",High (v0.9.0),TRUE,Send error handling,Emit when send flow fails or is cancelled. +Swap order submitted,swap_initiated,Trading (DEX),"asset, secondary_asset, network, secondary_network","DEX funnel start, pair demand",High (v0.9.0),TRUE,Swap order submit,Dispatch when atomic swap order created. +Atomic swap succeeded,swap_success,Trading (DEX),"asset, secondary_asset, network, secondary_network, amount, fee","Trading volume, fee revenue",High (v0.9.0),TRUE,Swap completion,Send on successful atomic swap completion. +Swap failed,swap_failure,Trading (DEX),"asset, secondary_asset, network, secondary_network, failure_reason","Liquidity gaps, tech/UX blockers",High (v0.9.0),TRUE,Swap error,Log when swap fails at any stage. +Bridge transfer started,bridge_initiated,Cross-Chain,"asset, secondary_asset, network, secondary_network","Bridge demand, chain pairs",Medium (v0.9.1),TRUE,Bridge transfer start,Emit when cross-chain bridge initiated. +Bridge completed,bridge_success,Cross-Chain,"asset, secondary_asset, network, secondary_network, amount, duration_ms","Cross-chain volume, success rate",Medium (v0.9.1),TRUE,Bridge completion,Send when bridge transfer succeeds. +Bridge failed,bridge_failure,Cross-Chain,"asset, secondary_asset, network, secondary_network, failure_reason, duration_ms","Reliability issues, risk analysis",Medium (v0.9.1),TRUE,Bridge error,Fire when bridge transfer fails. +NFT gallery opened (measure load perf),nft_gallery_opened,NFT Wallet,"nft_count, load_time_ms","NFT engagement, gallery performance",Medium (v0.9.1),TRUE,NFT gallery screen,Record load time and count when gallery opened. +NFT send flow started,nft_transfer_initiated,NFT Wallet,"collection_name, token_id, hd_type","NFT tx funnel start, collection popularity",Medium (v0.9.1),TRUE,NFT send screen,Trigger when user opens NFT transfer flow. +NFT sent successfully,nft_transfer_success,NFT Wallet,"collection_name, token_id, fee, hd_type","NFT volume, user confidence",Medium (v0.9.1),TRUE,NFT send confirmation,Log when NFT transfer completes successfully. +NFT send failed,nft_transfer_failure,NFT Wallet,"collection_name, failure_reason, hd_type",Pain points in NFT UX or network,Medium (v0.9.1),TRUE,NFT send error,Emit when NFT transfer fails. +Bot config wizard opened,marketbot_setup_start,Market Bot,"strategy_type, pairs_count",Interest in automated trading,Medium (v0.9.1),TRUE,MarketBot setup wizard,Record when user opens bot configuration. +Bot configured & saved,marketbot_setup_complete,Market Bot,"strategy_type, base_capital","Bot adoption, barriers cleared",Medium (v0.9.1),TRUE,MarketBot wizard finish,Send when user saves bot settings. +Bot placed a trade,marketbot_trade_executed,Market Bot,"pair, amount, profit_usd","Bot performance, revenue impact",Medium (v0.9.1),TRUE,MarketBot trade callback,Log each automated trade executed by the bot. +Bot error encountered,marketbot_error,Market Bot,"failure_reason, strategy_type","Bot reliability, engineering focus",Medium (v0.9.1),TRUE,MarketBot error handler,Emit when bot encounters an error. +KMD active user reward claim started,reward_claim_initiated,Rewards,"asset, expected_reward_amount",Yield feature curiosity,Medium (v0.9.1),TRUE,KMD rewards screen,Send when user starts claiming active user rewards. +KMD active user reward claimed,reward_claim_success,Rewards,"asset, amount","Reward uptake, total payout",Medium (v0.9.1),TRUE,KMD rewards success,Trigger after reward claim transaction success. +Reward claim failed,reward_claim_failure,Rewards,"asset, failure_reason",Reward friction points,Medium (v0.9.1),TRUE,KMD rewards failure,Emit when claim fails or is rejected. +External DApp connection,dapp_connect,Ecosystem,"dapp_name, network",Ecosystem engagement,Low (Backlog),FALSE,DApp connection prompt,Log when external DApp handshake approved. +Setting toggled,settings_change,Preferences,"setting_name, new_value",User preference trends,Low (Backlog),FALSE,Settings toggles,Fire whenever a user toggles a setting value. +Error dialog shown,error_displayed,Stability,"failure_reason, screen_context",Crash/error hotspots,Medium (v0.9.1),FALSE,Global error dialogs,Send when error dialog is shown to user. +App / referral shared,app_share,Growth,channel,Viral growth potential,Medium (v0.9.1),FALSE,Share/referral actions,Emit when user shares app via share sheet. +Fresh receive address derived,hd_address_generated,HD Wallet Ops,"account_index, address_index, asset","Address-reuse risk, payment UX",High (v0.9.0),TRUE,Receive page address generation,Log when new HD receive address derived. +Time until the top of the coins list crosses 50 % of viewport,wallet_list_half_viewport,UI Usability,"time_to_half_ms, wallet_size",Detect if users struggle to reach balances; optimise list layout,Low (Backlog),TRUE,Coins list performance metric,Record time until wallet list scrolls halfway on first load. +Price/metadata refresh completed on launch,coins_data_updated,Data Sync,"coins_count, update_source, update_duration_ms",Ensure data freshness; monitor failed or slow syncs,High (v0.9.0),TRUE,Coins data refresh,Set visit dimension5 (update_source) via trackDimensions() at session start; send coins_count & update_duration_ms as action dimensions. +User submits text in coin‑search bar,searchbar_input,Search,"query_length, asset (if matched)","Gauge coin interest, search success; inform default lists",Medium (v0.9.1),TRUE,Coin search field,Emit on search submission with query stats. +User selects app theme,theme_selected,Preferences,theme_name (light/dark/auto),Theme preference statistics; influence dev priorities,Low (Backlog),TRUE,Theme selection page,Update visit dimension4 (theme_name) via trackDimensions() when theme changes; optionally also send as event. +Delay from page open until interactive (spinner hidden),page_interactive_delay,Performance,"page_name, interactive_delay_ms, spinner_time_ms",Identify performance bottlenecks impacting UX,High (v0.9.0),TRUE,Page load performance,Record time until spinner hidden after page open. diff --git a/lib/app_config/app_config.dart b/lib/app_config/app_config.dart index 551b99947e..8378cbfba7 100644 --- a/lib/app_config/app_config.dart +++ b/lib/app_config/app_config.dart @@ -107,15 +107,8 @@ const Set excludedAssetList = { 'FENIX', 'AWR', 'BOT', - // Pirate activation params are not yet implemented, so we need to - // exclude it from the list of coins for now. - 'ARRR', - 'ZOMBIE', 'SMTF-v2', 'SFUSD', - 'VOTE2023', - 'RICK', - 'MORTY', // NFT v2 coins: https://github.com/KomodoPlatform/coins/pull/1061 will be // used in the background, so users do not need to see them. diff --git a/lib/bloc/analytics/analytics_api.dart b/lib/bloc/analytics/analytics_api.dart new file mode 100644 index 0000000000..0d61b4d51a --- /dev/null +++ b/lib/bloc/analytics/analytics_api.dart @@ -0,0 +1,33 @@ +import 'dart:async'; +import 'package:web_dex/model/settings/analytics_settings.dart'; +import 'analytics_repo.dart'; + +/// Abstract interface for analytics providers +abstract class AnalyticsApi { + /// Initialize the analytics provider + Future initialize(AnalyticsSettings settings); + + /// Send an analytics event + Future sendEvent(AnalyticsEventData event); + + /// Activate analytics collection + Future activate(); + + /// Deactivate analytics collection + Future deactivate(); + + /// Check if the provider is initialized + bool get isInitialized; + + /// Check if the provider is enabled + bool get isEnabled; + + /// Get the provider name + String get providerName; + + /// Retry initialization if it previously failed + Future retryInitialization(AnalyticsSettings settings); + + /// Cleanup resources + Future dispose(); +} diff --git a/lib/bloc/analytics/analytics_repo.dart b/lib/bloc/analytics/analytics_repo.dart index 1ae7d94243..04f3be58ae 100644 --- a/lib/bloc/analytics/analytics_repo.dart +++ b/lib/bloc/analytics/analytics_repo.dart @@ -1,26 +1,36 @@ -import 'dart:convert'; import 'dart:async'; -import 'package:firebase_analytics/firebase_analytics.dart'; -import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; import 'package:get_it/get_it.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:web_dex/model/settings/analytics_settings.dart'; import 'package:web_dex/shared/utils/utils.dart'; -import 'package:web_dex/firebase_options.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'analytics_api.dart'; +import 'firebase_analytics_api.dart'; +import 'matomo_analytics_api.dart'; abstract class AnalyticsEventData { + const AnalyticsEventData(); + String get name; Map get parameters; + + MapEntry? get primaryParameter { + final params = parameters; + if (params.isEmpty) { + return null; + } + final iterator = params.entries.iterator; + if (!iterator.moveNext()) { + return null; + } + return iterator.current; + } } /// A simple implementation of AnalyticsEventData for persisted events -class PersistedAnalyticsEventData implements AnalyticsEventData { - PersistedAnalyticsEventData({ - required this.name, - required this.parameters, - }); +class PersistedAnalyticsEventData extends AnalyticsEventData { + PersistedAnalyticsEventData({required this.name, required this.parameters}); @override final String name; @@ -60,108 +70,99 @@ abstract class AnalyticsRepo { Future loadPersistedQueue(); /// Cleanup resources used by the repository - void dispose(); + Future dispose(); } -class FirebaseAnalyticsRepo implements AnalyticsRepo { - FirebaseAnalyticsRepo(AnalyticsSettings settings) { - _initializeWithRetry(settings); +/// Unified analytics repository that handles multiple analytics providers +class AnalyticsRepository implements AnalyticsRepo { + AnalyticsRepository(AnalyticsSettings settings) { + _initializeProviders(settings); } - late FirebaseAnalytics _instance; - final Completer _initCompleter = Completer(); - + final List _providers = []; bool _isInitialized = false; bool _isEnabled = false; - int _initRetryCount = 0; - static const int _maxInitRetries = 3; - static const String _persistedQueueKey = 'analytics_persisted_queue'; - /// Queue to store events when analytics is disabled - final List _eventQueue = []; - - /// Timer for periodic queue persistence - Timer? _queuePersistenceTimer; - - /// For checking initialization status @override bool get isInitialized => _isInitialized; - /// For checking if analytics is enabled @override bool get isEnabled => _isEnabled; - /// Registers the AnalyticsRepo instance with GetIt for dependency injection + /// Registers the AnalyticsRepository instance with GetIt for dependency injection static void register(AnalyticsSettings settings) { if (!GetIt.I.isRegistered()) { - final repo = FirebaseAnalyticsRepo(settings); + final repo = AnalyticsRepository(settings); GetIt.I.registerSingleton(repo); if (kDebugMode) { log( - 'AnalyticsRepo registered with GetIt', - path: 'analytics -> FirebaseAnalyticsService -> register', + 'AnalyticsRepository registered with GetIt', + path: 'analytics -> AnalyticsRepository -> register', ); } } else if (kDebugMode) { log( - 'AnalyticsRepo already registered with GetIt', - path: 'analytics -> FirebaseAnalyticsService -> register', + 'AnalyticsRepository already registered with GetIt', + path: 'analytics -> AnalyticsRepository -> register', ); } } - /// Initialize with retry mechanism - Future _initializeWithRetry(AnalyticsSettings settings) async { - // Firebase is not supported on Linux - if (!kIsWeb && defaultTargetPlatform == TargetPlatform.linux) { - _isInitialized = false; - _isEnabled = false; - _initCompleter.completeError(UnsupportedError); - return; - } - + /// Initialize all configured analytics providers + Future _initializeProviders(AnalyticsSettings settings) async { try { if (kDebugMode) { log( - 'Initializing Firebase Analytics with settings: isSendAllowed=${settings.isSendAllowed}', - path: 'analytics -> FirebaseAnalyticsService -> _initialize', + 'Initializing analytics providers with settings: isSendAllowed=${settings.isSendAllowed}', + path: 'analytics -> AnalyticsRepository -> _initializeProviders', ); } - // Setup queue persistence timer - _queuePersistenceTimer = Timer.periodic( - const Duration(minutes: 5), - (_) => persistQueue(), - ); - - // Load any previously saved events - await loadPersistedQueue(); - - // Initialize Firebase - try { - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, + // Add Firebase Analytics provider + final firebaseProvider = FirebaseAnalyticsApi(); + _providers.add(firebaseProvider); + + // Add Matomo Analytics provider when configuration is present + final bool hasMatomoConfig = + matomoUrl.isNotEmpty && matomoSiteId.isNotEmpty; + if (hasMatomoConfig) { + final matomoProvider = MatomoAnalyticsApi(); + _providers.add(matomoProvider); + } else if (kDebugMode) { + log( + 'Matomo provider not registered: missing MATOMO_URL and/or MATOMO_SITE_ID', + path: 'analytics -> AnalyticsRepository -> _initializeProviders', ); - } on UnsupportedError { - _isInitialized = false; - _isEnabled = false; - if (kDebugMode) { - log('Firebase Analytics initializeApp failed with UnsupportedError'); - } - _initCompleter.completeError(UnsupportedError); - return; } - _instance = FirebaseAnalytics.instance; - _isInitialized = true; - _isEnabled = settings.isSendAllowed; + // Initialize all providers + final initFutures = _providers.map( + (provider) => provider.initialize(settings), + ); + await Future.wait(initFutures, eagerError: false); + + // Check if at least one provider is initialized successfully + final initializedProviders = _providers + .where((p) => p.isInitialized) + .toList(); + _isInitialized = initializedProviders.isNotEmpty; + // Disable analytics in CI or when analyticsDisabled flag is set + final bool shouldDisable = analyticsDisabled || isCiEnvironment; + _isEnabled = settings.isSendAllowed && !shouldDisable; if (kDebugMode) { log( - 'Firebase Analytics initialized: _isInitialized=$_isInitialized, _isEnabled=$_isEnabled', - path: 'analytics -> FirebaseAnalyticsService -> _initialize', + 'Analytics providers initialized: ${initializedProviders.length}/${_providers.length} successful', + path: 'analytics -> AnalyticsRepository -> _initializeProviders', ); + + for (final provider in _providers) { + log( + '${provider.providerName}: initialized=${provider.isInitialized}, enabled=${provider.isEnabled}', + path: 'analytics -> AnalyticsRepository -> _initializeProviders', + ); + } } if (_isInitialized && _isEnabled) { @@ -169,123 +170,66 @@ class FirebaseAnalyticsRepo implements AnalyticsRepo { } else { await deactivate(); } - - // Successfully initialized - if (!_initCompleter.isCompleted) { - _initCompleter.complete(); - } } catch (e) { - _isInitialized = false; - if (kDebugMode) { log( - 'Error initializing Firebase Analytics: $e', - path: 'analytics -> FirebaseAnalyticsService -> _initialize', + 'Error initializing analytics providers: $e', + path: 'analytics -> AnalyticsRepository -> _initializeProviders', isError: true, ); } - - // Try to initialize again if we haven't exceeded max retries - if (_initRetryCount < _maxInitRetries) { - _initRetryCount++; - - if (kDebugMode) { - log( - 'Retrying analytics initialization (attempt $_initRetryCount of $_maxInitRetries)', - path: 'analytics -> FirebaseAnalyticsService -> _initialize', - ); - } - - // Retry with exponential backoff - await Future.delayed(Duration(seconds: 2 * _initRetryCount)); - await _initializeWithRetry(settings); - } else { - // Maximum retries exceeded - if (!_initCompleter.isCompleted) { - _initCompleter.completeError(e); - } - } - } - } - - /// Retry initialization if it previously failed - @override - Future retryInitialization(AnalyticsSettings settings) async { - if (!_isInitialized) { - _initRetryCount = 0; - return _initializeWithRetry(settings); } } @override Future sendData(AnalyticsEventData event) async { - final sanitizedParameters = event.parameters.map((key, value) { - if (value == null) return MapEntry(key, "null"); - if (value is Map || value is List) { - return MapEntry(key, jsonEncode(value)); - } - return MapEntry(key, value.toString()); - }); - - // Log the event in debug mode with formatted parameters for better readability - if (kDebugMode) { - final formattedParams = - const JsonEncoder.withIndent(' ').convert(sanitizedParameters); - log( - 'Analytics Event: ${event.name}; Parameters: $formattedParams', - path: 'analytics -> FirebaseAnalyticsService -> sendData', - ); + if (!_isInitialized || !_isEnabled) { + return queueEvent(event); } - try { - await _instance.logEvent( - name: event.name, - parameters: sanitizedParameters, - ); - } catch (e, s) { - log( - e.toString(), - path: 'analytics -> FirebaseAnalyticsService -> logEvent', - trace: s, - isError: true, - ); - } - } + // Forward to all providers; individual providers handle enabled/queueing + final sendFutures = _providers.map( + (provider) => _sendToProvider(provider, event), + ); - @override - Future queueEvent(AnalyticsEventData data) async { - // Log the queued event in debug mode with formatted parameters - if (kDebugMode) { - final formattedParams = - const JsonEncoder.withIndent(' ').convert(data.parameters); - log( - 'Analytics Event Queued: ${data.name}\nParameters:\n$formattedParams', - path: 'analytics -> FirebaseAnalyticsService -> queueEvent', - ); - } + await Future.wait(sendFutures, eagerError: false); + } - if (!_isInitialized) { - _eventQueue.add(data); + Future _sendToProvider( + AnalyticsApi provider, + AnalyticsEventData event, + ) async { + try { + await provider.sendEvent(event); + } catch (e) { if (kDebugMode) { log( - 'Analytics not initialized, added to queue (${_eventQueue.length} events queued)', - path: 'analytics -> FirebaseAnalyticsService -> queueEvent', + 'Error sending event to ${provider.providerName}: $e', + path: 'analytics -> AnalyticsRepository -> _sendToProvider', + isError: true, ); } - return; } + } - if (_isEnabled) { - await sendData(data); - } else { - _eventQueue.add(data); - if (kDebugMode) { - log( - 'Analytics disabled, added to queue (${_eventQueue.length} events queued)', - path: 'analytics -> FirebaseAnalyticsService -> queueEvent', - ); + @override + Future queueEvent(AnalyticsEventData data) async { + // Always forward to providers; they are responsible for queuing/persisting + final queueFutures = _providers.map((provider) async { + try { + await provider.sendEvent(data); + } catch (e) { + if (kDebugMode) { + log( + 'Error queueing event for ${provider.providerName}: $e', + path: 'analytics -> AnalyticsRepository -> queueEvent', + isError: true, + ); + } } - } + }); + + await Future.wait(queueFutures, eagerError: false); } @override @@ -295,30 +239,30 @@ class FirebaseAnalyticsRepo implements AnalyticsRepo { } _isEnabled = true; - await _instance.setAnalyticsCollectionEnabled(true); - // Process any queued events - if (_eventQueue.isNotEmpty) { - if (kDebugMode) { - log( - 'Processing ${_eventQueue.length} queued analytics events', - path: 'analytics -> FirebaseAnalyticsService -> activate', - ); - } + final activateFutures = _providers + .where((provider) => provider.isInitialized) + .map((provider) => _activateProvider(provider)); - final queuedEvents = List.from(_eventQueue); - _eventQueue.clear(); + await Future.wait(activateFutures, eagerError: false); - int processedCount = 0; - for (final event in queuedEvents) { - await sendData(event); - processedCount++; - } + if (kDebugMode) { + log( + 'Analytics providers activated', + path: 'analytics -> AnalyticsRepository -> activate', + ); + } + } - if (kDebugMode && processedCount > 0) { + Future _activateProvider(AnalyticsApi provider) async { + try { + await provider.activate(); + } catch (e) { + if (kDebugMode) { log( - 'Successfully processed $processedCount queued analytics events', - path: 'analytics -> FirebaseAnalyticsService -> activate', + 'Error activating ${provider.providerName}: $e', + path: 'analytics -> AnalyticsRepository -> _activateProvider', + isError: true, ); } } @@ -330,63 +274,30 @@ class FirebaseAnalyticsRepo implements AnalyticsRepo { return; } + _isEnabled = false; + + final deactivateFutures = _providers + .where((provider) => provider.isInitialized) + .map((provider) => _deactivateProvider(provider)); + + await Future.wait(deactivateFutures, eagerError: false); + if (kDebugMode) { log( - 'Analytics collection disabled', - path: 'analytics -> FirebaseAnalyticsService -> deactivate', + 'Analytics providers deactivated', + path: 'analytics -> AnalyticsRepository -> deactivate', ); } - - _isEnabled = false; - await _instance.setAnalyticsCollectionEnabled(false); } - @override - Future persistQueue() async { - if (_eventQueue.isEmpty) { - if (kDebugMode) { - log( - 'No events to persist (queue empty)', - path: 'analytics -> FirebaseAnalyticsService -> persistQueue', - ); - } - return; - } - + Future _deactivateProvider(AnalyticsApi provider) async { try { + await provider.deactivate(); + } catch (e) { if (kDebugMode) { log( - 'Persisting ${_eventQueue.length} queued analytics events', - path: 'analytics -> FirebaseAnalyticsService -> persistQueue', - ); - } - - final prefs = await SharedPreferences.getInstance(); - - // Convert events to a serializable format - final serializedEvents = _eventQueue.map((event) { - return { - 'name': event.name, - 'parameters': event.parameters, - }; - }).toList(); - - // Serialize and store - final serialized = jsonEncode(serializedEvents); - await prefs.setString(_persistedQueueKey, serialized); - - if (kDebugMode) { - log( - 'Successfully persisted ${_eventQueue.length} events to SharedPreferences', - path: 'analytics -> FirebaseAnalyticsService -> persistQueue', - ); - } - } catch (e, s) { - if (kDebugMode) { - log( - 'Error persisting analytics queue: $e', - path: 'analytics -> FirebaseAnalyticsService -> persistQueue', - trace: s, + 'Error deactivating ${provider.providerName}: $e', + path: 'analytics -> AnalyticsRepository -> _deactivateProvider', isError: true, ); } @@ -394,76 +305,79 @@ class FirebaseAnalyticsRepo implements AnalyticsRepo { } @override - Future loadPersistedQueue() async { - try { - if (kDebugMode) { - log( - 'Loading persisted analytics events from SharedPreferences', - path: 'analytics -> FirebaseAnalyticsService -> loadPersistedQueue', - ); - } - - final prefs = await SharedPreferences.getInstance(); - final serialized = prefs.getString(_persistedQueueKey); - - if (serialized == null || serialized.isEmpty) { + Future retryInitialization(AnalyticsSettings settings) async { + final retryFutures = _providers.map((provider) async { + try { + await provider.retryInitialization(settings); + } catch (e) { if (kDebugMode) { log( - 'No persisted analytics events found', - path: 'analytics -> FirebaseAnalyticsService -> loadPersistedQueue', + 'Error retrying initialization for ${provider.providerName}: $e', + path: 'analytics -> AnalyticsRepository -> retryInitialization', + isError: true, ); } - return; } + }); - // Deserialize the data - final List decodedList = jsonDecode(serialized); - - // Create PersistedAnalyticsEventData instances - for (final eventMap in decodedList) { - _eventQueue.add(PersistedAnalyticsEventData( - name: eventMap['name'], - parameters: Map.from(eventMap['parameters']), - )); - } + await Future.wait(retryFutures, eagerError: false); - if (kDebugMode) { - log( - 'Loaded ${_eventQueue.length} persisted analytics events', - path: 'analytics -> FirebaseAnalyticsService -> loadPersistedQueue', - ); - } + // Update initialization status + final initializedProviders = _providers + .where((p) => p.isInitialized) + .toList(); + _isInitialized = initializedProviders.isNotEmpty; + } - // Clear the persisted data after loading - await prefs.remove(_persistedQueueKey); - } catch (e, s) { - if (kDebugMode) { - log( - 'Error loading persisted analytics queue: $e', - path: 'analytics -> FirebaseAnalyticsService -> loadPersistedQueue', - trace: s, - isError: true, - ); - } + @override + Future persistQueue() async { + // Each provider handles its own queue persistence + // This is a no-op at the repository level since providers manage their own queues + if (kDebugMode) { + log( + 'Queue persistence handled by individual providers', + path: 'analytics -> AnalyticsRepository -> persistQueue', + ); } } - /// Cleanup resources used by the repository @override - void dispose() { - if (_queuePersistenceTimer != null) { - _queuePersistenceTimer!.cancel(); - _queuePersistenceTimer = null; + Future loadPersistedQueue() async { + // Each provider handles loading its own persisted queue + // This is a no-op at the repository level since providers manage their own queues + if (kDebugMode) { + log( + 'Queue loading handled by individual providers', + path: 'analytics -> AnalyticsRepository -> loadPersistedQueue', + ); + } + } - if (kDebugMode) { - log( - 'Cancelled queue persistence timer', - path: 'analytics -> FirebaseAnalyticsService -> dispose', - ); + @override + Future dispose() async { + final disposeFutures = _providers.map((provider) async { + try { + await provider.dispose(); + } catch (e) { + if (kDebugMode) { + log( + 'Error disposing ${provider.providerName}: $e', + path: 'analytics -> AnalyticsRepository -> dispose', + isError: true, + ); + } } - } + }); + + await Future.wait(disposeFutures, eagerError: false); - // Persist any remaining events before disposing - persistQueue(); + _providers.clear(); + + if (kDebugMode) { + log( + 'AnalyticsRepository disposed', + path: 'analytics -> AnalyticsRepository -> dispose', + ); + } } } diff --git a/lib/bloc/analytics/firebase_analytics_api.dart b/lib/bloc/analytics/firebase_analytics_api.dart new file mode 100644 index 0000000000..347663ec45 --- /dev/null +++ b/lib/bloc/analytics/firebase_analytics_api.dart @@ -0,0 +1,362 @@ +import 'dart:convert'; +import 'dart:async'; + +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:web_dex/model/settings/analytics_settings.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/firebase_options.dart'; +import 'analytics_api.dart'; +import 'analytics_repo.dart'; + +class FirebaseAnalyticsApi implements AnalyticsApi { + late FirebaseAnalytics _instance; + final Completer _initCompleter = Completer(); + + bool _isInitialized = false; + bool _isEnabled = false; + int _initRetryCount = 0; + static const int _maxInitRetries = 3; + static const String _persistedQueueKey = 'firebase_analytics_persisted_queue'; + + /// Queue to store events when analytics is disabled + final List _eventQueue = []; + + /// Timer for periodic queue persistence + Timer? _queuePersistenceTimer; + + @override + String get providerName => 'Firebase'; + + @override + bool get isInitialized => _isInitialized; + + @override + bool get isEnabled => _isEnabled; + + @override + Future initialize(AnalyticsSettings settings) async { + return _initializeWithRetry(settings); + } + + /// Initialize with retry mechanism + Future _initializeWithRetry(AnalyticsSettings settings) async { + try { + if (kDebugMode) { + log( + 'Initializing Firebase Analytics with settings: isSendAllowed=${settings.isSendAllowed}', + path: 'analytics -> FirebaseAnalyticsApi -> _initialize', + ); + } + + // Setup queue persistence timer + _queuePersistenceTimer = Timer.periodic( + const Duration(minutes: 5), + (_) => _persistQueue(), + ); + + // Load any previously saved events + await _loadPersistedQueue(); + + // Skip unsupported platforms (Linux not supported by Firebase Analytics) + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.linux) { + if (kDebugMode) { + log( + 'Firebase Analytics not supported on Linux; marking as initialized=false and enabled=false', + path: 'analytics -> FirebaseAnalyticsApi -> _initialize', + ); + } + _isInitialized = false; + _isEnabled = false; + if (!_initCompleter.isCompleted) { + _initCompleter.complete(); + } + return; + } + + // Initialize Firebase + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + _instance = FirebaseAnalytics.instance; + + _isInitialized = true; + _isEnabled = settings.isSendAllowed; + + if (kDebugMode) { + log( + 'Firebase Analytics initialized: _isInitialized=$_isInitialized, _isEnabled=$_isEnabled', + path: 'analytics -> FirebaseAnalyticsApi -> _initialize', + ); + } + + if (_isInitialized && _isEnabled) { + await activate(); + } else { + await deactivate(); + } + + // Successfully initialized + if (!_initCompleter.isCompleted) { + _initCompleter.complete(); + } + } catch (e) { + _isInitialized = false; + + if (kDebugMode) { + log( + 'Error initializing Firebase Analytics: $e', + path: 'analytics -> FirebaseAnalyticsApi -> _initialize', + isError: true, + ); + } + + // Try to initialize again if we haven't exceeded max retries + if (_initRetryCount < _maxInitRetries) { + _initRetryCount++; + + if (kDebugMode) { + log( + 'Retrying Firebase analytics initialization (attempt $_initRetryCount of $_maxInitRetries)', + path: 'analytics -> FirebaseAnalyticsApi -> _initialize', + ); + } + + // Retry with exponential backoff + await Future.delayed(Duration(seconds: 2 * _initRetryCount)); + await _initializeWithRetry(settings); + } else { + // Maximum retries exceeded + if (!_initCompleter.isCompleted) { + _initCompleter.completeError(e); + } + } + } + } + + @override + Future retryInitialization(AnalyticsSettings settings) async { + if (!_isInitialized) { + _initRetryCount = 0; + return _initializeWithRetry(settings); + } + } + + @override + Future sendEvent(AnalyticsEventData event) async { + // If not initialized or disabled, enqueue for later + if (!_isInitialized || !_isEnabled) { + _eventQueue.add(event); + return; + } + final sanitizedParameters = event.parameters.map((key, value) { + if (value == null) return MapEntry(key, "null"); + if (value is Map || value is List) { + return MapEntry(key, jsonEncode(value)); + } + return MapEntry(key, value.toString()); + }); + + // Log the event in debug mode with formatted parameters for better readability + if (kDebugMode) { + final formattedParams = const JsonEncoder.withIndent( + ' ', + ).convert(sanitizedParameters); + log( + 'Firebase Analytics Event: ${event.name}; Parameters: $formattedParams', + path: 'analytics -> FirebaseAnalyticsApi -> sendEvent', + ); + } + + try { + await _instance.logEvent( + name: event.name, + parameters: sanitizedParameters, + ); + } catch (e, s) { + log( + e.toString(), + path: 'analytics -> FirebaseAnalyticsApi -> sendEvent', + trace: s, + isError: true, + ); + } + } + + @override + Future activate() async { + if (!_isInitialized) { + return; + } + + _isEnabled = true; + await _instance.setAnalyticsCollectionEnabled(true); + + // Process any queued events + if (_eventQueue.isNotEmpty) { + if (kDebugMode) { + log( + 'Processing ${_eventQueue.length} queued Firebase analytics events', + path: 'analytics -> FirebaseAnalyticsApi -> activate', + ); + } + + final queuedEvents = List.from(_eventQueue); + _eventQueue.clear(); + + int processedCount = 0; + for (final event in queuedEvents) { + await sendEvent(event); + processedCount++; + } + + if (kDebugMode && processedCount > 0) { + log( + 'Successfully processed $processedCount queued Firebase analytics events', + path: 'analytics -> FirebaseAnalyticsApi -> activate', + ); + } + } + } + + @override + Future deactivate() async { + if (!_isInitialized) { + return; + } + + if (kDebugMode) { + log( + 'Firebase analytics collection disabled', + path: 'analytics -> FirebaseAnalyticsApi -> deactivate', + ); + } + + _isEnabled = false; + await _instance.setAnalyticsCollectionEnabled(false); + } + + Future _persistQueue() async { + if (_eventQueue.isEmpty) { + if (kDebugMode) { + log( + 'No Firebase events to persist (queue empty)', + path: 'analytics -> FirebaseAnalyticsApi -> _persistQueue', + ); + } + return; + } + + try { + if (kDebugMode) { + log( + 'Persisting ${_eventQueue.length} queued Firebase analytics events', + path: 'analytics -> FirebaseAnalyticsApi -> _persistQueue', + ); + } + + final prefs = await SharedPreferences.getInstance(); + + // Convert events to a serializable format + final serializedEvents = _eventQueue.map((event) { + return {'name': event.name, 'parameters': event.parameters}; + }).toList(); + + // Serialize and store + final serialized = jsonEncode(serializedEvents); + await prefs.setString(_persistedQueueKey, serialized); + + if (kDebugMode) { + log( + 'Successfully persisted ${_eventQueue.length} Firebase events to SharedPreferences', + path: 'analytics -> FirebaseAnalyticsApi -> _persistQueue', + ); + } + } catch (e, s) { + if (kDebugMode) { + log( + 'Error persisting Firebase analytics queue: $e', + path: 'analytics -> FirebaseAnalyticsApi -> _persistQueue', + trace: s, + isError: true, + ); + } + } + } + + Future _loadPersistedQueue() async { + try { + if (kDebugMode) { + log( + 'Loading persisted Firebase analytics events from SharedPreferences', + path: 'analytics -> FirebaseAnalyticsApi -> _loadPersistedQueue', + ); + } + + final prefs = await SharedPreferences.getInstance(); + final serialized = prefs.getString(_persistedQueueKey); + + if (serialized == null || serialized.isEmpty) { + if (kDebugMode) { + log( + 'No persisted Firebase analytics events found', + path: 'analytics -> FirebaseAnalyticsApi -> _loadPersistedQueue', + ); + } + return; + } + + // Deserialize the data + final List decodedList = jsonDecode(serialized); + + // Create PersistedAnalyticsEventData instances + for (final eventMap in decodedList) { + _eventQueue.add( + PersistedAnalyticsEventData( + name: eventMap['name'], + parameters: Map.from(eventMap['parameters']), + ), + ); + } + + if (kDebugMode) { + log( + 'Loaded ${_eventQueue.length} persisted Firebase analytics events', + path: 'analytics -> FirebaseAnalyticsApi -> _loadPersistedQueue', + ); + } + + // Clear the persisted data after loading + await prefs.remove(_persistedQueueKey); + } catch (e, s) { + if (kDebugMode) { + log( + 'Error loading persisted Firebase analytics queue: $e', + path: 'analytics -> FirebaseAnalyticsApi -> _loadPersistedQueue', + trace: s, + isError: true, + ); + } + } + } + + @override + Future dispose() async { + if (_queuePersistenceTimer != null) { + _queuePersistenceTimer!.cancel(); + _queuePersistenceTimer = null; + + if (kDebugMode) { + log( + 'Cancelled Firebase queue persistence timer', + path: 'analytics -> FirebaseAnalyticsApi -> dispose', + ); + } + } + + // Persist any remaining events before disposing + await _persistQueue(); + } +} diff --git a/lib/bloc/analytics/matomo_analytics_api.dart b/lib/bloc/analytics/matomo_analytics_api.dart new file mode 100644 index 0000000000..1a2e1dacd8 --- /dev/null +++ b/lib/bloc/analytics/matomo_analytics_api.dart @@ -0,0 +1,865 @@ +import 'dart:convert'; +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:matomo_tracker/matomo_tracker.dart'; +import 'package:web_dex/model/settings/analytics_settings.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'analytics_api.dart'; +import 'analytics_repo.dart'; + +class MatomoAnalyticsApi implements AnalyticsApi { + late MatomoTracker _instance; + final Completer _initCompleter = Completer(); + + bool _isInitialized = false; + bool _isEnabled = false; + int _initRetryCount = 0; + static const int _maxInitRetries = 3; + + /// SYNC NOTE: + /// The event-to-category mapping and numeric value extraction keys below must + /// stay in sync with `lib/analytics/required_analytics_events.csv`. + /// Ideally these would not be hard-coded and should be generated from a + /// shared analytics metadata source. + + /// Explicit mapping of GA4 event names to Business Categories as defined in + /// `lib/analytics/required_analytics_events.csv`. + static const Map _eventCategoryMap = { + // User Engagement + 'app_open': 'User Engagement', + + // User Acquisition + 'onboarding_start': 'User Acquisition', + 'wallet_created': 'User Acquisition', + 'wallet_imported': 'User Acquisition', + + // Security + 'backup_complete': 'Security Adoption', + 'backup_skipped': 'Security Risk', + + // Portfolio + 'portfolio_viewed': 'Portfolio', + 'portfolio_growth_viewed': 'Portfolio', + 'portfolio_pnl_viewed': 'Portfolio', + + // Asset Mgmt + 'add_asset': 'Asset Mgmt', + 'view_asset': 'Asset Mgmt', + 'asset_enabled': 'Asset Mgmt', + 'asset_disabled': 'Asset Mgmt', + + // Transactions + 'send_initiated': 'Transactions', + 'send_success': 'Transactions', + 'send_failure': 'Transactions', + + // Trading (DEX) + 'swap_initiated': 'Trading (DEX)', + 'swap_success': 'Trading (DEX)', + 'swap_failure': 'Trading (DEX)', + + // Cross-Chain + 'bridge_initiated': 'Cross-Chain', + 'bridge_success': 'Cross-Chain', + 'bridge_failure': 'Cross-Chain', + + // NFT Wallet + 'nft_gallery_opened': 'NFT Wallet', + 'nft_transfer_initiated': 'NFT Wallet', + 'nft_transfer_success': 'NFT Wallet', + 'nft_transfer_failure': 'NFT Wallet', + + // Market Bot + 'marketbot_setup_start': 'Market Bot', + 'marketbot_setup_complete': 'Market Bot', + 'marketbot_trade_executed': 'Market Bot', + 'marketbot_error': 'Market Bot', + + // Rewards + 'reward_claim_initiated': 'Rewards', + 'reward_claim_success': 'Rewards', + 'reward_claim_failure': 'Rewards', + + // Ecosystem + 'dapp_connect': 'Ecosystem', + + // Preferences + 'settings_change': 'Preferences', + 'theme_selected': 'Preferences', + + // Stability + 'error_displayed': 'Stability', + + // Growth + 'app_share': 'Growth', + + // HD Wallet Ops + 'hd_address_generated': 'HD Wallet Ops', + + // UX & UI + 'wallet_list_half_viewport': 'UI Usability', + + // Data Sync + 'coins_data_updated': 'Data Sync', + + // Search + 'searchbar_input': 'Search', + + // Performance + 'page_interactive_delay': 'Performance', + }; + + /// Visit-scoped dimension identifiers (set once per session). + static const Map _visitDimensionIds = { + 'platform': 1, + 'app_version': 2, + 'referral_source': 3, + 'theme_name': 4, + 'update_source': 5, + }; + + /// Action-scoped dimension identifiers (sent with each event). + static const Map _actionDimensionIds = { + 'asset': 6, + 'secondary_asset': 7, + 'network': 8, + 'secondary_network': 9, + 'amount': 10, + 'fee': 11, + 'collection_name': 12, + 'token_id': 13, + 'pair': 14, + 'strategy_type': 15, + 'hd_type': 16, + 'failure_reason': 17, + 'screen_context': 18, + 'stage_skipped': 19, + 'method': 20, + 'import_type': 21, + 'setting_name': 22, + 'total_coins': 23, + 'total_value_usd': 24, + 'period': 25, + 'timeframe': 26, + 'growth_pct': 27, + 'realized_pnl': 28, + 'unrealized_pnl': 29, + 'profit_usd': 30, + 'backup_time': 31, + 'duration_ms': 32, + 'load_time_ms': 33, + 'time_to_half_ms': 34, + 'update_duration_ms': 35, + 'interactive_delay_ms': 36, + 'spinner_time_ms': 37, + 'nft_count': 38, + 'pairs_count': 39, + 'base_capital': 40, + 'account_index': 41, + 'address_index': 42, + 'wallet_size': 43, + 'coins_count': 44, + 'query_length': 45, + 'dapp_name': 46, + 'new_value': 47, + 'channel': 48, + 'scroll_delta': 49, + 'page_name': 50, + 'expected_reward_amount': 51, + }; + + static const Set _assetDimensionRequiredEvents = { + 'add_asset', + 'view_asset', + 'asset_enabled', + 'asset_disabled', + 'send_initiated', + 'send_success', + 'send_failure', + 'swap_initiated', + 'swap_success', + 'swap_failure', + 'bridge_initiated', + 'bridge_success', + 'bridge_failure', + 'reward_claim_initiated', + 'reward_claim_success', + 'reward_claim_failure', + 'hd_address_generated', + 'marketbot_trade_executed', + }; + + /// Queue to store events when analytics is disabled + final List _eventQueue = []; + + /// Tracks the currently active visit-scoped dimension values so we only send updates + /// when something changes (e.g., theme switch mid-session). + final Map _currentVisitDimensions = {}; + + @override + String get providerName => 'Matomo'; + + @override + bool get isInitialized => _isInitialized; + + @override + bool get isEnabled => _isEnabled; + + @override + Future initialize(AnalyticsSettings settings) async { + return _initializeWithRetry(settings); + } + + /// Initialize with retry mechanism + Future _initializeWithRetry(AnalyticsSettings settings) async { + try { + if (kDebugMode) { + log( + 'Initializing Matomo Analytics with settings: isSendAllowed=${settings.isSendAllowed}', + path: 'analytics -> MatomoAnalyticsApi -> _initialize', + ); + } + + // Initialize Matomo only if configuration is provided + + final bool hasConfig = matomoUrl.isNotEmpty && matomoSiteId.isNotEmpty; + if (!hasConfig) { + if (kDebugMode) { + log( + 'Matomo configuration missing (MATOMO_URL and/or MATOMO_SITE_ID). Disabling Matomo.', + path: 'analytics -> MatomoAnalyticsApi -> _initialize', + ); + } + _isInitialized = false; + _isEnabled = false; + if (!_initCompleter.isCompleted) { + _initCompleter.complete(); + } + return; + } + + await MatomoTracker.instance.initialize( + siteId: matomoSiteId, + url: matomoUrl, + dispatchSettings: const DispatchSettings.persistent(), + // Include backend API key header similarly to feedback feature + customHeaders: { + if (const String.fromEnvironment('FEEDBACK_API_KEY').isNotEmpty) + 'X-KW-KEY': const String.fromEnvironment('FEEDBACK_API_KEY'), + }, + ); + _instance = MatomoTracker.instance; + + _isInitialized = true; + // Disable analytics in CI or when analyticsDisabled flag is set + final bool shouldDisable = analyticsDisabled || isCiEnvironment; + _isEnabled = settings.isSendAllowed && !shouldDisable; + + if (kDebugMode) { + log( + 'Matomo Analytics initialized: _isInitialized=$_isInitialized, _isEnabled=$_isEnabled', + path: 'analytics -> MatomoAnalyticsApi -> _initialize', + ); + } + + if (_isInitialized && _isEnabled) { + await activate(); + } else { + await deactivate(); + } + + // Successfully initialized + if (!_initCompleter.isCompleted) { + _initCompleter.complete(); + } + } catch (e) { + _isInitialized = false; + + if (kDebugMode) { + log( + 'Error initializing Matomo Analytics: $e', + path: 'analytics -> MatomoAnalyticsApi -> _initialize', + isError: true, + ); + } + + // Try to initialize again if we haven't exceeded max retries + if (_initRetryCount < _maxInitRetries) { + _initRetryCount++; + + if (kDebugMode) { + log( + 'Retrying Matomo analytics initialization (attempt $_initRetryCount of $_maxInitRetries)', + path: 'analytics -> MatomoAnalyticsApi -> _initialize', + ); + } + + // Retry with exponential backoff + await Future.delayed(Duration(seconds: 2 * _initRetryCount)); + await _initializeWithRetry(settings); + } else { + // Maximum retries exceeded + if (!_initCompleter.isCompleted) { + _initCompleter.completeError(e); + } + } + } + } + + @override + Future retryInitialization(AnalyticsSettings settings) async { + if (!_isInitialized) { + _initRetryCount = 0; + return _initializeWithRetry(settings); + } + } + + @override + Future sendEvent(AnalyticsEventData event) async { + // If not initialized or disabled, enqueue for later + if (!_isInitialized || !_isEnabled) { + _eventQueue.add(event); + return; + } + + final normalizedParameters = _normalizeParameters( + event.parameters, + event.name, + ); + final sanitizedParameters = normalizedParameters.map((key, value) { + if (value == null) { + return MapEntry(key, 'null'); + } + if (value is Map || value is List) { + return MapEntry(key, jsonEncode(value)); + } + return MapEntry(key, value.toString()); + }); + + final visitDimensionUpdates = _extractVisitDimensionUpdates( + sanitizedParameters, + ); + if (visitDimensionUpdates.isNotEmpty) { + await _setVisitDimensions(visitDimensionUpdates); + } + + final actionDimensions = _extractActionDimensions(sanitizedParameters); + _ensureAssetDimension(event.name, actionDimensions, sanitizedParameters); + + final primaryEventLabel = + _derivePrimaryEventLabel(event, sanitizedParameters) ?? event.name; + + // Log the event in debug mode with formatted parameters for better readability + if (kDebugMode) { + final formattedParams = const JsonEncoder.withIndent( + ' ', + ).convert(sanitizedParameters); + log( + 'Matomo Analytics Event: ${event.name}; Parameters: $formattedParams', + path: 'analytics -> MatomoAnalyticsApi -> sendEvent', + ); + } + + try { + // Convert to Matomo event format + _instance.trackEvent( + eventInfo: EventInfo( + category: _extractCategory(event.name), + action: event.name, + name: primaryEventLabel, + value: _extractEventValue(normalizedParameters), + ), + dimensions: actionDimensions, + ); + + // Note: Custom dimensions should be set separately in Matomo + // You can extend this implementation to handle custom dimensions if needed + } catch (e, s) { + log( + e.toString(), + path: 'analytics -> MatomoAnalyticsApi -> sendEvent', + trace: s, + isError: true, + ); + } + } + + @override + Future activate() async { + if (!_isInitialized) { + return; + } + + _isEnabled = true; + _currentVisitDimensions.clear(); + // Matomo doesn't have a direct enable/disable method like Firebase + // so we handle this by simply processing queued events + + // Process any queued events + if (_eventQueue.isNotEmpty) { + if (kDebugMode) { + log( + 'Processing ${_eventQueue.length} queued Matomo analytics events', + path: 'analytics -> MatomoAnalyticsApi -> activate', + ); + } + + final queuedEvents = List.from(_eventQueue); + _eventQueue.clear(); + + int processedCount = 0; + for (final event in queuedEvents) { + await sendEvent(event); + processedCount++; + } + + if (kDebugMode && processedCount > 0) { + log( + 'Successfully processed $processedCount queued Matomo analytics events', + path: 'analytics -> MatomoAnalyticsApi -> activate', + ); + } + } + } + + @override + Future deactivate() async { + if (!_isInitialized) { + return; + } + + if (kDebugMode) { + log( + 'Matomo analytics collection disabled', + path: 'analytics -> MatomoAnalyticsApi -> deactivate', + ); + } + + _isEnabled = false; + // Matomo doesn't have a direct disable method + // Events will be queued instead of sent when disabled + } + + Map _normalizeParameters( + Map parameters, + String eventName, + ) { + final original = {}; + for (final entry in parameters.entries) { + final value = entry.value; + if (value != null) { + original[entry.key] = value; + } + } + + dynamic sanitizeValue(dynamic value) { + if (value == null) return null; + if (value is String) { + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + return value; + } + + final normalized = {}; + + final dynamic asset = sanitizeValue( + original.remove('asset') ?? + original.remove('asset_symbol') ?? + original.remove('from_asset'), + ); + if (asset != null) { + normalized['asset'] = asset; + } + + final dynamic secondaryAsset = sanitizeValue( + original.remove('secondary_asset') ?? original.remove('to_asset'), + ); + if (secondaryAsset != null) { + normalized['secondary_asset'] = secondaryAsset; + } + + dynamic network = sanitizeValue( + original.remove('network') ?? + original.remove('asset_network') ?? + original.remove('from_chain'), + ); + dynamic secondaryNetwork = sanitizeValue( + original.remove('secondary_network') ?? original.remove('to_chain'), + ); + final dynamic networksRaw = original.remove('networks'); + if ((network == null || secondaryNetwork == null) && networksRaw != null) { + final parsed = _splitNetworkPair(networksRaw.toString()); + network ??= parsed.primary; + secondaryNetwork ??= parsed.secondary; + } + if (network != null) { + normalized['network'] = network; + } + if (secondaryNetwork != null) { + normalized['secondary_network'] = secondaryNetwork; + } + + final dynamic amount = + original.remove('amount') ?? + original.remove('trade_size') ?? + original.remove('reward_amount'); + if (amount != null) { + normalized['amount'] = amount; + } + + final dynamic fee = original.remove('fee'); + if (fee != null) { + normalized['fee'] = fee; + } + + final dynamic collectionName = original.remove('collection_name'); + if (collectionName != null) { + normalized['collection_name'] = collectionName; + } + + final dynamic tokenId = original.remove('token_id'); + if (tokenId != null) { + normalized['token_id'] = tokenId; + } + + final dynamic pair = original.remove('pair'); + if (pair != null) { + normalized['pair'] = pair; + } + + final dynamic strategyType = original.remove('strategy_type'); + if (strategyType != null) { + normalized['strategy_type'] = strategyType; + } + + final dynamic hdType = + original.remove('hd_type') ?? original.remove('wallet_type'); + if (hdType != null) { + normalized['hd_type'] = hdType; + } + + final dynamic failureReasonExplicit = original.remove('failure_reason'); + final dynamic failReason = original.remove('fail_reason'); + final dynamic failError = original.remove('fail_error'); + final dynamic failStage = original.remove('fail_stage'); + final dynamic errorCode = original.remove('error_code'); + if (failureReasonExplicit != null) { + normalized['failure_reason'] = failureReasonExplicit; + } else { + final parts = []; + + void addPart(String label, dynamic value) { + final trimmed = sanitizeValue(value); + if (trimmed != null) { + parts.add('$label:$trimmed'); + } + } + + addPart('stage', failStage); + final dynamic primaryReason = failReason ?? failError; + addPart('reason', primaryReason); + if (errorCode != null && errorCode != primaryReason) { + addPart('code', errorCode); + } + + if (parts.isNotEmpty) { + normalized['failure_reason'] = parts.join('|'); + } + } + + for (final key in [ + 'screen_context', + 'stage_skipped', + 'method', + 'import_type', + ]) { + final value = original.remove(key); + if (value != null) { + normalized[key] = value; + } + } + + final settingName = original.remove('setting_name'); + if (settingName != null) { + normalized['setting_name'] = settingName; + } + + final coinsTotalsKeys = [ + 'total_coins', + 'total_value_usd', + 'period', + 'timeframe', + 'growth_pct', + 'realized_pnl', + 'unrealized_pnl', + 'profit_usd', + 'backup_time', + 'duration_ms', + 'load_time_ms', + 'time_to_half_ms', + 'update_duration_ms', + 'interactive_delay_ms', + 'spinner_time_ms', + 'nft_count', + 'pairs_count', + 'base_capital', + 'account_index', + 'address_index', + 'wallet_size', + 'coins_count', + 'query_length', + 'dapp_name', + 'new_value', + 'channel', + 'scroll_delta', + 'page_name', + 'expected_reward_amount', + ]; + for (final key in coinsTotalsKeys) { + final value = original.remove(key); + if (value != null) { + normalized[key] = value; + } + } + + // Visit dimensions should also be part of the normalized map so they can be + // routed to Matomo via trackDimensions(). Leaving them here means we can + // reuse the same sanitized map for Firebase and app logging. + for (final key in _visitDimensionIds.keys) { + final value = original.remove(key); + if (value != null) { + normalized[key] = value; + } + } + + // Any remaining keys were already using consolidated names or are + // provider-specific metadata – keep them to avoid data loss. + normalized.addAll(original); + + return normalized; + } + + Map _extractVisitDimensionUpdates( + Map parameters, + ) { + final updates = {}; + for (final entry in _visitDimensionIds.entries) { + final value = parameters[entry.key]; + if (value == null) continue; + final dimensionKey = 'dimension${entry.value}'; + if (_currentVisitDimensions[dimensionKey] != value) { + updates[dimensionKey] = value; + } + } + return updates; + } + + Future _setVisitDimensions(Map updates) async { + if (updates.isEmpty) return; + try { + _instance.trackDimensions(dimensions: updates); + _currentVisitDimensions.addAll(updates); + } catch (e, s) { + if (kDebugMode) { + log( + 'Failed to update Matomo visit dimensions: $e', + path: 'analytics -> MatomoAnalyticsApi -> _setVisitDimensions', + trace: s, + isError: true, + ); + } + } + } + + Map _extractActionDimensions(Map parameters) { + final dimensions = {}; + for (final entry in _actionDimensionIds.entries) { + final value = parameters[entry.key]; + if (value == null) continue; + dimensions['dimension${entry.value}'] = value; + } + return dimensions; + } + + void _ensureAssetDimension( + String eventName, + Map actionDimensions, + Map parameters, + ) { + if (!_assetDimensionRequiredEvents.contains(eventName)) { + return; + } + + final assetDimension = actionDimensions['dimension6']; + if (assetDimension != null && assetDimension.trim().isNotEmpty) { + return; + } + + final fallback = parameters['asset']; + if (fallback != null && fallback.trim().isNotEmpty) { + actionDimensions['dimension6'] = fallback; + return; + } + + if (kDebugMode) { + log( + 'Matomo asset dimension missing for $eventName. parameters=$parameters', + path: 'analytics -> MatomoAnalyticsApi -> _ensureAssetDimension', + isError: true, + ); + } + } + + ({String? primary, String? secondary}) _splitNetworkPair(String raw) { + const separators = [',', '->', '|', '/']; + for (final separator in separators) { + if (raw.contains(separator)) { + final parts = raw + .split(separator) + .map((part) => part.trim()) + .where((part) => part.isNotEmpty) + .toList(); + if (parts.isEmpty) return (primary: null, secondary: null); + if (parts.length == 1) { + return (primary: parts[0], secondary: null); + } + return (primary: parts[0], secondary: parts[1]); + } + } + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + return (primary: null, secondary: null); + } + return (primary: trimmed, secondary: null); + } + + /// Extract category from event name (used for Matomo event categorization) + String _extractCategory(String eventName) { + // 1) Exact mapping from CSV + final mapped = _eventCategoryMap[eventName]; + if (mapped != null) return mapped; + + // 2) Fallback by prefix → Business Category (keep in sync with CSV semantics) + if (eventName.startsWith('onboarding_')) return 'User Acquisition'; + if (eventName.startsWith('wallet_')) return 'User Acquisition'; + if (eventName.startsWith('app_')) return 'User Engagement'; + if (eventName.startsWith('portfolio_')) return 'Portfolio'; + if (eventName.startsWith('asset_')) return 'Asset Mgmt'; + if (eventName.startsWith('send_')) return 'Transactions'; + if (eventName.startsWith('swap_')) return 'Trading (DEX)'; + if (eventName.startsWith('bridge_')) return 'Cross-Chain'; + if (eventName.startsWith('nft_')) return 'NFT Wallet'; + if (eventName.startsWith('marketbot_')) return 'Market Bot'; + if (eventName.startsWith('reward_')) return 'Rewards'; + if (eventName.startsWith('dapp_')) return 'Ecosystem'; + if (eventName.startsWith('settings_')) return 'Preferences'; + if (eventName.startsWith('error_')) return 'Stability'; + if (eventName.startsWith('hd_')) return 'HD Wallet Ops'; + if (eventName.startsWith('scroll_')) return 'UX Interaction'; + if (eventName.startsWith('searchbar_')) return 'Search'; + if (eventName.startsWith('theme_')) return 'Preferences'; + if (eventName.startsWith('coins_')) return 'Data Sync'; + if (eventName.startsWith('page_')) return 'Performance'; + + return 'General'; + } + + /// Extract numeric value from parameters for Matomo event value + double? _extractEventValue(Map parameters) { + // Look for common numeric parameters that could serve as event value + final potentialValueKeys = [ + 'amount', + 'value', + 'count', + 'duration_ms', + 'profit_usd', + 'reward_amount', + 'base_capital', + 'trade_size', + // From required_analytics_events.csv (keep in sync) + 'backup_time', + 'total_coins', + 'total_value_usd', + 'growth_pct', + 'realized_pnl', + 'unrealized_pnl', + 'fee', + 'load_time_ms', + 'nft_count', + 'pairs_count', + 'expected_reward_amount', + 'account_index', + 'address_index', + 'scroll_delta', + 'time_to_half_ms', + 'wallet_size', + 'coins_count', + 'update_duration_ms', + 'query_length', + 'interactive_delay_ms', + 'spinner_time_ms', + ]; + + for (final key in potentialValueKeys) { + if (parameters.containsKey(key)) { + final value = parameters[key]; + if (value is num) { + return value.toDouble(); + } + if (value is String) { + final parsed = double.tryParse(value); + if (parsed != null) return parsed; + } + } + } + return null; + } + + String? _derivePrimaryEventLabel( + AnalyticsEventData event, + Map sanitizedParameters, + ) { + final primary = event.primaryParameter; + if (primary == null) { + return _extractPrimaryEventLabel(sanitizedParameters); + } + final sanitizedValue = sanitizedParameters[primary.key]; + if (sanitizedValue == null) { + return _extractPrimaryEventLabel(sanitizedParameters); + } + final stringValue = sanitizedValue.toString().trim(); + if (stringValue.isEmpty || stringValue.toLowerCase() == 'null') { + return _extractPrimaryEventLabel(sanitizedParameters); + } + return '${primary.key}: $stringValue'; + } + + /// Extract the most relevant parameter/value pair to describe the event. + /// Returns the first non-empty parameter in insertion order, formatted as + /// "key: value". + String? _extractPrimaryEventLabel(Map parameters) { + for (final entry in parameters.entries) { + final dynamic value = entry.value; + if (value == null) continue; + final String stringValue = value.toString().trim(); + if (stringValue.isEmpty || stringValue.toLowerCase() == 'null') { + continue; + } + return '${entry.key}: $stringValue'; + } + return null; + } + + @override + Future dispose() async { + if (kDebugMode) { + log( + 'MatomoAnalyticsApi disposed', + path: 'analytics -> MatomoAnalyticsApi -> dispose', + ); + } + } +} diff --git a/lib/bloc/app_bloc_root.dart b/lib/bloc/app_bloc_root.dart index 6c34226232..b06509b3eb 100644 --- a/lib/bloc/app_bloc_root.dart +++ b/lib/bloc/app_bloc_root.dart @@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_ui/komodo_ui.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -48,9 +47,10 @@ import 'package:web_dex/bloc/system_health/system_clock_repository.dart'; import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; import 'package:web_dex/bloc/taker_form/taker_bloc.dart'; import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; -import 'package:web_dex/bloc/trading_status/trading_status_repository.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_service.dart'; import 'package:web_dex/bloc/transaction_history/transaction_history_bloc.dart'; import 'package:web_dex/bloc/transaction_history/transaction_history_repo.dart'; +import 'package:web_dex/bloc/version_info/version_info_bloc.dart'; import 'package:web_dex/blocs/kmd_rewards_bloc.dart'; import 'package:web_dex/blocs/maker_form_bloc.dart'; import 'package:web_dex/blocs/orderbook_bloc.dart'; @@ -66,6 +66,7 @@ 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/shared/utils/debug_utils.dart'; +import 'package:web_dex/shared/utils/ipfs_gateway_manager.dart'; import 'package:web_dex/shared/utils/utils.dart'; class AppBlocRoot extends StatelessWidget { @@ -86,8 +87,9 @@ class AppBlocRoot extends StatelessWidget { ) async { final sharedPrefs = await SharedPreferences.getInstance(); - final storedLastPerformanceMode = - sharedPrefs.getString('last_performance_mode'); + final storedLastPerformanceMode = sharedPrefs.getString( + 'last_performance_mode', + ); if (storedLastPerformanceMode != performanceMode?.name) { profitLossRepo.clearCache().ignore(); @@ -119,13 +121,12 @@ class AppBlocRoot extends StatelessWidget { final transactionsRepo = performanceMode != null ? MockTransactionHistoryRepo( performanceMode: performanceMode, - demoDataGenerator: DemoDataCache.withDefaults(), + demoDataGenerator: DemoDataCache.withDefaults(komodoDefiSdk), ) : SdkTransactionHistoryRepository(sdk: komodoDefiSdk); final profitLossRepo = ProfitLossRepository.withDefaults( transactionHistoryRepo: transactionsRepo, - cexRepository: binanceRepository, // Returns real data if performanceMode is null. Consider changing the // other repositories to use this pattern. demoMode: performanceMode, @@ -134,7 +135,6 @@ class AppBlocRoot extends StatelessWidget { final portfolioGrowthRepo = PortfolioGrowthRepository.withDefaults( transactionHistoryRepo: transactionsRepo, - cexRepository: binanceRepository, demoMode: performanceMode, coinsRepository: coinsRepository, sdk: komodoDefiSdk, @@ -152,11 +152,14 @@ class AppBlocRoot extends StatelessWidget { return MultiRepositoryProvider( providers: [ + // Keep ipfs gateway manager near root to keep in-memory cache of failing + // URLS to avoid repeated requests to the same failing URLs. RepositoryProvider( - create: (_) => NftsRepo( - api: mm2Api.nft, - coinsRepo: coinsRepository, - ), + create: (_) => IpfsGatewayManager(), + dispose: (manager) => manager.dispose(), + ), + RepositoryProvider( + create: (_) => NftsRepo(api: mm2Api.nft, coinsRepo: coinsRepository), ), RepositoryProvider(create: (_) => tradingEntitiesBloc), RepositoryProvider(create: (_) => dexRepository), @@ -168,12 +171,11 @@ class AppBlocRoot extends StatelessWidget { dexRepository: dexRepository, ), ), - RepositoryProvider(create: (_) => OrderbookBloc(api: mm2Api)), + RepositoryProvider(create: (_) => OrderbookBloc(sdk: komodoDefiSdk)), RepositoryProvider(create: (_) => myOrdersService), RepositoryProvider( create: (_) => KmdRewardsBloc(coinsRepository, mm2Api), ), - RepositoryProvider(create: (_) => TradingStatusRepository()), ], child: MultiBlocProvider( providers: [ @@ -181,13 +183,12 @@ class AppBlocRoot extends StatelessWidget { create: (context) => CoinsBloc( komodoDefiSdk, coinsRepository, + context.read(), )..add(CoinsStarted()), ), BlocProvider( - create: (context) => PriceChartBloc( - binanceRepository, - komodoDefiSdk, - )..add( + create: (context) => PriceChartBloc(komodoDefiSdk) + ..add( const PriceChartStarted( symbols: ['BTC'], period: Duration(days: 30), @@ -202,10 +203,7 @@ class AppBlocRoot extends StatelessWidget { ), ), BlocProvider( - create: (context) => ProfitLossBloc( - profitLossRepo, - komodoDefiSdk, - ), + create: (context) => ProfitLossBloc(profitLossRepo, komodoDefiSdk), ), BlocProvider( create: (BuildContext ctx) => PortfolioGrowthBloc( @@ -214,9 +212,8 @@ class AppBlocRoot extends StatelessWidget { ), ), BlocProvider( - create: (BuildContext ctx) => TransactionHistoryBloc( - sdk: komodoDefiSdk, - ), + create: (BuildContext ctx) => + TransactionHistoryBloc(sdk: komodoDefiSdk), ), BlocProvider( create: (context) => @@ -235,6 +232,7 @@ class AppBlocRoot extends StatelessWidget { kdfSdk: komodoDefiSdk, dexRepository: dexRepository, coinsRepository: coinsRepository, + analyticsBloc: BlocProvider.of(context), ), ), BlocProvider( @@ -252,10 +250,8 @@ class AppBlocRoot extends StatelessWidget { ), BlocProvider( lazy: false, - create: (context) => NftMainBloc( - repo: context.read(), - sdk: komodoDefiSdk, - ), + create: (context) => + NftMainBloc(repo: context.read(), sdk: komodoDefiSdk), ), if (isBitrefillIntegrationEnabled) BlocProvider( @@ -264,10 +260,7 @@ class AppBlocRoot extends StatelessWidget { ), BlocProvider( create: (context) => MarketMakerBotBloc( - MarketMakerBotRepository( - mm2Api, - SettingsRepository(), - ), + MarketMakerBotRepository(mm2Api, SettingsRepository()), MarketMakerBotOrderListRepository( myOrdersService, SettingsRepository(), @@ -277,13 +270,14 @@ class AppBlocRoot extends StatelessWidget { ), BlocProvider( lazy: false, - create: (context) => TradingStatusBloc( - context.read(), - )..add(TradingStatusCheckRequested()), + create: (context) => + TradingStatusBloc(context.read()) + ..add(TradingStatusWatchStarted()), ), BlocProvider( - create: (_) => SystemHealthBloc(SystemClockRepository(), mm2Api) - ..add(SystemHealthPeriodicCheckStarted()), + create: (_) => + SystemHealthBloc(SystemClockRepository(), mm2Api) + ..add(SystemHealthPeriodicCheckStarted()), ), BlocProvider( create: (context) => CoinsManagerBloc( @@ -298,6 +292,13 @@ class AppBlocRoot extends StatelessWidget { create: (context) => FaucetBloc(kdfSdk: context.read()), ), + BlocProvider( + lazy: false, + create: (context) => + VersionInfoBloc(mm2Api: mm2Api, komodoDefiSdk: komodoDefiSdk) + ..add(const LoadVersionInfo()) + ..add(const StartPeriodicPolling()), + ), BlocProvider( lazy: false, create: (context) => @@ -347,8 +348,9 @@ class _MyAppViewState extends State<_MyAppView> { Widget build(BuildContext context) { return MaterialApp.router( onGenerateTitle: (_) => appTitle, - themeMode: context - .select((SettingsBloc settingsBloc) => settingsBloc.state.themeMode), + themeMode: context.select( + (SettingsBloc settingsBloc) => settingsBloc.state.themeMode, + ), darkTheme: theme.global.dark, theme: theme.global.light, routerDelegate: _routerDelegate, @@ -388,15 +390,16 @@ class _MyAppViewState extends State<_MyAppView> { // Remove the loading indicator. loadingElement.remove(); - final delay = - DateTime.now().difference(_pageLoadStartTime).inMilliseconds; + final delay = DateTime.now() + .difference(_pageLoadStartTime) + .inMilliseconds; context.read().logEvent( - PageInteractiveDelayEventData( - pageName: 'app_root', - interactiveDelayMs: delay, - spinnerTimeMs: 200, - ), - ); + PageInteractiveDelayEventData( + pageName: 'app_root', + interactiveDelayMs: delay, + spinnerTimeMs: 200, + ), + ); } } @@ -430,20 +433,22 @@ class _MyAppViewState extends State<_MyAppView> { // } // ignore: use_build_context_synchronously - await AssetIcon.precacheAssetIcon(context, assetId).onError( - (_, __) => debugPrint('Error precaching coin icon $assetId')); + await AssetIcon.precacheAssetIcon( + context, + assetId, + ).onError((_, __) => debugPrint('Error precaching coin icon $assetId')); } _currentPrecacheOperation!.complete(); if (!mounted) return; context.read().logEvent( - CoinsDataUpdatedEventData( - updateSource: 'remote', - updateDurationMs: stopwatch.elapsedMilliseconds, - coinsCount: availableAssetIds.length, - ), - ); + CoinsDataUpdatedEventData( + updateSource: 'remote', + updateDurationMs: stopwatch.elapsedMilliseconds, + coinsCount: availableAssetIds.length, + ), + ); } catch (e) { log('Error precaching coin icons: $e'); _currentPrecacheOperation!.completeError(e); diff --git a/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart b/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart index 231e16316e..48f942c1cc 100644 --- a/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart +++ b/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart @@ -9,6 +9,7 @@ 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'; import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_repository.dart'; import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart'; +import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/model/coin.dart'; part 'asset_overview_event.dart'; @@ -48,11 +49,8 @@ class AssetOverviewBloc extends Bloc { event.walletId, ); - final totalInvestment = - await _investmentRepository.calculateTotalInvestment( - event.walletId, - [event.coin], - ); + final totalInvestment = await _investmentRepository + .calculateTotalInvestment(event.walletId, [event.coin]); final profitAmount = profitLosses.lastOrNull?.profitLoss ?? 0.0; // The percent which the user has gained or lost on their investment @@ -96,9 +94,21 @@ class AssetOverviewBloc extends Bloc { return; } - await _sdk.waitForEnabledCoinsToPassThreshold(event.coins); + final supportedCoins = await event.coins.filterSupportedCoins(); + if (supportedCoins.isEmpty) { + _log.warning('No supported coins to load portfolio overview for'); + return; + } + + await _sdk.waitForEnabledCoinsToPassThreshold(supportedCoins); - final profitLossesFutures = event.coins.map((coin) async { + final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); + if (activeCoins.isEmpty) { + _log.warning('No active coins to load portfolio overview for'); + return; + } + + final profitLossesFutures = activeCoins.map((coin) async { // Catch errors that occur for single coins and exclude them from the // total so that transaction fetching errors for a single coin do not // affect the total investment calculation. @@ -108,18 +118,16 @@ class AssetOverviewBloc extends Bloc { 'USDT', event.walletId, ); - } catch (e) { + } catch (e, s) { + _log.shout('Failed to fetch profit/loss for ${coin.id.id}', e, s); return Future.value([]); } }); final profitLosses = await Future.wait(profitLossesFutures); - final totalInvestment = - await _investmentRepository.calculateTotalInvestment( - event.walletId, - event.coins, - ); + final totalInvestment = await _investmentRepository + .calculateTotalInvestment(event.walletId, activeCoins); final profitAmount = profitLosses.fold(0.0, (sum, item) { return sum + (item.lastOrNull?.profitLoss ?? 0.0); @@ -128,12 +136,14 @@ class AssetOverviewBloc extends Bloc { final double portfolioInvestmentReturnPercentage = _calculateInvestmentReturnPercentage(profitAmount, totalInvestment); // Total profit / total purchase amount - final assetPortionPercentages = - _calculateAssetPortionPercentages(profitLosses, profitAmount); + final assetPortionPercentages = _calculateAssetPortionPercentages( + profitLosses, + profitAmount, + ); emit( PortfolioAssetsOverviewLoadSuccess( - selectedAssetIds: event.coins.map((coin) => coin.id.id).toList(), + selectedAssetIds: activeCoins.map((coin) => coin.id.id).toList(), assetPortionPercentages: assetPortionPercentages, totalInvestment: totalInvestment, totalValue: FiatValue.usd(profitAmount), @@ -164,20 +174,12 @@ class AssetOverviewBloc extends Bloc { AssetOverviewSubscriptionRequested event, Emitter emit, ) async { - add( - AssetOverviewLoadRequested( - coin: event.coin, - walletId: event.walletId, - ), - ); + add(AssetOverviewLoadRequested(coin: event.coin, walletId: event.walletId)); _updateTimer?.cancel(); _updateTimer = Timer.periodic(event.updateFrequency, (_) { add( - AssetOverviewLoadRequested( - coin: event.coin, - walletId: event.walletId, - ), + AssetOverviewLoadRequested(coin: event.coin, walletId: event.walletId), ); }); } diff --git a/lib/bloc/assets_overview/investment_repository.dart b/lib/bloc/assets_overview/investment_repository.dart index 94ddd014a7..dacfefc48c 100644 --- a/lib/bloc/assets_overview/investment_repository.dart +++ b/lib/bloc/assets_overview/investment_repository.dart @@ -1,14 +1,14 @@ +import 'package:logging/logging.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/profit_loss_repository.dart'; import 'package:web_dex/model/coin.dart'; -import 'package:web_dex/shared/utils/utils.dart' as logger; class InvestmentRepository { - InvestmentRepository({ - required ProfitLossRepository profitLossRepository, - }) : _profitLossRepository = profitLossRepository; + InvestmentRepository({required ProfitLossRepository profitLossRepository}) + : _profitLossRepository = profitLossRepository; final ProfitLossRepository _profitLossRepository; + final Logger _log = Logger('InvestmentRepository'); // TODO: Create a balance repository to fetch the current balance for a coin // and also calculate its fiat value @@ -46,8 +46,8 @@ class InvestmentRepository { ); return totalPurchased; - } catch (e) { - logger.log('Failed to calculate total investment: $e', isError: true); + } catch (e, s) { + _log.shout('Failed to calculate total investment', e, s); return FiatValue.usd(0); } }); diff --git a/lib/bloc/auth_bloc/auth_bloc.dart b/lib/bloc/auth_bloc/auth_bloc.dart index da1eda929a..fb7f1b857a 100644 --- a/lib/bloc/auth_bloc/auth_bloc.dart +++ b/lib/bloc/auth_bloc/auth_bloc.dart @@ -9,6 +9,7 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/settings/settings_repository.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_service.dart'; import 'package:web_dex/blocs/wallets_repository.dart'; import 'package:web_dex/model/authorize_mode.dart'; import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; @@ -23,8 +24,12 @@ part 'trezor_auth_mixin.dart'; class AuthBloc extends Bloc with TrezorAuthMixin { /// Handles [AuthBlocEvent]s and emits [AuthBlocState]s. /// [_kdfSdk] is an instance of [KomodoDefiSdk] used for authentication. - AuthBloc(this._kdfSdk, this._walletsRepository, this._settingsRepository) - : super(AuthBlocState.initial()) { + AuthBloc( + this._kdfSdk, + this._walletsRepository, + this._settingsRepository, + this._tradingStatusService, + ) : super(AuthBlocState.initial()) { on(_onAuthChanged); on(_onClearState); on(_onLogout); @@ -41,12 +46,34 @@ class AuthBloc extends Bloc with TrezorAuthMixin { final KomodoDefiSdk _kdfSdk; final WalletsRepository _walletsRepository; final SettingsRepository _settingsRepository; + final TradingStatusService _tradingStatusService; StreamSubscription? _authChangesSubscription; + @override final _log = Logger('AuthBloc'); @override KomodoDefiSdk get _sdk => _kdfSdk; + /// Filters out geo-blocked assets from a list of coin IDs. + /// This ensures that blocked assets are not added to wallet metadata during + /// registration or restoration. + /// + /// TODO: UX Improvement - For faster wallet creation/restoration, consider + /// adding all default coins to metadata initially, then removing blocked ones + /// when bouncer status is confirmed. This would require: + /// 1. Reactive metadata updates when trading status changes + /// 2. Coordinated cleanup across wallet metadata and activated coins + /// 3. Handling edge cases where user manually re-adds a blocked coin + /// See TradingStatusService._currentStatus for related startup optimizations. + @override + List _filterBlockedAssets(List coinIds) { + return coinIds.where((coinId) { + final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); + if (assets.isEmpty) return true; // Keep unknown assets for now + return !_tradingStatusService.isAssetBlocked(assets.single.id); + }).toList(); + } + @override Future close() async { await _authChangesSubscription?.cancel(); @@ -64,9 +91,16 @@ class AuthBloc extends Bloc with TrezorAuthMixin { ) async { _log.info('Logging out from a wallet'); emit(AuthBlocState.loading()); - await _kdfSdk.auth.signOut(); - await _authChangesSubscription?.cancel(); - emit(AuthBlocState.initial()); + try { + await _kdfSdk.auth.signOut(); + } catch (e, s) { + // Do not crash the app on sign-out errors (e.g., KDF not stopping in time). + // Log and continue to clear local auth state so UI can recover. + _log.shout('Error during sign out, proceeding to reset state', e, s); + } finally { + await _authChangesSubscription?.cancel(); + emit(AuthBlocState.initial()); + } } Future _onLogIn( @@ -84,11 +118,10 @@ class AuthBloc extends Bloc with TrezorAuthMixin { ); } - _log.info('login from a wallet'); emit(AuthBlocState.loading()); + _log.info('Logging in to an existing wallet.'); final weakPasswordsAllowed = await _areWeakPasswordsAllowed(); - await _kdfSdk.auth.signIn( walletName: event.wallet.name, password: event.password, @@ -104,7 +137,7 @@ class AuthBloc extends Bloc with TrezorAuthMixin { return emit(AuthBlocState.error(AuthException.notSignedIn())); } - _log.info('logged in from a wallet'); + _log.info('Successfully logged in to wallet'); emit(AuthBlocState.loggedIn(currentUser)); _listenToAuthStateChanges(); } catch (e, s) { @@ -152,13 +185,17 @@ class AuthBloc extends Bloc with TrezorAuthMixin { try { emit(AuthBlocState.loading()); if (await _didSignInExistingWallet(event.wallet, event.password)) { + add( + AuthSignInRequested(wallet: event.wallet, password: event.password), + ); + _log.warning( + 'Wallet ${event.wallet.name} already exists, attempting sign-in', + ); return; } - _log.info('register from a wallet'); - + _log.info('Registering a new wallet'); final weakPasswordsAllowed = await _areWeakPasswordsAllowed(); - await _kdfSdk.auth.register( password: event.password, walletName: event.wallet.name, @@ -170,10 +207,14 @@ class AuthBloc extends Bloc with TrezorAuthMixin { ), ); - _log.info('registered from a wallet'); + _log.info( + 'Registered a new wallet, setting up metadata and logging in...', + ); await _kdfSdk.setWalletType(event.wallet.config.type); await _kdfSdk.confirmSeedBackup(hasBackup: false); - await _kdfSdk.addActivatedCoins(enabledByDefaultCoins); + // Filter out geo-blocked assets from default coins before adding to wallet + final allowedDefaultCoins = _filterBlockedAssets(enabledByDefaultCoins); + await _kdfSdk.addActivatedCoins(allowedDefaultCoins); final currentUser = await _kdfSdk.auth.currentUser; if (currentUser == null) { @@ -198,15 +239,19 @@ class AuthBloc extends Bloc with TrezorAuthMixin { Emitter emit, ) async { try { - emit(AuthBlocState.loading()); if (await _didSignInExistingWallet(event.wallet, event.password)) { + add( + AuthSignInRequested(wallet: event.wallet, password: event.password), + ); + _log.warning( + 'Wallet ${event.wallet.name} already exists, attempting sign-in', + ); return; } - _log.info('restore from a wallet'); - + emit(AuthBlocState.loading()); + _log.info('Restoring wallet from a seed'); final weakPasswordsAllowed = await _areWeakPasswordsAllowed(); - await _kdfSdk.auth.register( password: event.password, walletName: event.wallet.name, @@ -219,27 +264,45 @@ class AuthBloc extends Bloc with TrezorAuthMixin { ), ); - _log.info('restored from a wallet'); + _log.info( + 'Successfully restored wallet from a seed. ' + 'Setting up wallet metadata and logging in...', + ); await _kdfSdk.setWalletType(event.wallet.config.type); await _kdfSdk.confirmSeedBackup(hasBackup: event.wallet.config.hasBackup); - await _kdfSdk.addActivatedCoins(enabledByDefaultCoins); - - final currentUser = await _kdfSdk.auth.currentUser; - if (currentUser == null) { - throw Exception('Registration failed: user is not signed in'); + // Filter out geo-blocked assets from default coins before adding to wallet + final allowedDefaultCoins = _filterBlockedAssets(enabledByDefaultCoins); + await _kdfSdk.addActivatedCoins(allowedDefaultCoins); + if (event.wallet.config.activatedCoins.isNotEmpty) { + // Seed import files and legacy wallets may contain removed or unsupported + // coins, so we filter them out before adding them to the wallet metadata. + final availableWalletCoins = _filterOutUnsupportedCoins( + event.wallet.config.activatedCoins, + ); + // Also filter out geo-blocked assets from restored wallet coins + final allowedWalletCoins = _filterBlockedAssets(availableWalletCoins); + await _kdfSdk.addActivatedCoins(allowedWalletCoins); } - emit(AuthBlocState.loggedIn(currentUser)); // Delete legacy wallet on successful restoration & login to avoid // duplicates in the wallet list if (event.wallet.isLegacyWallet) { - await _kdfSdk.addActivatedCoins(event.wallet.config.activatedCoins); + _log.info( + 'Migration successful. ' + 'Deleting legacy wallet ${event.wallet.name}', + ); await _walletsRepository.deleteWallet( event.wallet, password: event.password, ); } + final currentUser = await _kdfSdk.auth.currentUser; + if (currentUser == null) { + throw Exception('Restoration from seed failed: user is not signed in'); + } + + emit(AuthBlocState.loggedIn(currentUser)); _listenToAuthStateChanges(); } catch (e, s) { final errorMsg = 'Failed to restore existing wallet ${event.wallet.name}'; @@ -259,8 +322,6 @@ class AuthBloc extends Bloc with TrezorAuthMixin { (KdfUser user) => user.walletId.name == wallet.name, ); if (walletExists) { - add(AuthSignInRequested(wallet: wallet, password: password)); - _log.warning('Wallet ${wallet.name} already exist, attempting sign-in'); return true; } @@ -342,9 +403,28 @@ class AuthBloc extends Bloc with TrezorAuthMixin { void _listenToAuthStateChanges() { _authChangesSubscription?.cancel(); _authChangesSubscription = _kdfSdk.auth.watchCurrentUser().listen((user) { - final AuthorizeMode event = - user != null ? AuthorizeMode.logIn : AuthorizeMode.noLogin; + final AuthorizeMode event = user != null + ? AuthorizeMode.logIn + : AuthorizeMode.noLogin; add(AuthModeChanged(mode: event, currentUser: user)); }); } + + List _filterOutUnsupportedCoins(List coins) { + final unsupportedAssets = coins.where( + (coin) => _kdfSdk.assets.findAssetsByConfigId(coin).isEmpty, + ); + _log.warning( + 'Skipping import of unsupported assets: ' + '${unsupportedAssets.map((coin) => coin).join(', ')}', + ); + + final supportedAssets = coins + .map((coin) => _kdfSdk.assets.findAssetsByConfigId(coin)) + .where((assets) => assets.isNotEmpty) + .map((assets) => assets.single.id.id); + _log.info('Import supported assets: ${supportedAssets.join(', ')}'); + + return supportedAssets.toList(); + } } diff --git a/lib/bloc/auth_bloc/trezor_auth_mixin.dart b/lib/bloc/auth_bloc/trezor_auth_mixin.dart index 877a5ba998..e516353eb0 100644 --- a/lib/bloc/auth_bloc/trezor_auth_mixin.dart +++ b/lib/bloc/auth_bloc/trezor_auth_mixin.dart @@ -3,7 +3,7 @@ part of 'auth_bloc.dart'; /// Mixin that exposes Trezor authentication helpers for [AuthBloc]. mixin TrezorAuthMixin on Bloc { KomodoDefiSdk get _sdk; - final _log = Logger('TrezorAuthMixin'); + Logger get _log; /// Registers handlers for Trezor specific events. void setupTrezorEventHandlers() { @@ -17,6 +17,10 @@ mixin TrezorAuthMixin on Bloc { /// to authentication state changes. void _listenToAuthStateChanges(); + /// Filters out geo-blocked assets from a list of coin IDs. + /// Implemented in [AuthBloc]. + List _filterBlockedAssets(List coinIds); + Future _onTrezorInitAndAuth( AuthTrezorInitAndAuthStarted event, Emitter emit, @@ -130,7 +134,9 @@ mixin TrezorAuthMixin on Bloc { if (authState.user!.wallet.config.activatedCoins.isEmpty) { // If no coins are activated, we assume this is the first time // the user is setting up their Trezor wallet. - await _sdk.addActivatedCoins(enabledByDefaultCoins); + // Filter out geo-blocked assets from default coins before adding to wallet + final allowedDefaultCoins = _filterBlockedAssets(enabledByDefaultCoins); + await _sdk.addActivatedCoins(allowedDefaultCoins); } // Refresh the current user to pull in the updated wallet metadata diff --git a/lib/bloc/bridge_form/bridge_bloc.dart b/lib/bloc/bridge_form/bridge_bloc.dart index 500d246f44..894dcab392 100644 --- a/lib/bloc/bridge_form/bridge_bloc.dart +++ b/lib/bloc/bridge_form/bridge_bloc.dart @@ -1,10 +1,14 @@ import 'dart:async'; 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:komodo_defi_sdk/komodo_defi_sdk.dart' show KomodoDefiSdk; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart' + show LinearBackoff, retry; +import 'package:komodo_defi_types/komodo_defi_types.dart' show KdfUser; import 'package:rational/rational.dart'; +import 'package:web_dex/analytics/events/cross_chain_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/bridge_form/bridge_event.dart'; import 'package:web_dex/bloc/bridge_form/bridge_repository.dart'; import 'package:web_dex/bloc/bridge_form/bridge_state.dart'; @@ -27,9 +31,6 @@ import 'package:web_dex/model/typedef.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/views/dex/dex_helpers.dart'; -import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; -import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; -import 'package:web_dex/analytics/events/cross_chain_events.dart'; class BridgeBloc extends Bloc { BridgeBloc({ @@ -38,12 +39,12 @@ class BridgeBloc extends Bloc { required CoinsRepo coinsRepository, required KomodoDefiSdk kdfSdk, required AnalyticsBloc analyticsBloc, - }) : _bridgeRepository = bridgeRepository, - _dexRepository = dexRepository, - _coinsRepository = coinsRepository, - _kdfSdk = kdfSdk, - _analyticsBloc = analyticsBloc, - super(BridgeState.initial()) { + }) : _bridgeRepository = bridgeRepository, + _dexRepository = dexRepository, + _coinsRepository = coinsRepository, + _kdfSdk = kdfSdk, + _analyticsBloc = analyticsBloc, + super(BridgeState.initial()) { on(_onInit); on(_onReInit); on(_onLogout); @@ -80,8 +81,9 @@ class BridgeBloc extends Bloc { sdk: _kdfSdk, ); - _authorizationSubscription = - _kdfSdk.auth.watchCurrentUser().listen((event) { + _authorizationSubscription = _kdfSdk.auth.watchCurrentUser().listen(( + event, + ) { _isLoggedIn = event != null; if (!_isLoggedIn) add(const BridgeLogout()); }); @@ -101,30 +103,19 @@ class BridgeBloc extends Bloc { Timer? _maxSellAmountTimer; Timer? _preimageTimer; - void _onInit( - BridgeInit event, - Emitter emit, - ) { + void _onInit(BridgeInit event, Emitter emit) { if (state.selectedTicker != null) return; final Coin? defaultTickerCoin = _coinsRepository.getCoin(event.ticker); - emit(state.copyWith( - selectedTicker: () => defaultTickerCoin?.abbr, - )); + emit(state.copyWith(selectedTicker: () => defaultTickerCoin?.abbr)); add(const BridgeUpdateTickers()); } - Future _onReInit( - BridgeReInit event, - Emitter emit, - ) async { + Future _onReInit(BridgeReInit event, Emitter emit) async { _isLoggedIn = true; - emit(state.copyWith( - error: () => null, - autovalidate: () => false, - )); + emit(state.copyWith(error: () => null, autovalidate: () => false)); add(const BridgeUpdateMaxSellAmount(true)); @@ -135,37 +126,35 @@ class BridgeBloc extends Bloc { _subscribeFees(); } - void _onLogout( - BridgeLogout event, - Emitter emit, - ) { + void _onLogout(BridgeLogout event, Emitter emit) { _isLoggedIn = false; - emit(state.copyWith( - availableBalanceState: () => AvailableBalanceState.unavailable, - maxSellAmount: () => null, - preimageData: () => null, - step: () => BridgeStep.form, - )); + emit( + state.copyWith( + availableBalanceState: () => AvailableBalanceState.unavailable, + maxSellAmount: () => null, + preimageData: () => null, + step: () => BridgeStep.form, + ), + ); } - void _onTickerChanged( - BridgeTickerChanged event, - Emitter emit, - ) { - emit(state.copyWith( - selectedTicker: () => event.ticker, - showTickerDropdown: () => false, - sellCoin: () => null, - sellAmount: () => null, - bestOrders: () => null, - bestOrder: () => null, - buyAmount: () => null, - maxSellAmount: () => null, - availableBalanceState: () => AvailableBalanceState.unavailable, - preimageData: () => null, - error: () => null, - )); + void _onTickerChanged(BridgeTickerChanged event, Emitter emit) { + emit( + state.copyWith( + selectedTicker: () => event.ticker, + showTickerDropdown: () => false, + sellCoin: () => null, + sellAmount: () => null, + bestOrders: () => null, + bestOrder: () => null, + buyAmount: () => null, + maxSellAmount: () => null, + availableBalanceState: () => AvailableBalanceState.unavailable, + preimageData: () => null, + error: () => null, + ), + ); } Future _onUpdateTickers( @@ -174,9 +163,7 @@ class BridgeBloc extends Bloc { ) async { final CoinsByTicker tickers = await _bridgeRepository.getAvailableTickers(); - emit(state.copyWith( - tickers: () => tickers, - )); + emit(state.copyWith(tickers: () => tickers)); add(const BridgeUpdateSellCoins()); } @@ -185,64 +172,71 @@ class BridgeBloc extends Bloc { BridgeShowTickerDropdown event, Emitter emit, ) { - emit(state.copyWith( - showTickerDropdown: () => event.show, - showSourceDropdown: () => false, - showTargetDropdown: () => false, - )); + emit( + state.copyWith( + showTickerDropdown: () => event.show, + showSourceDropdown: () => false, + showTargetDropdown: () => false, + ), + ); } void _onShowSourceDropdown( BridgeShowSourceDropdown event, Emitter emit, ) { - emit(state.copyWith( - showSourceDropdown: () => event.show, - showTickerDropdown: () => false, - showTargetDropdown: () => false, - )); + emit( + state.copyWith( + showSourceDropdown: () => event.show, + showTickerDropdown: () => false, + showTargetDropdown: () => false, + ), + ); } void _onShowTargetDropdown( BridgeShowTargetDropdown event, Emitter emit, ) { - emit(state.copyWith( - showTargetDropdown: () => event.show, - showTickerDropdown: () => false, - showSourceDropdown: () => false, - )); + emit( + state.copyWith( + showTargetDropdown: () => event.show, + showTickerDropdown: () => false, + showSourceDropdown: () => false, + ), + ); } Future _onUpdateSellCoins( BridgeUpdateSellCoins event, Emitter emit, ) async { - final CoinsByTicker? sellCoins = - await _bridgeRepository.getSellCoins(state.tickers); + final CoinsByTicker? sellCoins = await _bridgeRepository.getSellCoins( + state.tickers, + ); - emit(state.copyWith( - sellCoins: () => sellCoins, - )); + emit(state.copyWith(sellCoins: () => sellCoins)); } Future _onSetSellCoin( BridgeSetSellCoin event, Emitter emit, ) async { - emit(state.copyWith( - sellCoin: () => event.coin, - sellAmount: () => null, - showSourceDropdown: () => false, - bestOrders: () => null, - bestOrder: () => null, - buyAmount: () => null, - maxSellAmount: () => null, - availableBalanceState: () => AvailableBalanceState.initial, - preimageData: () => null, - error: () => null, - autovalidate: () => false, - )); + emit( + state.copyWith( + sellCoin: () => event.coin, + sellAmount: () => null, + showSourceDropdown: () => false, + bestOrders: () => null, + bestOrder: () => null, + buyAmount: () => null, + maxSellAmount: () => null, + availableBalanceState: () => AvailableBalanceState.initial, + preimageData: () => null, + error: () => null, + autovalidate: () => false, + ), + ); _autoActivateCoin(event.coin.abbr); _subscribeMaxSellAmount(); @@ -262,39 +256,45 @@ class BridgeBloc extends Bloc { final sellCoin = state.sellCoin; if (sellCoin == null) return; - final bestOrders = await _dexRepository.getBestOrders(BestOrdersRequest( - coin: sellCoin.abbr, - action: 'sell', - type: BestOrdersRequestType.number, - number: 1, - )); + final bestOrders = await _dexRepository.getBestOrders( + BestOrdersRequest( + coin: sellCoin.abbr, + action: 'sell', + type: BestOrdersRequestType.number, + number: 1, + ), + ); /// Unsupported coins like ARRR cause downstream errors, so we need to /// remove them from the list here - bestOrders.result - ?.removeWhere((coinId, _) => excludedAssetList.contains(coinId)); + bestOrders.result?.removeWhere( + (coinId, _) => excludedAssetList.contains(coinId), + ); - emit(state.copyWith( - bestOrders: () => bestOrders, - )); + emit(state.copyWith(bestOrders: () => bestOrders)); } void _onSelectBestOrder( BridgeSelectBestOrder event, Emitter emit, ) async { - final bool switchingCoin = state.bestOrder != null && + final bool switchingCoin = + state.bestOrder != null && event.order != null && state.bestOrder!.coin != event.order!.coin; - emit(state.copyWith( - bestOrder: () => event.order, - showTargetDropdown: () => false, - buyAmount: () => calculateBuyAmount( - sellAmount: state.sellAmount, selectedOrder: event.order), - error: () => null, - autovalidate: switchingCoin ? () => false : null, - )); + emit( + state.copyWith( + bestOrder: () => event.order, + showTargetDropdown: () => false, + buyAmount: () => calculateBuyAmount( + sellAmount: state.sellAmount, + selectedOrder: event.order, + ), + error: () => null, + autovalidate: switchingCoin ? () => false : null, + ), + ); if (!state.autovalidate) add(const BridgeVerifyOrderVolume()); @@ -303,22 +303,12 @@ class BridgeBloc extends Bloc { _subscribeFees(); } - void _onSetError( - BridgeSetError event, - Emitter emit, - ) { - emit(state.copyWith( - error: () => event.error, - )); + void _onSetError(BridgeSetError event, Emitter emit) { + emit(state.copyWith(error: () => event.error)); } - void _onClearErrors( - BridgeClearErrors event, - Emitter emit, - ) { - emit(state.copyWith( - error: () => null, - )); + void _onClearErrors(BridgeClearErrors event, Emitter emit) { + emit(state.copyWith(error: () => null)); } void _subscribeFees() { @@ -346,8 +336,10 @@ class BridgeBloc extends Bloc { ) { final Rational? maxSellAmount = state.maxSellAmount; if (maxSellAmount == null) return; - final Rational sellAmount = - getFractionOfAmount(maxSellAmount, event.fraction); + final Rational sellAmount = getFractionOfAmount( + maxSellAmount, + event.fraction, + ); add(BridgeSetSellAmount(sellAmount)); } @@ -355,8 +347,9 @@ class BridgeBloc extends Bloc { BridgeSellAmountChange event, Emitter emit, ) { - final Rational? amount = - event.value.isNotEmpty ? Rational.parse(event.value) : null; + final Rational? amount = event.value.isNotEmpty + ? Rational.tryParse(event.value) + : null; if (amount == state.sellAmount) return; @@ -367,13 +360,15 @@ class BridgeBloc extends Bloc { BridgeSetSellAmount event, Emitter emit, ) async { - emit(state.copyWith( - sellAmount: () => event.amount, - buyAmount: () => calculateBuyAmount( - selectedOrder: state.bestOrder, - sellAmount: event.amount, + emit( + state.copyWith( + sellAmount: () => event.amount, + buyAmount: () => calculateBuyAmount( + selectedOrder: state.bestOrder, + sellAmount: event.amount, + ), ), - )); + ); if (state.autovalidate) { await _validator.validateForm(); @@ -389,50 +384,56 @@ class BridgeBloc extends Bloc { ) async { if (state.sellCoin == null) { _maxSellAmountTimer?.cancel(); - emit(state.copyWith( - availableBalanceState: () => AvailableBalanceState.unavailable, - )); + emit( + state.copyWith( + availableBalanceState: () => AvailableBalanceState.unavailable, + ), + ); return; } if (state.availableBalanceState == AvailableBalanceState.initial || event.setLoadingStatus) { - emit(state.copyWith( - availableBalanceState: () => AvailableBalanceState.loading, - )); + emit( + state.copyWith( + availableBalanceState: () => AvailableBalanceState.loading, + ), + ); } if (!_isLoggedIn) { - emit(state.copyWith( - availableBalanceState: () => AvailableBalanceState.unavailable, - )); + emit( + state.copyWith( + availableBalanceState: () => AvailableBalanceState.unavailable, + ), + ); } else { - Rational? maxSellAmount = - await _dexRepository.getMaxTakerVolume(state.sellCoin!.abbr); + Rational? maxSellAmount = await _dexRepository.getMaxTakerVolume( + state.sellCoin!.abbr, + ); if (maxSellAmount != null) { - emit(state.copyWith( - maxSellAmount: () => maxSellAmount, - availableBalanceState: () => AvailableBalanceState.success, - )); + emit( + state.copyWith( + maxSellAmount: () => maxSellAmount, + availableBalanceState: () => AvailableBalanceState.success, + ), + ); } else { maxSellAmount = await _frequentlyGetMaxTakerVolume(); - emit(state.copyWith( - maxSellAmount: () => maxSellAmount, - availableBalanceState: maxSellAmount == null - ? () => AvailableBalanceState.failure - : () => AvailableBalanceState.success, - )); + emit( + state.copyWith( + maxSellAmount: () => maxSellAmount, + availableBalanceState: maxSellAmount == null + ? () => AvailableBalanceState.failure + : () => AvailableBalanceState.success, + ), + ); } } } - void _onUpdateFees( - BridgeUpdateFees event, - Emitter emit, - ) async { - emit(state.copyWith( - preimageData: () => null, - )); + void _onUpdateFees(BridgeUpdateFees event, Emitter emit) async { + emit(state.copyWith(preimageData: () => null)); if (!_validator.canRequestPreimage) { _preimageTimer?.cancel(); @@ -449,65 +450,45 @@ class BridgeBloc extends Bloc { ) async { if (state.sellCoin == null) return; if (!_isLoggedIn) { - emit(state.copyWith( - minSellAmount: () => null, - )); + emit(state.copyWith(minSellAmount: () => null)); return; } - final Rational? minSellAmount = - await _dexRepository.getMinTradingVolume(state.sellCoin!.abbr); + final Rational? minSellAmount = await _dexRepository.getMinTradingVolume( + state.sellCoin!.abbr, + ); - emit(state.copyWith( - minSellAmount: () => minSellAmount, - )); + emit(state.copyWith(minSellAmount: () => minSellAmount)); } - void _onSetPreimage( - BridgeSetPreimage event, - Emitter emit, - ) { - emit(state.copyWith( - preimageData: () => event.preimageData, - )); + void _onSetPreimage(BridgeSetPreimage event, Emitter emit) { + emit(state.copyWith(preimageData: () => event.preimageData)); } - void _onSetInProgress( - BridgeSetInProgress event, - Emitter emit, - ) { - emit(state.copyWith( - inProgress: () => event.inProgress, - )); + void _onSetInProgress(BridgeSetInProgress event, Emitter emit) { + emit(state.copyWith(inProgress: () => event.inProgress)); } void _onSubmitClick( BridgeSubmitClick event, Emitter emit, ) async { - emit(state.copyWith( - inProgress: () => true, - autovalidate: () => true, - )); + emit(state.copyWith(inProgress: () => true, autovalidate: () => true)); await pauseWhile(() => _waitingForWallet || _activatingAssets); final bool isValid = await _validator.validate(); - emit(state.copyWith( - inProgress: () => false, - step: () => isValid ? BridgeStep.confirm : BridgeStep.form, - )); + emit( + state.copyWith( + inProgress: () => false, + step: () => isValid ? BridgeStep.confirm : BridgeStep.form, + ), + ); } - void _onBackClick( - BridgeBackClick event, - Emitter emit, - ) { - emit(state.copyWith( - step: () => BridgeStep.form, - error: () => null, - )); + void _onBackClick(BridgeBackClick event, Emitter emit) { + emit(state.copyWith(step: () => BridgeStep.form, error: () => null)); } void _onSetWalletIsReady( @@ -517,10 +498,7 @@ class BridgeBloc extends Bloc { _waitingForWallet = !event.isReady; } - void _onStartSwap( - BridgeStartSwap event, - Emitter emit, - ) async { + void _onStartSwap(BridgeStartSwap event, Emitter emit) async { final sellCoin = state.sellCoin; final bestOrder = state.bestOrder; if (sellCoin != null && bestOrder != null) { @@ -529,23 +507,24 @@ class BridgeBloc extends Bloc { (await _kdfSdk.auth.currentUser)?.wallet.config.type.name ?? ''; _analyticsBloc.logEvent( BridgeInitiatedEventData( - fromChain: sellCoin.protocolType, - toChain: buyCoin?.protocolType ?? '', asset: sellCoin.abbr, - walletType: walletType, + secondaryAsset: buyCoin?.abbr ?? sellCoin.abbr, + network: sellCoin.protocolType, + secondaryNetwork: buyCoin?.protocolType ?? '', + hdType: walletType, ), ); } - emit(state.copyWith( - inProgress: () => true, - )); - final SellResponse response = await _dexRepository.sell(SellRequest( - base: state.sellCoin!.abbr, - rel: state.bestOrder!.coin, - volume: state.sellAmount!, - price: state.bestOrder!.price, - orderType: SellBuyOrderType.fillOrKill, - )); + emit(state.copyWith(inProgress: () => true)); + final SellResponse response = await _dexRepository.sell( + SellRequest( + base: state.sellCoin!.abbr, + rel: state.bestOrder!.coin, + volume: state.sellAmount!, + price: state.bestOrder!.price, + orderType: SellBuyOrderType.fillOrKill, + ), + ); final String? uuid = response.result?.uuid; @@ -555,11 +534,12 @@ class BridgeBloc extends Bloc { (await _kdfSdk.auth.currentUser)?.wallet.config.type.name ?? ''; _analyticsBloc.logEvent( BridgeSucceededEventData( - fromChain: state.sellCoin!.protocolType, - toChain: buyCoin?.protocolType ?? '', asset: state.sellCoin!.abbr, + secondaryAsset: buyCoin?.abbr ?? state.sellCoin!.abbr, + network: state.sellCoin!.protocolType, + secondaryNetwork: buyCoin?.protocolType ?? '', amount: state.sellAmount?.toDouble() ?? 0.0, - walletType: walletType, + hdType: walletType, ), ); } else { @@ -569,19 +549,24 @@ class BridgeBloc extends Bloc { final error = response.error?.message ?? 'unknown'; _analyticsBloc.logEvent( BridgeFailedEventData( - fromChain: state.sellCoin!.protocolType, - toChain: buyCoin?.protocolType ?? '', - failError: error, - walletType: walletType, + asset: state.sellCoin!.abbr, + secondaryAsset: buyCoin?.abbr ?? state.sellCoin!.abbr, + network: state.sellCoin!.protocolType, + secondaryNetwork: buyCoin?.protocolType ?? '', + failureStage: 'start_swap', + failureDetail: error, + hdType: walletType, ), ); add(BridgeSetError(DexFormError(error: error))); } - emit(state.copyWith( - inProgress: uuid == null ? () => false : null, - swapUuid: () => uuid, - )); + emit( + state.copyWith( + inProgress: uuid == null ? () => false : null, + swapUuid: () => uuid, + ), + ); } void _verifyOrderVolume( @@ -591,10 +576,7 @@ class BridgeBloc extends Bloc { _validator.verifyOrderVolume(); } - void _onClear( - BridgeClear event, - Emitter emit, - ) { + void _onClear(BridgeClear event, Emitter emit) { emit(BridgeState.initial()); } @@ -621,8 +603,10 @@ class BridgeBloc extends Bloc { if (abbr == null) return; _activatingAssets = true; - final List activationErrors = - await activateCoinIfNeeded(abbr, _coinsRepository); + final List activationErrors = await activateCoinIfNeeded( + abbr, + _coinsRepository, + ); _activatingAssets = false; if (activationErrors.isNotEmpty) { @@ -638,20 +622,18 @@ class BridgeBloc extends Bloc { bestOrders.forEach((key, value) => list.addAll(value)); - list.removeWhere( - (order) { - final Coin? item = _coinsRepository.getCoin(order.coin); - if (item == null) return true; + list.removeWhere((order) { + final Coin? item = _coinsRepository.getCoin(order.coin); + if (item == null) return true; - final sameTicker = abbr2Ticker(item.abbr) == abbr2Ticker(sellCoin.abbr); - if (!sameTicker) return true; + final sameTicker = abbr2Ticker(item.abbr) == abbr2Ticker(sellCoin.abbr); + if (!sameTicker) return true; - if (item.isSuspended) return true; - if (item.walletOnly) return true; + if (item.isSuspended) return true; + if (item.walletOnly) return true; - return false; - }, - ); + return false; + }); list.sort((a, b) => a.coin.compareTo(b.coin)); @@ -668,8 +650,12 @@ class BridgeBloc extends Bloc { state.sellAmount, ); } catch (e, s) { - log(e.toString(), - trace: s, path: 'bridge_bloc::_getFeesData', isError: true); + log( + e.toString(), + trace: s, + path: 'bridge_bloc::_getFeesData', + isError: true, + ); return DataFromService(error: TextError(error: 'Failed to request fees')); } } diff --git a/lib/bloc/bridge_form/bridge_repository.dart b/lib/bloc/bridge_form/bridge_repository.dart index 5b728a7a69..c1db2114d8 100644 --- a/lib/bloc/bridge_form/bridge_repository.dart +++ b/lib/bloc/bridge_form/bridge_repository.dart @@ -55,7 +55,6 @@ class BridgeRepository { Future getAvailableTickers() async { List coins = _coinsRepository.getKnownCoins(); coins = removeWalletOnly(coins); - coins = removeSuspended(coins, await _kdfSdk.auth.isSignedIn()); final CoinsByTicker coinsByTicker = convertToCoinsByTicker(coins); final CoinsByTicker multiProtocolCoins = diff --git a/lib/bloc/bridge_form/bridge_validator.dart b/lib/bloc/bridge_form/bridge_validator.dart index 189da19abf..0e5c59d1b1 100644 --- a/lib/bloc/bridge_form/bridge_validator.dart +++ b/lib/bloc/bridge_form/bridge_validator.dart @@ -292,7 +292,7 @@ class BridgeValidator { } DexFormError _coinNotActiveError(String abbr) { - return DexFormError(error: '$abbr is not active.'); + return DexFormError(error: LocaleKeys.coinIsNotActive.tr(args: [abbr])); } DexFormError _selectSourceProtocolError() => 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 new file mode 100644 index 0000000000..c345e38455 --- /dev/null +++ b/lib/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart @@ -0,0 +1,76 @@ +import 'dart:math' as math; + +/// A strategy for implementing exponential backoff with paired intervals. +/// The pattern is: 1min, 1min, 2min, 2min, 4min, 4min, 8min, 8min, etc. +/// This reduces API calls while still providing reasonable update frequency. +class UpdateFrequencyBackoffStrategy { + UpdateFrequencyBackoffStrategy({ + this.baseInterval = const Duration(minutes: 1), + this.maxInterval = const Duration(hours: 1), + }); + + /// The base interval for the first attempts (default: 2 minutes) + final Duration baseInterval; + + /// The maximum interval to backoff to (default: 1 hour) + final Duration maxInterval; + + int _attemptCount = 0; + + /// Reset the backoff strategy to start from the beginning + void reset() { + _attemptCount = 0; + } + + /// Get the current attempt count + int get attemptCount => _attemptCount; + + /// Get the next interval duration and increment the attempt count + Duration getNextInterval() { + final interval = getCurrentInterval(); + _attemptCount++; + return interval; + } + + /// Get the current interval duration without incrementing the attempt count + Duration getCurrentInterval() { + // Calculate which "pair" we're in (0, 1, 2, 3, ...) + // Each pair has 2 attempts with the same interval + final pairIndex = _attemptCount ~/ 2; + + // Calculate the multiplier: 2^pairIndex + final multiplier = math.pow(2, pairIndex).toInt(); + + // Calculate the interval + final intervalMs = baseInterval.inMilliseconds * multiplier; + + // Cap at maximum interval + final cappedIntervalMs = math.min(intervalMs, maxInterval.inMilliseconds); + + return Duration(milliseconds: cappedIntervalMs); + } + + /// Check if we should update the cache on the current attempt + /// Returns true for cache update attempts, false for cache-only reads + bool shouldUpdateCache() { + // Update cache on every attempt for now, but this could be modified + // to only update on certain intervals if needed + return true; + } + + /// Get a preview of the next N intervals without affecting the state + List previewNextIntervals(int count) { + final currentAttempt = _attemptCount; + final intervals = []; + + for (int i = 0; i < count; i++) { + final pairIndex = (currentAttempt + i) ~/ 2; + final multiplier = math.pow(2, pairIndex).toInt(); + final intervalMs = baseInterval.inMilliseconds * multiplier; + final cappedIntervalMs = math.min(intervalMs, maxInterval.inMilliseconds); + intervals.add(Duration(milliseconds: cappedIntervalMs)); + } + + return intervals; + } +} \ No newline at end of file diff --git a/lib/bloc/cex_market_data/mockup/generate_demo_data.dart b/lib/bloc/cex_market_data/mockup/generate_demo_data.dart index c6d07d1a24..d6e466f803 100644 --- a/lib/bloc/cex_market_data/mockup/generate_demo_data.dart +++ b/lib/bloc/cex_market_data/mockup/generate_demo_data.dart @@ -1,22 +1,22 @@ import 'dart:math'; import 'package:decimal/decimal.dart'; -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:uuid/uuid.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; -// similar to generator implementation to allow for const constructor -final _ohlcvCache = >{}; +// Cache for demo price history data +final _priceHistoryCache = >{}; /// Generates semi-random transaction data for demo purposes. The transactions -/// are generated based on the historical OHLCV data for the given coin. The +/// are generated based on simulated historical price data for the given coin. The /// transactions are generated in a way that the overall balance of the user /// will increase or decrease based on the given performance mode. class DemoDataGenerator { - final CexRepository _ohlcRepo; + final KomodoDefiSdk _sdk; final int randomSeed; - final List coinPairs; + final List assetIds; final Map transactionsPerMode; final Map overallReturn; final Map> buyProbabilities; @@ -24,16 +24,9 @@ class DemoDataGenerator { final double initialBalance; const DemoDataGenerator( - this._ohlcRepo, { + this._sdk, { this.initialBalance = 1000.0, - this.coinPairs = const [ - CexCoinPair.usdtPrice('KMD'), - CexCoinPair.usdtPrice('LTC'), - CexCoinPair.usdtPrice('MATIC'), - CexCoinPair.usdtPrice('AVAX'), - CexCoinPair.usdtPrice('FTM'), - CexCoinPair.usdtPrice('ATOM'), - ], + this.assetIds = const [], // Will be initialized with default list this.transactionsPerMode = const { PerformanceMode.good: 28, PerformanceMode.mediocre: 52, @@ -57,47 +50,118 @@ class DemoDataGenerator { this.randomSeed = 42, }); + /// Default asset IDs for demo purposes + static final List defaultAssetIds = [ + AssetId( + chainId: AssetChainId(chainId: 1), + derivationPath: '', + id: 'KMD', + name: 'Komodo', + subClass: CoinSubClass.smartChain, + symbol: AssetSymbol(assetConfigId: 'KMD'), + ), + AssetId( + chainId: AssetChainId(chainId: 2), + derivationPath: '', + id: 'LTC', + name: 'Litecoin', + subClass: CoinSubClass.smartChain, + symbol: AssetSymbol(assetConfigId: 'LTC'), + ), + AssetId( + chainId: AssetChainId(chainId: 137), + derivationPath: '', + id: 'MATIC', + name: 'Polygon', + subClass: CoinSubClass.matic, + symbol: AssetSymbol(assetConfigId: 'MATIC'), + ), + AssetId( + chainId: AssetChainId(chainId: 43114), + derivationPath: '', + id: 'AVAX', + name: 'Avalanche', + subClass: CoinSubClass.avx20, + symbol: AssetSymbol(assetConfigId: 'AVAX'), + ), + AssetId( + chainId: AssetChainId(chainId: 250), + derivationPath: '', + id: 'FTM', + name: 'Fantom', + subClass: CoinSubClass.ftm20, + symbol: AssetSymbol(assetConfigId: 'FTM'), + ), + AssetId( + chainId: AssetChainId(chainId: 118), + derivationPath: '', + id: 'ATOM', + name: 'Cosmos', + subClass: CoinSubClass.tendermint, + symbol: AssetSymbol(assetConfigId: 'ATOM'), + ), + ]; + Future> generateTransactions( String coinId, PerformanceMode mode, ) async { - if (_ohlcvCache.isEmpty) { - _ohlcvCache.addAll(await fetchOhlcData()); + if (_priceHistoryCache.isEmpty) { + await fetchPriceHistoryData(); } - // Remove segwit suffix for cache key, as the ohlc data from cex providers - // does not include the segwit suffix - final cacheKey = coinId.replaceAll('-segwit', ''); - if (!_ohlcvCache.containsKey(CexCoinPair.usdtPrice(cacheKey))) { + // Try to match the coinId to one of our asset IDs + final actualAssetIds = assetIds.isEmpty ? defaultAssetIds : assetIds; + final assetId = actualAssetIds.cast().firstWhere( + (asset) => + asset!.id.toLowerCase() == coinId.toLowerCase() || + asset.symbol.assetConfigId.toLowerCase() == coinId.toLowerCase(), + orElse: () => null, + ); + + if (assetId == null || !_priceHistoryCache.containsKey(assetId)) { return []; } - final ohlcvData = _ohlcvCache[CexCoinPair.usdtPrice(cacheKey)]!; + + final priceHistory = _priceHistoryCache[assetId]!; + final priceEntries = priceHistory.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); final numTransactions = transactionsPerMode[mode]!; final random = Random(randomSeed); - final buyProbalities = buyProbabilities[mode]!; + final buyProbabilities = this.buyProbabilities[mode]!; final tradeAmounts = tradeAmountFactors[mode]!; - double totalBalance = initialBalance / ohlcvData.last.close; + + // Get the initial price for calculations + final initialPrice = priceEntries.first.value; + final finalPrice = priceEntries.last.value; + double totalBalance = initialBalance / initialPrice; double targetFinalBalance = - (initialBalance * overallReturn[mode]!) / ohlcvData.first.close; + (initialBalance * overallReturn[mode]!) / finalPrice; List transactions = []; for (int i = 0; i < numTransactions; i++) { - final int index = (i * ohlcvData.length ~/ numTransactions) - .clamp(0, ohlcvData.length - 1); - final Ohlc ohlcv = ohlcvData[index]; + final int index = (i * priceEntries.length ~/ numTransactions).clamp( + 0, + priceEntries.length - 1, + ); + final priceEntry = priceEntries[index]; final int quarter = (i * 4 ~/ numTransactions).clamp(0, 3); - final bool isBuy = random.nextDouble() < buyProbalities[quarter]; + final bool isBuy = random.nextDouble() < buyProbabilities[quarter]; final bool isSameDay = random.nextDouble() < tradeAmounts[quarter]; final double tradeAmountFactor = tradeAmounts[quarter]; final double tradeAmount = random.nextDouble() * tradeAmountFactor * totalBalance; - final transaction = - fromTradeAmount(coinId, tradeAmount, isBuy, ohlcv.closeTime); + final transaction = fromTradeAmount( + coinId, + tradeAmount, + isBuy, + priceEntry.key.millisecondsSinceEpoch, + ); transactions.add(transaction); if (isSameDay) { @@ -105,7 +169,7 @@ class DemoDataGenerator { coinId, -tradeAmount, !isBuy, - ohlcv.closeTime + 100, + priceEntry.key.millisecondsSinceEpoch + 100, ); transactions.add(transaction); } @@ -131,8 +195,9 @@ class DemoDataGenerator { double totalBalance, List transactions, ) { - final Decimal adjustmentFactor = - Decimal.parse((targetFinalBalance / totalBalance).toString()); + final Decimal adjustmentFactor = Decimal.parse( + (targetFinalBalance / totalBalance).toString(), + ); final adjustedTransactions = []; for (var transaction in transactions) { final netChange = transaction.balanceChanges.netChange; @@ -154,33 +219,101 @@ class DemoDataGenerator { return adjustedTransactions; } - Future>> fetchOhlcData() async { - final ohlcvData = >{}; - final supportedCoins = await _ohlcRepo.getCoinList(); - for (final CexCoinPair coin in coinPairs) { - final supportedCoin = supportedCoins.where( - (element) => element.id == coin.baseCoinTicker, - ); - if (supportedCoin.isEmpty) { - continue; + /// Fetches simulated price history data for demo purposes. + /// This replaces the legacy CEX repository OHLC data fetching. + Future fetchPriceHistoryData() async { + final actualAssetIds = assetIds.isEmpty ? defaultAssetIds : assetIds; + for (final assetId in actualAssetIds) { + try { + // Try to fetch real price history from SDK if available + final now = DateTime.now(); + final startDate = now.subtract(const Duration(days: 365)); + + // Generate daily intervals for the past year + final dates = []; + for ( + var date = startDate; + date.isBefore(now); + date = date.add(const Duration(days: 1)) + ) { + dates.add(date); + } + + Map priceHistory; + + try { + // Attempt to get real price data from SDK + final quoteCurrency = QuoteCurrency.fromString('USDT'); + if (quoteCurrency != null) { + final sdkPriceHistory = await _sdk.marketData.fiatPriceHistory( + assetId, + dates, + quoteCurrency: quoteCurrency, + ); + + // Convert Decimal to double + priceHistory = sdkPriceHistory.map( + (key, value) => MapEntry(key, value.toDouble()), + ); + } else { + throw Exception('Unable to create USDT quote currency'); + } + } catch (e) { + // Fallback: generate simulated price data + priceHistory = _generateSimulatedPriceData( + startDate: startDate, + endDate: now, + initialPrice: + 50.0 + + Random(assetId.hashCode).nextDouble() * + 100, // Price between $50-$150 + ); + } + + _priceHistoryCache[assetId] = priceHistory; + } catch (e) { + // If all else fails, generate basic simulated data + final now = DateTime.now(); + final startDate = now.subtract(const Duration(days: 365)); + final priceHistory = _generateSimulatedPriceData( + startDate: startDate, + endDate: now, + initialPrice: + 10.0 + + Random(assetId.hashCode).nextDouble() * + 90, // Price between $10-$100 + ); + _priceHistoryCache[assetId] = priceHistory; } + } + } - const interval = GraphInterval.oneDay; - final startAt = DateTime.now().subtract(const Duration(days: 365)); + /// Generates simulated price data for demo purposes + Map _generateSimulatedPriceData({ + required DateTime startDate, + required DateTime endDate, + required double initialPrice, + }) { + final priceHistory = {}; + final random = Random(randomSeed); + var currentPrice = initialPrice; - final data = - await _ohlcRepo.getCoinOhlc(coin, interval, startAt: startAt); + for ( + var date = startDate; + date.isBefore(endDate); + date = date.add(const Duration(days: 1)) + ) { + // Generate semi-realistic price movements (±5% daily change) + final changePercent = (random.nextDouble() - 0.5) * 0.1; // ±5% + currentPrice = currentPrice * (1 + changePercent); - final twoWeeksAgo = DateTime.now().subtract(const Duration(days: 14)); - data.ohlc.addAll( - await _ohlcRepo - .getCoinOhlc(coin, GraphInterval.oneHour, startAt: twoWeeksAgo) - .then((value) => value.ohlc), - ); + // Ensure price doesn't go below $1 + currentPrice = currentPrice.clamp(1.0, double.infinity); - ohlcvData[coin] = data.ohlc; + priceHistory[date] = currentPrice; } - return ohlcvData; + + return priceHistory; } } @@ -188,7 +321,7 @@ Transaction fromTradeAmount( String coinId, double tradeAmount, bool isBuy, - int closeTimestamp, + int timestampMilliseconds, ) { const uuid = Uuid(); final random = Random(42); @@ -215,7 +348,7 @@ Transaction fromTradeAmount( spentByMe: Decimal.parse(isBuy ? tradeAmount.toString() : '0'), totalAmount: Decimal.parse(tradeAmount.toString()), ), - timestamp: DateTime.fromMillisecondsSinceEpoch(closeTimestamp), + timestamp: DateTime.fromMillisecondsSinceEpoch(timestampMilliseconds), to: const ["address2"], txHash: uuid.v4(), memo: "memo", diff --git a/lib/bloc/cex_market_data/mockup/generator.dart b/lib/bloc/cex_market_data/mockup/generator.dart index b80a94bec0..d8f0194a39 100644 --- a/lib/bloc/cex_market_data/mockup/generator.dart +++ b/lib/bloc/cex_market_data/mockup/generator.dart @@ -1,37 +1,18 @@ import 'dart:async'; -import 'dart:convert'; -import 'package:flutter/services.dart'; -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/generate_demo_data.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; -final _supportedCoinsCache = >{}; final _transactionsCache = >>{}; class DemoDataCache { DemoDataCache(this._generator); - DemoDataCache.withDefaults() - : _generator = DemoDataGenerator( - BinanceRepository(binanceProvider: const BinanceProvider()), - ); + DemoDataCache.withDefaults(KomodoDefiSdk sdk) + : _generator = DemoDataGenerator(sdk); final DemoDataGenerator _generator; - Future> supportedCoinsDemoData() async { - const cacheKey = 'supportedCoins'; - if (_supportedCoinsCache.containsKey(cacheKey)) { - return _supportedCoinsCache[cacheKey]!; - } - - final String response = - await rootBundle.loadString('assets/debug/demo_trade_data.json'); - final data = json.decode(response) as Map; - final result = (data['profit'] as Map).keys.toList(); - _supportedCoinsCache[cacheKey] = result; - return result; - } - Future> loadTransactionsDemoData( PerformanceMode performanceMode, String coin, @@ -42,8 +23,10 @@ class DemoDataCache { return _transactionsCache[cacheKey]![performanceMode]!; } - final result = - await _generator.generateTransactions(cacheKey, performanceMode); + final result = await _generator.generateTransactions( + cacheKey, + performanceMode, + ); _transactionsCache.putIfAbsent(cacheKey, () => {}); _transactionsCache[cacheKey]![performanceMode] = result; 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 f27981068c..5bd32af820 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,4 +1,3 @@ -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; import 'package:komodo_persistence_layer/komodo_persistence_layer.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'; @@ -9,7 +8,6 @@ import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_r class MockPortfolioGrowthRepository extends PortfolioGrowthRepository { MockPortfolioGrowthRepository({ - required super.cexRepository, required super.transactionHistoryRepo, required super.cacheProvider, required this.performanceMode, @@ -22,17 +20,14 @@ class MockPortfolioGrowthRepository extends PortfolioGrowthRepository { required super.coinsRepository, required super.sdk, }) : super( - cexRepository: BinanceRepository( - binanceProvider: const BinanceProvider(), - ), - transactionHistoryRepo: MockTransactionHistoryRepo( - performanceMode: performanceMode, - demoDataGenerator: DemoDataCache.withDefaults(), - ), - cacheProvider: HiveLazyBoxProvider( - name: GraphType.balanceGrowth.tableName, - ), - ); + transactionHistoryRepo: MockTransactionHistoryRepo( + performanceMode: performanceMode, + demoDataGenerator: DemoDataCache.withDefaults(sdk), + ), + cacheProvider: HiveLazyBoxProvider( + name: GraphType.balanceGrowth.tableName, + ), + ); final PerformanceMode performanceMode; } 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 fdaa810127..e9f3747cf2 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 @@ -6,12 +6,13 @@ import 'package:equatable/equatable.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/bloc/cex_market_data/charts.dart'; +import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart'; import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart'; +import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; 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/utils/extensions/legacy_coin_migration_extensions.dart'; part 'portfolio_growth_event.dart'; part 'portfolio_growth_state.dart'; @@ -19,9 +20,13 @@ part 'portfolio_growth_state.dart'; class PortfolioGrowthBloc extends Bloc { PortfolioGrowthBloc({ - required this.portfolioGrowthRepository, - required this.sdk, - }) : super(const PortfolioGrowthInitial()) { + required PortfolioGrowthRepository portfolioGrowthRepository, + required KomodoDefiSdk sdk, + UpdateFrequencyBackoffStrategy? backoffStrategy, + }) : _sdk = sdk, + _portfolioGrowthRepository = portfolioGrowthRepository, + _backoffStrategy = backoffStrategy ?? UpdateFrequencyBackoffStrategy(), + super(const PortfolioGrowthInitial()) { // Use the restartable transformer for period change events to avoid // overlapping events if the user rapidly changes the period (i.e. faster // than the previous event can complete). @@ -36,9 +41,10 @@ class PortfolioGrowthBloc on(_onClearPortfolioGrowth); } - final PortfolioGrowthRepository portfolioGrowthRepository; - final KomodoDefiSdk sdk; + final PortfolioGrowthRepository _portfolioGrowthRepository; + final KomodoDefiSdk _sdk; final _log = Logger('PortfolioGrowthBloc'); + final UpdateFrequencyBackoffStrategy _backoffStrategy; void _onClearPortfolioGrowth( PortfolioGrowthClearRequested event, @@ -51,6 +57,14 @@ class PortfolioGrowthBloc PortfolioGrowthPeriodChanged event, Emitter emit, ) { + final coins = event.coins.withoutTestCoins(); + final ( + int totalCoins, + int coinsWithKnownBalance, + int coinsWithKnownBalanceAndFiat, + ) = _calculateCoinProgressCounters( + coins, + ); final currentState = state; if (currentState is PortfolioGrowthChartLoadSuccess) { emit( @@ -61,6 +75,9 @@ class PortfolioGrowthBloc totalBalance: currentState.totalBalance, totalChange24h: currentState.totalChange24h, percentageChange24h: currentState.percentageChange24h, + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, isUpdating: true, ), ); @@ -69,11 +86,19 @@ class PortfolioGrowthBloc GrowthChartLoadFailure( error: currentState.error, selectedPeriod: event.selectedPeriod, + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, ), ); } else if (currentState is PortfolioGrowthChartUnsupported) { emit( - PortfolioGrowthChartUnsupported(selectedPeriod: event.selectedPeriod), + PortfolioGrowthChartUnsupported( + selectedPeriod: event.selectedPeriod, + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, + ), ); } else { emit(const PortfolioGrowthInitial()); @@ -81,10 +106,9 @@ class PortfolioGrowthBloc add( PortfolioGrowthLoadRequested( - coins: event.coins, + coins: coins, selectedPeriod: event.selectedPeriod, fiatCoinId: 'USDT', - updateFrequency: event.updateFrequency, walletId: event.walletId, ), ); @@ -95,17 +119,35 @@ class PortfolioGrowthBloc Emitter emit, ) async { try { - final List coins = await _removeUnsupportedCoins(event); + final List coins = await event.coins.filterSupportedCoins( + (coin) => _portfolioGrowthRepository.isCoinChartSupported( + coin.id, + event.fiatCoinId, + ), + ); // Charts for individual coins (coin details) are parsed here as well, // and should be hidden if not supported. - if (coins.isEmpty && event.coins.length <= 1) { + final filteredEventCoins = event.coins.withoutTestCoins(); + if (coins.isEmpty && filteredEventCoins.length <= 1) { + final ( + int totalCoins, + int coinsWithKnownBalance, + int coinsWithKnownBalanceAndFiat, + ) = _calculateCoinProgressCounters( + filteredEventCoins, + ); return emit( - PortfolioGrowthChartUnsupported(selectedPeriod: event.selectedPeriod), + PortfolioGrowthChartUnsupported( + selectedPeriod: event.selectedPeriod, + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, + ), ); } await _loadChart( - coins, + filteredEventCoins, event, useCache: true, ).then(emit.call).catchError((Object error, StackTrace stackTrace) { @@ -118,61 +160,31 @@ class PortfolioGrowthBloc // In case most coins are activating on wallet startup, wait for at least // 50% of the coins to be enabled before attempting to load the uncached // chart. - await sdk.waitForEnabledCoinsToPassThreshold(event.coins); + await _sdk.waitForEnabledCoinsToPassThreshold(filteredEventCoins); // Only remove inactivate/activating coins after an attempt to load the // cached chart, as the cached chart may contain inactive coins. - final activeCoins = await _removeInactiveCoins(coins); - if (activeCoins.isNotEmpty) { - await _loadChart( - activeCoins, - event, - useCache: false, - ).then(emit.call).catchError((Object error, StackTrace stackTrace) { - _log.shout('Failed to load chart', error, stackTrace); - // Don't emit an error state here. If cached and uncached attempts - // both fail, the periodic refresh attempts should recovery - // at the cost of a longer first loading time. - }); - } + await _loadChart( + filteredEventCoins, + event, + useCache: false, + ).then(emit.call).catchError((Object error, StackTrace stackTrace) { + _log.shout('Failed to load chart', error, stackTrace); + // Don't emit an error state here. If cached and uncached attempts + // both fail, the periodic refresh attempts should recovery + // at the cost of a longer first loading time. + }); } catch (error, stackTrace) { _log.shout('Failed to load portfolio growth', error, stackTrace); // Don't emit an error state here, as the periodic refresh attempts should // recover at the cost of a longer first loading time. } - await emit.forEach( - // computation is omitted, so null-valued events are emitted on a set - // interval. - Stream.periodic(event.updateFrequency).asyncMap((_) async { - // Update prices before fetching chart data - await portfolioGrowthRepository.updatePrices(); - return _fetchPortfolioGrowthChart(event); - }), - onData: (data) => - _handlePortfolioGrowthUpdate(data, event.selectedPeriod, event.coins), - onError: (error, stackTrace) { - _log.shout('Failed to load portfolio growth', error, stackTrace); - return GrowthChartLoadFailure( - error: TextError(error: 'Failed to load portfolio growth'), - selectedPeriod: event.selectedPeriod, - ); - }, - ); - } + // Reset backoff strategy for new load request + _backoffStrategy.reset(); - Future> _removeUnsupportedCoins( - PortfolioGrowthLoadRequested event, - ) async { - final List coins = List.from(event.coins); - for (final coin in event.coins) { - final isCoinSupported = await portfolioGrowthRepository - .isCoinChartSupported(coin.id, event.fiatCoinId); - if (!isCoinSupported) { - coins.remove(coin); - } - } - return coins; + // Create periodic update stream with dynamic intervals + await _runPeriodicUpdates(event, emit); } Future _loadChart( @@ -180,8 +192,9 @@ class PortfolioGrowthBloc PortfolioGrowthLoadRequested event, { required bool useCache, }) async { - final chart = await portfolioGrowthRepository.getPortfolioGrowthChart( - coins, + final activeCoins = await coins.removeInactiveCoins(_sdk); + final chart = await _portfolioGrowthRepository.getPortfolioGrowthChart( + activeCoins, fiatCoinId: event.fiatCoinId, walletId: event.walletId, useCache: useCache, @@ -191,123 +204,161 @@ class PortfolioGrowthBloc return state; } - // Fetch prices before calculating total change - // This ensures we have the latest prices in the cache - await portfolioGrowthRepository.updatePrices(); + final totalBalance = coins.totalLastKnownUsdBalance(_sdk); + final totalChange24h = await coins.totalChange24h(_sdk); + final percentageChange24h = await coins.percentageChange24h(_sdk); - final totalBalance = _calculateTotalBalance(coins); - final totalChange24h = _calculateTotalChange24h(coins); - final percentageChange24h = _calculatePercentageChange24h(coins); + final ( + int totalCoins, + int coinsWithKnownBalance, + int coinsWithKnownBalanceAndFiat, + ) = _calculateCoinProgressCounters( + coins, + ); return PortfolioGrowthChartLoadSuccess( portfolioGrowth: chart, percentageIncrease: chart.percentageIncrease, selectedPeriod: event.selectedPeriod, totalBalance: totalBalance, - totalChange24h: totalChange24h, - percentageChange24h: percentageChange24h, + totalChange24h: totalChange24h.toDouble(), + percentageChange24h: percentageChange24h.toDouble(), + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, isUpdating: false, ); } - Future _fetchPortfolioGrowthChart( + Future<(ChartData, List)> _fetchPortfolioGrowthChart( PortfolioGrowthLoadRequested event, ) async { // Do not let transaction loading exceptions stop the periodic updates try { - final supportedCoins = await _removeUnsupportedCoins(event); - final coins = await _removeInactiveCoins(supportedCoins); - return await portfolioGrowthRepository.getPortfolioGrowthChart( + final supportedCoins = await event.coins.filterSupportedCoins( + (coin) => _portfolioGrowthRepository.isCoinChartSupported( + coin.id, + event.fiatCoinId, + ), + ); + final coins = await supportedCoins.removeInactiveCoins(_sdk); + final chart = await _portfolioGrowthRepository.getPortfolioGrowthChart( coins, fiatCoinId: event.fiatCoinId, walletId: event.walletId, useCache: false, ); + return (chart, coins); } catch (error, stackTrace) { _log.shout('Empty growth chart on periodic update', error, stackTrace); - return ChartData.empty(); + return (ChartData.empty(), []); } } - Future> _removeInactiveCoins(List coins) async { - final coinsCopy = List.of(coins); - final activeCoins = await sdk.assets.getActivatedAssets(); - final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); - for (final coin in coins) { - if (!activeCoinsMap.contains(coin.id)) { - coinsCopy.remove(coin); - } - } - return coinsCopy; - } - - PortfolioGrowthState _handlePortfolioGrowthUpdate( + Future _handlePortfolioGrowthUpdate( ChartData growthChart, Duration selectedPeriod, List coins, - ) { + ) async { if (growthChart.isEmpty && state is PortfolioGrowthChartLoadSuccess) { return state; } final percentageIncrease = growthChart.percentageIncrease; - final totalBalance = _calculateTotalBalance(coins); - final totalChange24h = _calculateTotalChange24h(coins); - final percentageChange24h = _calculatePercentageChange24h(coins); + final totalBalance = coins.totalLastKnownUsdBalance(_sdk); + final totalChange24h = await coins.totalChange24h(_sdk); + final percentageChange24h = await coins.percentageChange24h(_sdk); + + final ( + int totalCoins, + int coinsWithKnownBalance, + int coinsWithKnownBalanceAndFiat, + ) = _calculateCoinProgressCounters( + coins, + ); return PortfolioGrowthChartLoadSuccess( portfolioGrowth: growthChart, percentageIncrease: percentageIncrease, selectedPeriod: selectedPeriod, totalBalance: totalBalance, - totalChange24h: totalChange24h, - percentageChange24h: percentageChange24h, + totalChange24h: totalChange24h.toDouble(), + percentageChange24h: percentageChange24h.toDouble(), + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, isUpdating: false, ); } - /// Calculate the total balance of all coins in USD - double _calculateTotalBalance(List coins) { - double total = coins.fold( - 0, - (prev, coin) => prev + (coin.lastKnownUsdBalance(sdk) ?? 0), - ); - - // Return at least 0.01 if total is positive but very small - if (total > 0 && total < 0.01) { - return 0.01; + /// Calculate progress counters for balances and fiat prices + /// - totalCoins: total coins being considered (input list length) + /// - coinsWithKnownBalance: number of coins with a known last balance + /// - coinsWithKnownBalanceAndFiat: number of coins with a known last balance and known fiat price + (int, int, int) _calculateCoinProgressCounters(List coins) { + int totalCoins = coins.length; + int withBalance = 0; + int withBalanceAndFiat = 0; + for (final coin in coins) { + final balanceKnown = _sdk.balances.lastKnown(coin.id) != null; + if (balanceKnown) { + withBalance++; + final priceKnown = _sdk.marketData.priceIfKnown(coin.id) != null; + if (priceKnown) { + withBalanceAndFiat++; + } + } } - - return total; + return (totalCoins, withBalance, withBalanceAndFiat); } - /// Calculate the total 24h change in USD value - double _calculateTotalChange24h(List coins) { - // Calculate the 24h change by summing the change percentage of each coin - // multiplied by its USD balance and divided by 100 (to convert percentage to decimal) - return coins.fold(0.0, (sum, coin) { - // Use the price change from the CexPrice if available - final usdBalance = coin.lastKnownUsdBalance(sdk) ?? 0.0; - // Get the coin price from the repository's prices cache - final price = portfolioGrowthRepository.getCachedPrice( - coin.id.symbol.configSymbol.toUpperCase(), - ); - final change24h = price?.change24h ?? 0.0; - return sum + (change24h * usdBalance / 100); - }); - } + /// Run periodic updates with exponential backoff strategy + Future _runPeriodicUpdates( + PortfolioGrowthLoadRequested event, + Emitter emit, + ) async { + while (true) { + if (isClosed || emit.isDone) { + _log.fine('Stopping portfolio growth periodic updates: bloc closed.'); + break; + } + try { + await Future.delayed(_backoffStrategy.getNextInterval()); - /// Calculate the percentage change over 24h for the entire portfolio - double _calculatePercentageChange24h(List coins) { - final double totalBalance = _calculateTotalBalance(coins); - final double totalChange = _calculateTotalChange24h(coins); + if (isClosed || emit.isDone) { + _log.fine( + 'Skipping portfolio growth periodic update: bloc closed during delay.', + ); + break; + } - // Avoid division by zero or very small balances - if (totalBalance <= 0.01) { - return 0.0; + final (chart, coins) = await _fetchPortfolioGrowthChart(event); + emit( + await _handlePortfolioGrowthUpdate( + chart, + event.selectedPeriod, + coins, + ), + ); + } catch (error, stackTrace) { + _log.shout('Failed to load portfolio growth', error, stackTrace); + final ( + int totalCoins, + int coinsWithKnownBalance, + int coinsWithKnownBalanceAndFiat, + ) = _calculateCoinProgressCounters( + event.coins.withoutTestCoins(), + ); + emit( + GrowthChartLoadFailure( + error: TextError(error: 'Failed to load portfolio growth'), + selectedPeriod: event.selectedPeriod, + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, + ), + ); + } } - - // Return the percentage change - return (totalChange / totalBalance) * 100; } } diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart index 0aa750b24a..957d1e97fb 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart @@ -17,23 +17,15 @@ class PortfolioGrowthLoadRequested extends PortfolioGrowthEvent { required this.fiatCoinId, required this.selectedPeriod, required this.walletId, - this.updateFrequency = const Duration(minutes: 1), }); final List coins; final String fiatCoinId; final Duration selectedPeriod; final String walletId; - final Duration updateFrequency; @override - List get props => [ - coins, - fiatCoinId, - selectedPeriod, - walletId, - updateFrequency, - ]; + List get props => [coins, fiatCoinId, selectedPeriod, walletId]; } class PortfolioGrowthPeriodChanged extends PortfolioGrowthEvent { @@ -41,14 +33,12 @@ class PortfolioGrowthPeriodChanged extends PortfolioGrowthEvent { required this.selectedPeriod, required this.coins, required this.walletId, - this.updateFrequency = const Duration(minutes: 1), }); final Duration selectedPeriod; final List coins; final String walletId; - final Duration updateFrequency; @override - List get props => [selectedPeriod, coins, walletId, updateFrequency]; + List get props => [selectedPeriod, coins, walletId]; } 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 63398b5f2d..602cb5613a 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,8 +1,14 @@ import 'dart:math' show Point; +import 'package:decimal/decimal.dart'; import 'package:hive/hive.dart'; -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart' as cex; -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart' + show + CoinOhlc, + GraphInterval, + GraphIntervalExtension, + OhlcGetters, + graphIntervalsInSeconds; 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'; @@ -22,23 +28,20 @@ import 'package:web_dex/shared/utils/extensions/legacy_coin_migration_extensions class PortfolioGrowthRepository { /// Create a new instance of the repository with the provided dependencies. PortfolioGrowthRepository({ - required cex.CexRepository cexRepository, required TransactionHistoryRepo transactionHistoryRepo, required PersistenceProvider cacheProvider, required CoinsRepo coinsRepository, required KomodoDefiSdk sdk, - }) : _transactionHistoryRepository = transactionHistoryRepo, - _cexRepository = cexRepository, - _graphCache = cacheProvider, - _coinsRepository = coinsRepository, - _sdk = sdk; + }) : _transactionHistoryRepository = transactionHistoryRepo, + _graphCache = cacheProvider, + _coinsRepository = coinsRepository, + _sdk = sdk; /// Create a new instance of the repository with default dependencies. /// The default dependencies are the [BinanceRepository] and the /// [TransactionHistoryRepo]. factory PortfolioGrowthRepository.withDefaults({ required TransactionHistoryRepo transactionHistoryRepo, - required cex.CexRepository cexRepository, required CoinsRepo coinsRepository, required KomodoDefiSdk sdk, PerformanceMode? demoMode, @@ -52,7 +55,6 @@ class PortfolioGrowthRepository { } return PortfolioGrowthRepository( - cexRepository: cexRepository, transactionHistoryRepo: transactionHistoryRepo, cacheProvider: HiveLazyBoxProvider( name: GraphType.balanceGrowth.tableName, @@ -62,9 +64,6 @@ class PortfolioGrowthRepository { ); } - /// The CEX repository to fetch the spot price of the coins. - final cex.CexRepository _cexRepository; - /// The transaction history repository to fetch the transactions. final TransactionHistoryRepo _transactionHistoryRepository; @@ -123,34 +122,13 @@ class PortfolioGrowthRepository { } if (useCache) { - final cacheStopwatch = Stopwatch()..start(); - final String compoundKey = GraphCache.getPrimaryKey( - coinId: coinId.id, - fiatCoinId: fiatCoinId, - graphType: GraphType.balanceGrowth, - walletId: walletId, - isHdWallet: currentUser.isHd, + return await _tryLoadCoinGrowthChartFromCache( + coinId, + fiatCoinId, + walletId, + currentUser, + methodStopwatch, ); - final GraphCache? cachedGraph = await _graphCache.get(compoundKey); - final cacheExists = cachedGraph != null; - cacheStopwatch.stop(); - - if (cacheExists) { - _log.fine( - 'Cache hit for ${coinId.id}: ${cacheStopwatch.elapsedMilliseconds}ms', - ); - methodStopwatch.stop(); - _log.fine( - 'getCoinGrowthChart completed in ' - '${methodStopwatch.elapsedMilliseconds}ms (cached)', - ); - return cachedGraph.graph; - } else { - _log.fine( - 'Cache miss ${coinId.id}: ${cacheStopwatch.elapsedMilliseconds}ms', - ); - throw CacheMissException(compoundKey); - } } final Coin coin = _coinsRepository.getCoinFromId(coinId)!; @@ -161,17 +139,17 @@ class PortfolioGrowthRepository { .fetchCompletedTransactions(coin.id) .then((value) => value.toList()) .catchError((Object e) { - txStopwatch.stop(); - _log.warning( - 'Error fetching transactions for ${coin.id} ' - 'in ${txStopwatch.elapsedMilliseconds}ms: $e', - ); - if (ignoreTransactionFetchErrors) { - return List.empty(); - } else { - throw e; - } - }); + txStopwatch.stop(); + _log.warning( + 'Error fetching transactions for ${coin.id} ' + 'in ${txStopwatch.elapsedMilliseconds}ms: $e', + ); + if (ignoreTransactionFetchErrors) { + return List.empty(); + } else { + throw e; + } + }); txStopwatch.stop(); _log.fine( 'Fetched ${transactions.length} transactions for ${coin.id} ' @@ -215,10 +193,7 @@ class PortfolioGrowthRepository { endAt ??= DateTime.now(); final String baseCoinId = coin.id.symbol.configSymbol.toUpperCase(); - final cex.GraphInterval interval = _getOhlcInterval( - startAt, - endDate: endAt, - ); + final GraphInterval interval = _getOhlcInterval(startAt, endDate: endAt); _log.fine( 'Fetching OHLC data for $baseCoinId/$fiatCoinId ' @@ -226,32 +201,52 @@ class PortfolioGrowthRepository { ); final ohlcStopwatch = Stopwatch()..start(); - cex.CoinOhlc ohlcData; + Map ohlcData; // if the base coin is the same as the fiat coin, return a chart with a // constant value of 1.0 if (baseCoinId.toLowerCase() == fiatCoinId.toLowerCase()) { _log.fine('Using constant price for fiat coin: $baseCoinId'); - ohlcData = cex.CoinOhlc.fromConstantPrice( - startAt: startAt, - endAt: endAt, - intervalSeconds: interval.toSeconds(), + ohlcData = Map.fromIterable( + CoinOhlc.fromConstantPrice( + startAt: startAt, + endAt: endAt, + intervalSeconds: interval.toSeconds(), + ).ohlc.map( + (ohlc) => MapEntry( + DateTime.fromMillisecondsSinceEpoch(ohlc.closeTimeMs), + ohlc.close, + ), + ), ); } else { - ohlcData = await _cexRepository.getCoinOhlc( - cex.CexCoinPair(baseCoinTicker: baseCoinId, relCoinTicker: fiatCoinId), - interval, - startAt: startAt, - endAt: endAt, + final totalSecs = endAt.difference(startAt).inSeconds; + final stepSecs = interval.toSeconds(); + final steps = (totalSecs ~/ stepSecs) + 1; // include start and end + final safeSteps = steps > 0 ? steps : 1; + final dates = List.generate( + safeSteps, + (i) => startAt!.add(Duration(seconds: i * stepSecs)), + ); + + final quoteCurrency = + QuoteCurrency.fromString(fiatCoinId) ?? Stablecoin.usdt; + + ohlcData = await _sdk.marketData.fiatPriceHistory( + coinId, + dates, + quoteCurrency: quoteCurrency, ); } ohlcStopwatch.stop(); _log.fine( - 'Fetched ${ohlcData.ohlc.length} OHLC data points ' + 'Fetched ${ohlcData.length} OHLC data points ' 'in ${ohlcStopwatch.elapsedMilliseconds}ms', ); - final List> portfolowGrowthChart = - _mergeTransactionsWithOhlc(ohlcData, transactions); + final List> portfolowGrowthChart = _mergeTransactionsWithOhlc( + ohlcData, + transactions, + ); final cacheInsertStopwatch = Stopwatch()..start(); await _graphCache.insert( GraphCache( @@ -278,6 +273,43 @@ class PortfolioGrowthRepository { return portfolowGrowthChart; } + Future _tryLoadCoinGrowthChartFromCache( + AssetId coinId, + String fiatCoinId, + String walletId, + KdfUser currentUser, + Stopwatch methodStopwatch, + ) async { + final cacheStopwatch = Stopwatch()..start(); + final String compoundKey = GraphCache.getPrimaryKey( + coinId: coinId.id, + fiatCoinId: fiatCoinId, + graphType: GraphType.balanceGrowth, + walletId: walletId, + isHdWallet: currentUser.isHd, + ); + final GraphCache? cachedGraph = await _graphCache.get(compoundKey); + final cacheExists = cachedGraph != null; + cacheStopwatch.stop(); + + if (cacheExists) { + _log.fine( + 'Cache hit for ${coinId.id}: ${cacheStopwatch.elapsedMilliseconds}ms', + ); + methodStopwatch.stop(); + _log.fine( + 'getCoinGrowthChart completed in ' + '${methodStopwatch.elapsedMilliseconds}ms (cached)', + ); + return cachedGraph.graph; + } else { + _log.fine( + 'Cache miss ${coinId.id}: ${cacheStopwatch.elapsedMilliseconds}ms', + ); + throw CacheMissException(compoundKey); + } + } + /// Get the growth chart for the portfolio based on the transactions /// and the spot price of the coins in the fiat currency provided. /// @@ -364,8 +396,9 @@ class PortfolioGrowthRepository { charts.removeWhere((element) => element.isEmpty); if (charts.isEmpty) { _log.warning( - 'getPortfolioGrowthChart: No valid charts found after filtering ' - 'empty charts in ${methodStopwatch.elapsedMilliseconds}ms'); + 'getPortfolioGrowthChart: No valid charts found after filtering ' + 'empty charts in ${methodStopwatch.elapsedMilliseconds}ms', + ); return ChartData.empty(); } @@ -375,7 +408,9 @@ class PortfolioGrowthRepository { // chart matches the current prices and ends at the current time. // TODO: Move to the SDK when portfolio balance is implemented. final double totalUsdBalance = coins.fold( - 0, (prev, coin) => prev + (coin.lastKnownUsdBalance(_sdk) ?? 0)); + 0, + (prev, coin) => prev + (coin.lastKnownUsdBalance(_sdk) ?? 0), + ); if (totalUsdBalance <= 0) { _log.fine( 'Total USD balance is zero or negative, skipping balance point addition', @@ -407,8 +442,10 @@ class PortfolioGrowthRepository { ); } - final filteredChart = - mergedChart.filterDomain(startAt: startAt, endAt: endAt); + final filteredChart = mergedChart.filterDomain( + startAt: startAt, + endAt: endAt, + ); methodStopwatch.stop(); _log.fine( @@ -420,29 +457,33 @@ class PortfolioGrowthRepository { } ChartData _mergeTransactionsWithOhlc( - cex.CoinOhlc ohlcData, + Map ohlcData, List transactions, ) { final stopwatch = Stopwatch()..start(); _log.fine( 'Merging ${transactions.length} transactions with ' - '${ohlcData.ohlc.length} OHLC data points', + '${ohlcData.length} OHLC data points', ); - if (transactions.isEmpty || ohlcData.ohlc.isEmpty) { + if (transactions.isEmpty || ohlcData.isEmpty) { _log.warning('Empty transactions or OHLC data, returning empty chart'); return List.empty(); } - final ChartData spotValues = ohlcData.ohlc.map((cex.Ohlc ohlc) { + final ChartData spotValues = ohlcData.entries.map(( + MapEntry entry, + ) { return Point( - ohlc.closeTime.toDouble(), - ohlc.close, + entry.key.millisecondsSinceEpoch.toDouble(), + entry.value.toDouble(), ); }).toList(); - final portfolowGrowthChart = - Charts.mergeTransactionsWithPortfolioOHLC(transactions, spotValues); + final portfolowGrowthChart = Charts.mergeTransactionsWithPortfolioOHLC( + transactions, + spotValues, + ); stopwatch.stop(); _log.fine( @@ -469,7 +510,6 @@ class PortfolioGrowthRepository { bool allowFiatAsBase = true, }) async { final Coin coin = _coinsRepository.getCoinFromId(coinId)!; - final supportedCoins = await _cexRepository.getCoinList(); final coinTicker = coin.id.symbol.configSymbol.toUpperCase(); // Allow fiat coins through, as they are represented by a constant value, // 1, in the repository layer and are not supported by the CEX API @@ -477,12 +517,7 @@ class PortfolioGrowthRepository { return true; } - final coinPair = CexCoinPair( - baseCoinTicker: coinTicker, - relCoinTicker: fiatCoinId.toUpperCase(), - ); - final isCoinSupported = coinPair.isCoinSupported(supportedCoins); - return !coin.isTestCoin && isCoinSupported; + return !coin.isTestCoin; } /// Get the OHLC interval for the chart based on the number of transactions @@ -501,54 +536,21 @@ class PortfolioGrowthRepository { /// final interval /// = _getOhlcInterval(transactions, targetLength: 500); /// ``` - cex.GraphInterval _getOhlcInterval( + GraphInterval _getOhlcInterval( DateTime startDate, { DateTime? endDate, int targetLength = 500, }) { final DateTime lastDate = endDate ?? DateTime.now(); final duration = lastDate.difference(startDate); - final int interval = duration.inSeconds.toDouble() ~/ targetLength; - final intervalValue = cex.graphIntervalsInSeconds.entries.firstWhere( - (entry) => entry.value >= interval, - orElse: () => cex.graphIntervalsInSeconds.entries.last, + final int interval = duration.inSeconds ~/ targetLength; + final int safeInterval = interval > 0 ? interval : 1; + final intervalValue = graphIntervalsInSeconds.entries.firstWhere( + (entry) => entry.value >= safeInterval, + orElse: () => graphIntervalsInSeconds.entries.last, ); return intervalValue.key; } Future clearCache() => _graphCache.deleteAll(); - - /// Calculate the total 24h change in USD value for a list of coins - /// - /// This method fetches the current prices for all coins and calculates - /// the 24h change by multiplying each coin's percentage change by its USD balance - Future calculateTotalChange24h(List coins) async { - // Fetch current prices including 24h change data - final prices = await _coinsRepository.fetchCurrentPrices() ?? {}; - - // Calculate the 24h change by summing the change percentage of each coin - // multiplied by its USD balance and divided by 100 (to convert percentage to decimal) - double totalChange = 0.0; - for (final coin in coins) { - final price = prices[coin.id.symbol.configSymbol.toUpperCase()]; - final change24h = price?.change24h ?? 0.0; - final usdBalance = coin.lastKnownUsdBalance(_sdk) ?? 0.0; - totalChange += (change24h * usdBalance / 100); - } - return totalChange; - } - - /// Get the cached price for a given coin symbol - /// - /// This is used to avoid fetching prices for every calculation - CexPrice? getCachedPrice(String symbol) { - return _coinsRepository.getCachedPrice(symbol); - } - - /// Update prices for all coins by fetching from market data - /// - /// This method ensures we have up-to-date price data before calculations - Future updatePrices() async { - await _coinsRepository.fetchCurrentPrices(); - } } diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_state.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_state.dart index bb2553976a..f3160837bf 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_state.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_state.dart @@ -11,7 +11,7 @@ sealed class PortfolioGrowthState extends Equatable { final class PortfolioGrowthInitial extends PortfolioGrowthState { const PortfolioGrowthInitial() - : super(selectedPeriod: const Duration(hours: 1)); + : super(selectedPeriod: const Duration(hours: 1)); } final class PortfolioGrowthChartLoadSuccess extends PortfolioGrowthState { @@ -22,6 +22,9 @@ final class PortfolioGrowthChartLoadSuccess extends PortfolioGrowthState { required this.totalBalance, required this.totalChange24h, required this.percentageChange24h, + required this.totalCoins, + required this.coinsWithKnownBalance, + required this.coinsWithKnownBalanceAndFiat, this.isUpdating = false, }); @@ -30,33 +33,67 @@ final class PortfolioGrowthChartLoadSuccess extends PortfolioGrowthState { final double totalBalance; final double totalChange24h; final double percentageChange24h; + final int totalCoins; + final int coinsWithKnownBalance; + final int coinsWithKnownBalanceAndFiat; final bool isUpdating; @override List get props => [ - portfolioGrowth, - percentageIncrease, - selectedPeriod, - totalBalance, - totalChange24h, - percentageChange24h, - isUpdating, - ]; + portfolioGrowth, + percentageIncrease, + selectedPeriod, + totalBalance, + totalChange24h, + percentageChange24h, + totalCoins, + coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat, + isUpdating, + ]; } final class GrowthChartLoadFailure extends PortfolioGrowthState { const GrowthChartLoadFailure({ required this.error, required super.selectedPeriod, + required this.totalCoins, + required this.coinsWithKnownBalance, + required this.coinsWithKnownBalanceAndFiat, }); final BaseError error; + final int totalCoins; + final int coinsWithKnownBalance; + final int coinsWithKnownBalanceAndFiat; @override - List get props => [error, selectedPeriod]; + List get props => [ + error, + selectedPeriod, + totalCoins, + coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat, + ]; } final class PortfolioGrowthChartUnsupported extends PortfolioGrowthState { - const PortfolioGrowthChartUnsupported({required Duration selectedPeriod}) - : super(selectedPeriod: selectedPeriod); + const PortfolioGrowthChartUnsupported({ + required Duration selectedPeriod, + required this.totalCoins, + required this.coinsWithKnownBalance, + required this.coinsWithKnownBalanceAndFiat, + }) : super(selectedPeriod: selectedPeriod); + + final int totalCoins; + final int coinsWithKnownBalance; + final int coinsWithKnownBalanceAndFiat; + + @override + List get props => [ + selectedPeriod, + totalCoins, + coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat, + ]; } diff --git a/lib/bloc/cex_market_data/price_chart/price_chart_bloc.dart b/lib/bloc/cex_market_data/price_chart/price_chart_bloc.dart index 1412335485..87ad27b8cd 100644 --- a/lib/bloc/cex_market_data/price_chart/price_chart_bloc.dart +++ b/lib/bloc/cex_market_data/price_chart/price_chart_bloc.dart @@ -1,7 +1,10 @@ +import 'package:decimal/decimal.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:rational/rational.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/shared/utils/utils.dart'; @@ -10,18 +13,13 @@ import 'price_chart_event.dart'; import 'price_chart_state.dart'; class PriceChartBloc extends Bloc { - PriceChartBloc(this.cexPriceRepository, this.sdk) - : super(const PriceChartState()) { + PriceChartBloc(this._sdk) : super(const PriceChartState()) { on(_onStarted); on(_onIntervalChanged); on(_onSymbolChanged); } - final BinanceRepository cexPriceRepository; - final KomodoDefiSdk sdk; - final KomodoPriceRepository _komodoPriceRepository = KomodoPriceRepository( - cexPriceProvider: KomodoPriceProvider(), - ); + final KomodoDefiSdk _sdk; void _onStarted( PriceChartStarted event, @@ -29,46 +27,72 @@ class PriceChartBloc extends Bloc { ) async { emit(state.copyWith(status: PriceChartStatus.loading)); try { + // Populate available coins for the selector from SDK assets if empty Map fetchedCexCoins = state.availableCoins; - if (state.availableCoins.isEmpty) { - fetchedCexCoins = await _fetchCoinsFromCex(); + if (fetchedCexCoins.isEmpty) { + final Map allAssets = _sdk.assets.available; + final entries = allAssets.values + .where((asset) => !excludedAssetList.contains(asset.id.id)) + .where((asset) => !asset.protocol.isTestnet) + .map( + (asset) => MapEntry( + asset.id, + CoinPriceInfo( + ticker: asset.id.symbol.assetConfigId, + selectedPeriodIncreasePercentage: 0.0, + id: asset.id.id, + name: asset.id.name, + ), + ), + ); + fetchedCexCoins = Map.fromEntries(entries); } final List> futures = event.symbols - .map((symbol) => sdk.getSdkAsset(symbol).id) + .map((symbol) => _sdk.getSdkAsset(symbol).id) .map((symbol) async { - try { - final CoinOhlc ohlcData = await cexPriceRepository.getCoinOhlc( - CexCoinPair.usdtPrice(symbol.symbol.assetConfigId), - _dividePeriodToInterval(event.period), - startAt: DateTime.now().subtract(event.period), - endAt: DateTime.now(), - ); - - final rangeChangePercent = _calculatePercentageChange( - ohlcData.ohlc.firstOrNull, - ohlcData.ohlc.lastOrNull, - ); - - return PriceChartDataSeries( - info: CoinPriceInfo( - ticker: symbol.symbol.assetConfigId, - id: fetchedCexCoins[symbol]!.id, - name: fetchedCexCoins[symbol]!.name, - selectedPeriodIncreasePercentage: rangeChangePercent ?? 0.0, - ), - data: ohlcData.ohlc.map((e) { - return PriceChartSeriesPoint( - usdValue: e.close, - unixTimestamp: e.closeTime.toDouble(), + try { + final startAt = DateTime.now().subtract(event.period); + final endAt = DateTime.now(); + final interval = _dividePeriodToInterval(event.period); + + final dates = List.generate( + (endAt.difference(startAt).inSeconds / interval.toSeconds()) + .toInt(), + (index) => startAt.add( + Duration(seconds: index * interval.toSeconds()), + ), ); - }).toList(), - ); - } catch (e) { - log("Error fetching OHLC data for $symbol: $e"); - return null; - } - }).toList(); + final ohlcData = await _sdk.marketData.fiatPriceHistory( + symbol, + dates, + ); + + final rangeChangePercent = _calculatePercentageChange( + ohlcData.values.firstOrNull, + ohlcData.values.lastOrNull, + )?.toDouble(); + + return PriceChartDataSeries( + info: CoinPriceInfo( + ticker: symbol.symbol.assetConfigId, + id: fetchedCexCoins[symbol]?.id ?? symbol.id, + name: fetchedCexCoins[symbol]?.name ?? symbol.name, + selectedPeriodIncreasePercentage: rangeChangePercent ?? 0.0, + ), + data: ohlcData.entries.map((e) { + return PriceChartSeriesPoint( + usdValue: e.value.toDouble(), + unixTimestamp: e.key.millisecondsSinceEpoch.toDouble(), + ); + }).toList(), + ); + } catch (e) { + log("Error fetching OHLC data for $symbol: $e"); + return null; + } + }) + .toList(); final data = await Future.wait(futures); @@ -85,75 +109,21 @@ class PriceChartBloc extends Bloc { ); } catch (e) { emit( - state.copyWith( - status: PriceChartStatus.failure, - error: e.toString(), - ), + state.copyWith(status: PriceChartStatus.failure, error: e.toString()), ); } } - Future> _fetchCoinsFromCex() async { - final coinPrices = await _komodoPriceRepository.getKomodoPrices(); - final coins = (await cexPriceRepository.getCoinList()) - .where((coin) => coin.currencies.contains('USDT')) - // `cexPriceRepository.getCoinList()` returns coins from a CEX - // (e.g. Binance), some of which are not in our known/available - // assets/coins list. This filter ensures that we only attempt to - // fetch and display data for supported coins - .where((coin) => sdk.assets.assetsFromTicker(coin.id).isNotEmpty) - .map((coin) async { - double? dayChangePercent = coinPrices[coin.symbol]?.change24h; - - if (dayChangePercent == null) { - try { - final coinOhlc = await cexPriceRepository.getCoinOhlc( - CexCoinPair.usdtPrice(coin.symbol), - GraphInterval.oneMinute, - startAt: DateTime.now().subtract(const Duration(days: 1)), - endAt: DateTime.now(), - ); - - dayChangePercent = _calculatePercentageChange( - coinOhlc.ohlc.firstOrNull, - coinOhlc.ohlc.lastOrNull, - ); - } catch (e) { - log("Error fetching OHLC data for ${coin.symbol}: $e"); - } - } - return CoinPriceInfo( - ticker: coin.symbol, - id: coin.id, - name: coin.name, - selectedPeriodIncreasePercentage: dayChangePercent ?? 0.0, - ); - }).toList(); - - final fetchedCexCoins = { - for (var coin in await Future.wait(coins)) - sdk.getSdkAsset(coin.ticker).id: coin, - }; - - return fetchedCexCoins; - } - - double? _calculatePercentageChange(Ohlc? first, Ohlc? last) { + Rational? _calculatePercentageChange(Decimal? first, Decimal? last) { if (first == null || last == null) { return null; } - // Calculate the typical price for the first and last OHLC entries - final firstTypicalPrice = - (first.open + first.high + first.low + first.close) / 4; - final lastTypicalPrice = - (last.open + last.high + last.low + last.close) / 4; - - if (firstTypicalPrice == 0) { - return null; + if (first == Decimal.zero) { + return Rational.zero; } - return ((lastTypicalPrice - firstTypicalPrice) / firstTypicalPrice) * 100; + return ((last - first) / first) * Rational.fromInt(100); } void _onIntervalChanged( @@ -164,11 +134,7 @@ class PriceChartBloc extends Bloc { if (currentState.status != PriceChartStatus.success) { return; } - emit( - state.copyWith( - selectedPeriod: event.period, - ), - ); + emit(state.copyWith(selectedPeriod: event.period)); add( PriceChartStarted( symbols: currentState.data.map((e) => e.info.id).toList(), @@ -182,10 +148,7 @@ class PriceChartBloc extends Bloc { Emitter emit, ) { add( - PriceChartStarted( - symbols: event.symbols, - period: state.selectedPeriod, - ), + PriceChartStarted(symbols: event.symbols, period: state.selectedPeriod), ); } 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 e223f735f8..96eddc0706 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,4 +1,3 @@ -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; 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/mockup/generator.dart'; @@ -12,7 +11,6 @@ class MockProfitLossRepository extends ProfitLossRepository { MockProfitLossRepository({ required this.performanceMode, required super.transactionHistoryRepo, - required super.cexRepository, required super.profitLossCacheProvider, required super.profitLossCalculator, required super.sdk, @@ -24,21 +22,15 @@ class MockProfitLossRepository extends ProfitLossRepository { String cacheTableName = 'mock_profit_loss', }) { return MockProfitLossRepository( - profitLossCacheProvider: - HiveLazyBoxProvider(name: cacheTableName), - cexRepository: BinanceRepository( - binanceProvider: const BinanceProvider(), + profitLossCacheProvider: HiveLazyBoxProvider( + name: cacheTableName, ), performanceMode: performanceMode, transactionHistoryRepo: MockTransactionHistoryRepo( performanceMode: performanceMode, - demoDataGenerator: DemoDataCache.withDefaults(), - ), - profitLossCalculator: RealisedProfitLossCalculator( - BinanceRepository( - binanceProvider: const BinanceProvider(), - ), + demoDataGenerator: DemoDataCache.withDefaults(sdk), ), + profitLossCalculator: RealisedProfitLossCalculator(sdk), sdk: sdk, ); } 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 be9342df5d..b382058728 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 @@ -7,8 +7,10 @@ import 'package:equatable/equatable.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/bloc/cex_market_data/charts.dart'; +import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_repository.dart'; import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart'; +import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; 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'; @@ -17,8 +19,12 @@ part 'profit_loss_event.dart'; part 'profit_loss_state.dart'; class ProfitLossBloc extends Bloc { - ProfitLossBloc(this._profitLossRepository, this._sdk) - : super(const ProfitLossInitial()) { + ProfitLossBloc( + this._profitLossRepository, + this._sdk, { + UpdateFrequencyBackoffStrategy? backoffStrategy, + }) : _backoffStrategy = backoffStrategy ?? UpdateFrequencyBackoffStrategy(), + super(const ProfitLossInitial()) { // Use the restartable transformer for load events to avoid overlapping // events if the user rapidly changes the period (i.e. faster than the // previous event can complete). @@ -34,6 +40,7 @@ class ProfitLossBloc extends Bloc { final KomodoDefiSdk _sdk; final _log = Logger('ProfitLossBloc'); + final UpdateFrequencyBackoffStrategy _backoffStrategy; void _onClearPortfolioProfitLoss( ProfitLossPortfolioChartClearRequested event, @@ -47,11 +54,12 @@ class ProfitLossBloc extends Bloc { Emitter emit, ) async { try { - final supportedCoins = - await _removeUnsupportedCons(event.coins, event.fiatCoinId); + 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 && event.coins.length <= 1) { + if (supportedCoins.isEmpty && filteredEventCoins.length <= 1) { return emit( PortfolioProfitLossChartUnsupported( selectedPeriod: event.selectedPeriod, @@ -59,9 +67,11 @@ class ProfitLossBloc extends Bloc { ); } - await _getProfitLossChart(event, supportedCoins, useCache: true) - .then(emit.call) - .catchError((Object error, StackTrace stackTrace) { + await _getProfitLossChart( + event, + initialActiveCoins, + useCache: true, + ).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 @@ -69,12 +79,16 @@ class ProfitLossBloc extends Bloc { }); // Fetch the un-cached version of the chart to update the cache. - await _sdk.waitForEnabledCoinsToPassThreshold(supportedCoins); - final activeCoins = await _removeInactiveCoins(supportedCoins); + if (supportedCoins.isNotEmpty) { + await _sdk.waitForEnabledCoinsToPassThreshold(supportedCoins); + } + final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); if (activeCoins.isNotEmpty) { - await _getProfitLossChart(event, activeCoins, useCache: false) - .then(emit.call) - .catchError((Object e, StackTrace s) { + await _getProfitLossChart( + event, + activeCoins, + 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 @@ -88,19 +102,11 @@ class ProfitLossBloc extends Bloc { // recover at the cost of a longer first loading time. } - await emit.forEach( - Stream.periodic(event.updateFrequency).asyncMap( - (_) async => _getProfitLossChart(event, event.coins, useCache: false), - ), - onData: (ProfitLossState updatedChartState) => updatedChartState, - onError: (e, s) { - _log.shout('Failed to load portfolio profit/loss', e, s); - return ProfitLossLoadFailure( - error: TextError(error: 'Failed to load portfolio profit/loss'), - selectedPeriod: event.selectedPeriod, - ); - }, - ); + // Reset backoff strategy for new load request + _backoffStrategy.reset(); + + // Create periodic update stream with dynamic intervals + await _runPeriodicUpdates(event, emit); } Future _getProfitLossChart( @@ -113,6 +119,7 @@ class ProfitLossBloc extends Bloc { try { final filteredChart = await _getSortedProfitLossChartForCoins( event, + coins, useCache: useCache, ); final unCachedProfitIncrease = filteredChart.increase; @@ -133,23 +140,6 @@ class ProfitLossBloc extends Bloc { } } - Future> _removeUnsupportedCons( - List walletCoins, - String fiatCoinId, - ) async { - final coins = List.of(walletCoins); - for (final coin in coins) { - final isCoinSupported = await _profitLossRepository.isCoinChartSupported( - coin.id, - fiatCoinId, - ); - if (coin.isTestCoin || !isCoinSupported) { - coins.remove(coin); - } - } - return coins; - } - Future _onPortfolioPeriodChanged( ProfitLossPortfolioPeriodChanged event, Emitter emit, @@ -187,7 +177,8 @@ class ProfitLossBloc extends Bloc { } Future _getSortedProfitLossChartForCoins( - ProfitLossPortfolioChartLoadRequested event, { + ProfitLossPortfolioChartLoadRequested event, + List coins, { bool useCache = true, }) async { if (!await _sdk.auth.isSignedIn()) { @@ -195,8 +186,18 @@ class ProfitLossBloc extends Bloc { return ChartData.empty(); } + final supportedCoins = await coins.filterSupportedCoins(); + if (supportedCoins.isEmpty) { + _log.warning('No supported coins to load profit/loss chart for'); + return ChartData.empty(); + } + final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); + if (activeCoins.isEmpty) { + _log.warning('No active coins to load profit/loss chart for'); + return ChartData.empty(); + } final chartsList = await Future.wait( - event.coins.map((coin) async { + activeCoins.map((coin) async { // Catch any errors and return an empty chart to prevent a single coin // from breaking the entire portfolio chart. try { @@ -207,15 +208,17 @@ class ProfitLossBloc extends Bloc { useCache: useCache, ); - final firstNonZeroProfitLossIndex = - profitLosses.indexWhere((element) => element.profitLoss != 0); + final firstNonZeroProfitLossIndex = profitLosses.indexWhere( + (element) => element.profitLoss != 0, + ); if (firstNonZeroProfitLossIndex == -1) { _log.info('No non-zero profit/loss data found for ${coin.abbr}'); return ChartData.empty(); } - final nonZeroProfitLosses = - profitLosses.sublist(firstNonZeroProfitLossIndex); + final nonZeroProfitLosses = profitLosses.sublist( + firstNonZeroProfitLossIndex, + ); return nonZeroProfitLosses.toChartData(); } catch (e, s) { final cached = useCache ? 'cached' : 'uncached'; @@ -229,15 +232,43 @@ class ProfitLossBloc extends Bloc { return Charts.merge(chartsList)..sort((a, b) => a.x.compareTo(b.x)); } - Future> _removeInactiveCoins(List coins) async { - final coinsCopy = List.of(coins); - final activeCoins = await _sdk.assets.getActivatedAssets(); - final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); - for (final coin in coins) { - if (!activeCoinsMap.contains(coin.id)) { - coinsCopy.remove(coin); + /// Run periodic updates with exponential backoff strategy + Future _runPeriodicUpdates( + ProfitLossPortfolioChartLoadRequested event, + Emitter emit, + ) async { + while (true) { + if (isClosed || emit.isDone) { + _log.fine('Stopping profit/loss periodic updates: bloc closed.'); + break; + } + try { + await Future.delayed(_backoffStrategy.getNextInterval()); + + if (isClosed || emit.isDone) { + _log.fine( + 'Skipping profit/loss periodic update: bloc closed during delay.', + ); + break; + } + + final supportedCoins = await event.coins.filterSupportedCoins(); + final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); + final updatedChartState = await _getProfitLossChart( + event, + activeCoins, + useCache: false, + ); + emit(updatedChartState); + } catch (error, stackTrace) { + _log.shout('Failed to load portfolio profit/loss', error, stackTrace); + emit( + ProfitLossLoadFailure( + error: TextError(error: 'Failed to load portfolio profit/loss'), + selectedPeriod: event.selectedPeriod, + ), + ); } } - return coinsCopy; } } diff --git a/lib/bloc/cex_market_data/profit_loss/profit_loss_calculator.dart b/lib/bloc/cex_market_data/profit_loss/profit_loss_calculator.dart index 41b02d2171..a8128968c3 100644 --- a/lib/bloc/cex_market_data/profit_loss/profit_loss_calculator.dart +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_calculator.dart @@ -1,12 +1,16 @@ -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +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:logging/logging.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/extensions/profit_loss_transaction_extension.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/models/price_stamped_transaction.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/models/profit_loss.dart'; class ProfitLossCalculator { - ProfitLossCalculator(this._cexRepository); - final CexRepository _cexRepository; + ProfitLossCalculator(this._sdk); + + final KomodoDefiSdk _sdk; + final Logger _log = Logger('ProfitLossCalculator'); /// Get the running profit/loss for a coin based on the transactions. /// ProfitLoss = Proceeds - CostBasis @@ -22,7 +26,7 @@ class ProfitLossCalculator { /// Returns the list of [ProfitLoss] for the coin. Future> getProfitFromTransactions( List transactions, { - required String coinId, + required AssetId coinId, required String fiatCoinId, }) async { if (transactions.isEmpty) { @@ -33,23 +37,35 @@ class ProfitLossCalculator { final todayAtMidnight = _getDateAtMidnight(DateTime.now()); final transactionDates = _getTransactionDates(transactions); - final coinUsdPrices = - await _getTimestampedUsdPrices(coinId, transactionDates); + final coinUsdPrices = await _sdk.marketData.fiatPriceHistory( + coinId, + transactionDates, + ); final currentPrice = coinUsdPrices[todayAtMidnight] ?? coinUsdPrices.values.last; - final priceStampedTransactions = - _priceStampTransactions(transactions, coinUsdPrices); + final priceStampedTransactions = _priceStampTransactions( + transactions, + coinUsdPrices, + ); return _calculateProfitLosses(priceStampedTransactions, currentPrice); } List _priceStampTransactions( List transactions, - Map usdPrices, + Map usdPrices, ) { return transactions.map((transaction) { - final usdPrice = usdPrices[_getDateAtMidnight(transaction.timestamp)]!; - return UsdPriceStampedTransaction(transaction, usdPrice); + final DateTime midnightDate = _getDateAtMidnight(transaction.timestamp); + final Decimal? usdPrice = usdPrices[midnightDate]; + if (usdPrice == null) { + _log.warning( + 'No USD price found for transaction ${transaction.id} ' + 'at $midnightDate. Available prices: ${usdPrices.keys}', + ); + throw Exception('No USD price found for transaction ${transaction.id}'); + } + return UsdPriceStampedTransaction(transaction, usdPrice.toDouble()); }).toList(); } @@ -58,20 +74,13 @@ class ProfitLossCalculator { } DateTime _getDateAtMidnight(DateTime date) { - return DateTime(date.year, date.month, date.day); - } - - Future> _getTimestampedUsdPrices( - String coinId, - List dates, - ) async { - final cleanCoinId = coinId.split('-').firstOrNull?.toUpperCase() ?? ''; - return _cexRepository.getCoinFiatPrices(cleanCoinId, dates); + final utcDate = date.toUtc(); + return DateTime.utc(utcDate.year, utcDate.month, utcDate.day); } List _calculateProfitLosses( List transactions, - double currentPrice, + Decimal currentPrice, ) { var state = _ProfitLossState(); final profitLosses = []; @@ -102,8 +111,10 @@ class ProfitLossCalculator { _ProfitLossState state, UsdPriceStampedTransaction transaction, ) { - final newHolding = - (holdings: transaction.amount.toDouble(), price: transaction.priceUsd); + final newHolding = ( + holdings: transaction.amount.toDouble(), + price: transaction.priceUsd, + ); return _ProfitLossState( holdings: [...state.holdings, newHolding], realizedProfitLoss: state.realizedProfitLoss, @@ -124,8 +135,9 @@ class ProfitLossCalculator { // calculate the cost basis (formula assumes positive "total" value). var remainingToSell = transaction.amount.toDouble().abs(); var costBasis = 0.0; - final newHoldings = - List<({double holdings, double price})>.from(state.holdings); + final newHoldings = List<({double holdings, double price})>.from( + state.holdings, + ); while (remainingToSell > 0) { final oldestBuy = newHoldings.first.holdings; @@ -136,7 +148,7 @@ class ProfitLossCalculator { } else { newHoldings[0] = ( holdings: newHoldings[0].holdings - remainingToSell, - price: newHoldings[0].price + price: newHoldings[0].price, ); costBasis += remainingToSell * state.holdings.first.price; remainingToSell = 0; @@ -161,37 +173,28 @@ class ProfitLossCalculator { ); } - double _calculateProfitLoss( - _ProfitLossState state, - double currentPrice, - ) { - final currentValue = state.currentHoldings * currentPrice; + double _calculateProfitLoss(_ProfitLossState state, Decimal currentPrice) { + final currentValue = state.currentHoldings * currentPrice.toDouble(); final unrealizedProfitLoss = currentValue - state.totalInvestment; return state.realizedProfitLoss + unrealizedProfitLoss; } } class RealisedProfitLossCalculator extends ProfitLossCalculator { - RealisedProfitLossCalculator(super.cexRepository); + RealisedProfitLossCalculator(super._sdk); @override - double _calculateProfitLoss( - _ProfitLossState state, - double currentPrice, - ) { + double _calculateProfitLoss(_ProfitLossState state, Decimal currentPrice) { return state.realizedProfitLoss; } } class UnRealisedProfitLossCalculator extends ProfitLossCalculator { - UnRealisedProfitLossCalculator(super.cexRepository); + UnRealisedProfitLossCalculator(super._sdk); @override - double _calculateProfitLoss( - _ProfitLossState state, - double currentPrice, - ) { - final currentValue = state.currentHoldings * currentPrice; + double _calculateProfitLoss(_ProfitLossState state, Decimal currentPrice) { + final currentValue = state.currentHoldings * currentPrice.toDouble(); final unrealizedProfitLoss = currentValue - state.totalInvestment; return unrealizedProfitLoss; } diff --git a/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart b/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart index 14ff2ff6da..f6ff6591d0 100644 --- a/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart @@ -17,13 +17,11 @@ class ProfitLossPortfolioChartLoadRequested extends ProfitLossEvent { required this.fiatCoinId, required this.selectedPeriod, required this.walletId, - this.updateFrequency = const Duration(minutes: 1), }); final List coins; final String fiatCoinId; final Duration selectedPeriod; - final Duration updateFrequency; final String walletId; @override @@ -32,19 +30,16 @@ class ProfitLossPortfolioChartLoadRequested extends ProfitLossEvent { fiatCoinId, selectedPeriod, walletId, - updateFrequency, ]; } class ProfitLossPortfolioPeriodChanged extends ProfitLossEvent { const ProfitLossPortfolioPeriodChanged({ required this.selectedPeriod, - this.updateFrequency = const Duration(minutes: 1), }); final Duration selectedPeriod; - final Duration updateFrequency; @override - List get props => [selectedPeriod, updateFrequency]; + List get props => [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 ec47cdb7c0..05561e6fd7 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 @@ -2,8 +2,6 @@ import 'dart:async'; import 'dart:math'; import 'package:hive/hive.dart'; -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart' as cex; -import 'package:komodo_cex_market_data/komodo_cex_market_data.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'; @@ -20,23 +18,20 @@ import 'package:web_dex/bloc/transaction_history/transaction_history_repo.dart'; class ProfitLossRepository { ProfitLossRepository({ required PersistenceProvider - profitLossCacheProvider, - required cex.CexRepository cexRepository, + profitLossCacheProvider, required TransactionHistoryRepo transactionHistoryRepo, required ProfitLossCalculator profitLossCalculator, required KomodoDefiSdk sdk, - }) : _transactionHistoryRepo = transactionHistoryRepo, - _cexRepository = cexRepository, - _profitLossCacheProvider = profitLossCacheProvider, - _profitLossCalculator = profitLossCalculator, - _sdk = sdk; + }) : _transactionHistoryRepo = transactionHistoryRepo, + _profitLossCacheProvider = profitLossCacheProvider, + _profitLossCalculator = profitLossCalculator, + _sdk = sdk; /// Return a new instance of [ProfitLossRepository] with default values. /// /// If [demoMode] is provided, it will return a [MockProfitLossRepository]. factory ProfitLossRepository.withDefaults({ required TransactionHistoryRepo transactionHistoryRepo, - required cex.CexRepository cexRepository, required KomodoDefiSdk sdk, String cacheTableName = 'profit_loss', PerformanceMode? demoMode, @@ -51,16 +46,15 @@ class ProfitLossRepository { return ProfitLossRepository( transactionHistoryRepo: transactionHistoryRepo, - profitLossCacheProvider: - HiveLazyBoxProvider(name: cacheTableName), - cexRepository: cexRepository, - profitLossCalculator: RealisedProfitLossCalculator(cexRepository), + profitLossCacheProvider: HiveLazyBoxProvider( + name: cacheTableName, + ), + profitLossCalculator: RealisedProfitLossCalculator(sdk), sdk: sdk, ); } final PersistenceProvider _profitLossCacheProvider; - final cex.CexRepository _cexRepository; final TransactionHistoryRepo _transactionHistoryRepo; final ProfitLossCalculator _profitLossCalculator; final KomodoDefiSdk _sdk; @@ -86,61 +80,6 @@ class ProfitLossRepository { ); } - /// Check if the coin is supported by the CEX API for charting. - /// This is used to filter out unsupported coins from the chart. - /// - /// [coinId] is the coin to check. - /// [fiatCoinId] is the fiat coin id to convert the coin to. - /// [allowFiatAsBase] is a flag to allow fiat coins as the base coin, - /// without checking if they are supported by the CEX API. - /// - /// Returns `true` if the coin is supported by the CEX API for charting. - /// Returns `false` if the coin is not supported by the CEX API for charting. - Future isCoinChartSupported( - AssetId coinId, - String fiatCoinId, { - bool allowFiatAsBase = false, - }) async { - final stopwatch = Stopwatch()..start(); - final coinTicker = coinId.symbol.configSymbol.toUpperCase(); - _log.fine( - 'Checking if coin $coinTicker is supported for profit/loss calculation', - ); - - final supportedCoinsStopwatch = Stopwatch()..start(); - final supportedCoins = await _cexRepository.getCoinList(); - supportedCoinsStopwatch.stop(); - _log.fine( - 'Fetched ${supportedCoins.length} supported coins in ' - '${supportedCoinsStopwatch.elapsedMilliseconds}ms', - ); - - // Allow fiat coins through, as they are represented by a constant value, - // 1, in the repository layer and are not supported by the CEX API - if (allowFiatAsBase && coinId.id == fiatCoinId.toUpperCase()) { - stopwatch.stop(); - _log.fine( - 'Coin $coinTicker is a fiat coin, supported: true ' - '(total: ${stopwatch.elapsedMilliseconds}ms)', - ); - return true; - } - - final coinPair = CexCoinPair( - baseCoinTicker: coinTicker, - relCoinTicker: fiatCoinId.toUpperCase(), - ); - final isSupported = coinPair.isCoinSupported(supportedCoins); - - stopwatch.stop(); - _log.fine( - 'Coin $coinTicker support check completed in ' - '${stopwatch.elapsedMilliseconds}ms, supported: $isSupported', - ); - - return isSupported; - } - /// Get the profit/loss data for a coin based on the transactions /// and the spot price of the coin in the fiat currency. /// @@ -187,8 +126,8 @@ class ProfitLossRepository { walletId: walletId, isHdWallet: currentUser.isHd, ); - final ProfitLossCache? profitLossCache = - await _profitLossCacheProvider.get(compoundKey); + final ProfitLossCache? profitLossCache = await _profitLossCacheProvider + .get(compoundKey); final bool cacheExists = profitLossCache != null; cacheStopwatch.stop(); @@ -207,26 +146,10 @@ class ProfitLossRepository { ); } - final supportCheckStopwatch = Stopwatch()..start(); - final isCoinSupported = await isCoinChartSupported( - coinId, - fiatCoinId, - ); - supportCheckStopwatch.stop(); - - if (!isCoinSupported) { - _log.fine( - 'Coin ${coinId.id} is not supported for profit/loss calculation ' - '(checked in ${supportCheckStopwatch.elapsedMilliseconds}ms)', - ); - methodStopwatch.stop(); - return []; - } - final txStopwatch = Stopwatch()..start(); _log.fine('Fetching transactions for ${coinId.id}'); - final transactions = - await _transactionHistoryRepo.fetchCompletedTransactions(coinId); + final transactions = await _transactionHistoryRepo + .fetchCompletedTransactions(coinId); txStopwatch.stop(); _log.fine( 'Fetched ${transactions.length} transactions for ${coinId.id} ' @@ -260,12 +183,12 @@ class ProfitLossRepository { _log.fine( 'Calculating profit/loss for ${coinId.id} with ${transactions.length} transactions', ); - final List profitLosses = - await _profitLossCalculator.getProfitFromTransactions( - transactions, - coinId: coinId.id, - fiatCoinId: fiatCoinId, - ); + final List profitLosses = await _profitLossCalculator + .getProfitFromTransactions( + transactions, + coinId: coinId, + fiatCoinId: fiatCoinId, + ); calcStopwatch.stop(); _log.fine( 'Calculated ${profitLosses.length} profit/loss entries for ${coinId.id} ' diff --git a/lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart b/lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart index 7501636e27..bf43c9aa80 100644 --- a/lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart +++ b/lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + 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' show NewAddressStatus; +import 'package:komodo_defi_types/komodo_defi_types.dart' + show Asset, NewAddressStatus, AssetPubkeys; 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'; @@ -11,18 +14,27 @@ class CoinAddressesBloc extends Bloc { final KomodoDefiSdk sdk; final String assetId; final AnalyticsBloc analyticsBloc; - CoinAddressesBloc( - this.sdk, - this.assetId, - this.analyticsBloc, - ) : super(const CoinAddressesState()) { - on(_onSubmitCreateAddress); - on(_onLoadAddresses); - on(_onUpdateHideZeroBalance); + + StreamSubscription? _pubkeysSub; + CoinAddressesBloc(this.sdk, this.assetId, this.analyticsBloc) + : super(const CoinAddressesState()) { + on(_onCreateAddressSubmitted); + on(_onStarted); + on(_onAddressesSubscriptionRequested); + on(_onHideZeroBalanceChanged); + on(_onPubkeysUpdated); + on(_onPubkeysSubscriptionFailed); + } + + Future _onStarted( + CoinAddressesStarted event, + Emitter emit, + ) async { + add(const CoinAddressesSubscriptionRequested()); } - Future _onSubmitCreateAddress( - SubmitCreateAddressEvent event, + Future _onCreateAddressSubmitted( + CoinAddressesAddressCreationSubmitted event, Emitter emit, ) async { emit( @@ -31,62 +43,76 @@ class CoinAddressesBloc extends Bloc { newAddressState: () => null, ), ); + try { + final asset = getSdkAsset(sdk, assetId); + final stream = sdk.pubkeys.watchCreateNewPubkey(asset); - final stream = sdk.pubkeys.createNewPubkeyStream(getSdkAsset(sdk, assetId)); - - await for (final newAddressState in stream) { - emit(state.copyWith(newAddressState: () => newAddressState)); - - switch (newAddressState.status) { - case NewAddressStatus.completed: - final pubkey = newAddressState.address; - final derivation = pubkey?.derivationPath; - if (derivation != null) { - final parsed = parseDerivationPath(derivation); - analyticsBloc.logEvent( - HdAddressGeneratedEventData( - accountIndex: parsed.accountIndex, - addressIndex: parsed.addressIndex, - assetSymbol: assetId, - ), - ); - } + await for (final newAddressState in stream) { + emit(state.copyWith(newAddressState: () => newAddressState)); - add(const LoadAddressesEvent()); + switch (newAddressState.status) { + case NewAddressStatus.completed: + final pubkey = newAddressState.address; + final derivation = pubkey?.derivationPath; + if (derivation != null) { + try { + final parsed = parseDerivationPath(derivation); + analyticsBloc.logEvent( + HdAddressGeneratedEventData( + accountIndex: parsed.accountIndex, + addressIndex: parsed.addressIndex, + asset: assetId, + ), + ); + } catch (_) { + // Non-fatal: continue without analytics if derivation parsing fails + } + } - emit( - state.copyWith( - createAddressStatus: () => FormStatus.success, - newAddressState: () => null, - ), - ); - return; - case NewAddressStatus.error: - emit( - state.copyWith( - createAddressStatus: () => FormStatus.failure, - errorMessage: () => newAddressState.error, - newAddressState: () => null, - ), - ); - return; - case NewAddressStatus.cancelled: - emit( - state.copyWith( - createAddressStatus: () => FormStatus.initial, - newAddressState: () => null, - ), - ); - return; - default: - // continue listening for next events - break; + add(const CoinAddressesSubscriptionRequested()); + + emit( + state.copyWith( + createAddressStatus: () => FormStatus.success, + newAddressState: () => null, + ), + ); + return; + case NewAddressStatus.error: + emit( + state.copyWith( + createAddressStatus: () => FormStatus.failure, + errorMessage: () => newAddressState.error, + newAddressState: () => null, + ), + ); + return; + case NewAddressStatus.cancelled: + emit( + state.copyWith( + createAddressStatus: () => FormStatus.initial, + newAddressState: () => null, + ), + ); + return; + default: + // continue listening for next events + break; + } } + } catch (e) { + emit( + state.copyWith( + createAddressStatus: () => FormStatus.failure, + errorMessage: () => e.toString(), + newAddressState: () => null, + ), + ); } } - Future _onLoadAddresses( - LoadAddressesEvent event, + Future _onAddressesSubscriptionRequested( + CoinAddressesSubscriptionRequested event, Emitter emit, ) async { emit(state.copyWith(status: () => FormStatus.submitting)); @@ -102,8 +128,11 @@ class CoinAddressesBloc extends Bloc { status: () => FormStatus.success, addresses: () => addresses, cantCreateNewAddressReasons: () => reasons, + errorMessage: () => null, ), ); + + await _startWatchingPubkeys(asset); } catch (e) { emit( state.copyWith( @@ -114,10 +143,79 @@ class CoinAddressesBloc extends Bloc { } } - void _onUpdateHideZeroBalance( - UpdateHideZeroBalanceEvent event, + void _onHideZeroBalanceChanged( + CoinAddressesZeroBalanceVisibilityChanged event, Emitter emit, ) { emit(state.copyWith(hideZeroBalance: () => event.hideZeroBalance)); } + + Future _onPubkeysUpdated( + CoinAddressesPubkeysUpdated event, + Emitter emit, + ) async { + try { + final asset = getSdkAsset(sdk, assetId); + final reasons = await asset.getCantCreateNewAddressReasons(sdk); + emit( + state.copyWith( + status: () => FormStatus.success, + addresses: () => event.addresses, + cantCreateNewAddressReasons: () => reasons, + errorMessage: () => null, + ), + ); + } catch (e) { + emit(state.copyWith(errorMessage: () => e.toString())); + } + } + + void _onPubkeysSubscriptionFailed( + CoinAddressesPubkeysSubscriptionFailed event, + Emitter emit, + ) { + emit( + state.copyWith( + status: () => FormStatus.failure, + errorMessage: () => event.error, + ), + ); + } + + Future _startWatchingPubkeys(Asset asset) async { + try { + await _pubkeysSub?.cancel(); + _pubkeysSub = null; + // Pre-cache pubkeys to ensure that any newly created pubkeys are available + // when we start watching. UI flickering between old and new states is + // avoided this way. The watchPubkeys function yields the last known pubkeys + // when the pubkeys stream is first activated. + await sdk.pubkeys.precachePubkeys(asset); + _pubkeysSub = sdk.pubkeys + .watchPubkeys(asset, activateIfNeeded: true) + .listen( + (AssetPubkeys assetPubkeys) { + if (!isClosed) { + add(CoinAddressesPubkeysUpdated(assetPubkeys.keys)); + } + }, + onError: (Object err) { + if (!isClosed) { + add(CoinAddressesPubkeysSubscriptionFailed(err.toString())); + } + }, + ); + } catch (e) { + if (!isClosed) { + add(CoinAddressesPubkeysSubscriptionFailed(e.toString())); + } + } + } + + @override + Future close() async { + await _pubkeysSub?.cancel(); + _pubkeysSub = null; + return super.close(); + } } diff --git a/lib/bloc/coin_addresses/bloc/coin_addresses_event.dart b/lib/bloc/coin_addresses/bloc/coin_addresses_event.dart index 391396d142..3fa8979145 100644 --- a/lib/bloc/coin_addresses/bloc/coin_addresses_event.dart +++ b/lib/bloc/coin_addresses/bloc/coin_addresses_event.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart' show PubkeyInfo; abstract class CoinAddressesEvent extends Equatable { const CoinAddressesEvent(); @@ -7,19 +8,41 @@ abstract class CoinAddressesEvent extends Equatable { List get props => []; } -class SubmitCreateAddressEvent extends CoinAddressesEvent { - const SubmitCreateAddressEvent(); +class CoinAddressesAddressCreationSubmitted extends CoinAddressesEvent { + const CoinAddressesAddressCreationSubmitted(); } -class LoadAddressesEvent extends CoinAddressesEvent { - const LoadAddressesEvent(); +class CoinAddressesStarted extends CoinAddressesEvent { + const CoinAddressesStarted(); } -class UpdateHideZeroBalanceEvent extends CoinAddressesEvent { +class CoinAddressesSubscriptionRequested extends CoinAddressesEvent { + const CoinAddressesSubscriptionRequested(); +} + +class CoinAddressesZeroBalanceVisibilityChanged extends CoinAddressesEvent { final bool hideZeroBalance; - const UpdateHideZeroBalanceEvent(this.hideZeroBalance); + const CoinAddressesZeroBalanceVisibilityChanged(this.hideZeroBalance); @override List get props => [hideZeroBalance]; } + +/// Emitted when the pubkeys watcher emits an updated set of keys (and balances) +class CoinAddressesPubkeysUpdated extends CoinAddressesEvent { + final List addresses; + const CoinAddressesPubkeysUpdated(this.addresses); + + @override + List get props => [addresses]; +} + +/// Emitted when the pubkeys watcher reports an error +class CoinAddressesPubkeysSubscriptionFailed extends CoinAddressesEvent { + final String error; + const CoinAddressesPubkeysSubscriptionFailed(this.error); + + @override + List get props => [error]; +} diff --git a/lib/bloc/coins_bloc/asset_coin_extension.dart b/lib/bloc/coins_bloc/asset_coin_extension.dart index 58cc78fb75..f1eef13e7c 100644 --- a/lib/bloc/coins_bloc/asset_coin_extension.dart +++ b/lib/bloc/coins_bloc/asset_coin_extension.dart @@ -2,9 +2,12 @@ import 'package:decimal/decimal.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:rational/rational.dart' show Rational; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/shared/utils/extensions/collection_extensions.dart'; +import 'package:web_dex/shared/utils/extensions/legacy_coin_migration_extensions.dart'; extension AssetCoinExtension on Asset { Coin toCoin() { @@ -15,7 +18,7 @@ extension AssetCoinExtension on Asset { final logoImageUrl = config.valueOrNull('logo_image_url'); final isCustomToken = (config.valueOrNull('is_custom_token') ?? false) || - logoImageUrl != null; + logoImageUrl != null; final ProtocolData protocolData = ProtocolData( platform: id.parentId?.id ?? platform ?? '', @@ -38,8 +41,9 @@ extension AssetCoinExtension on Asset { isTestCoin: protocol.isTestnet, coingeckoId: id.symbol.coinGeckoId, swapContractAddress: config.valueOrNull('swap_contract_address'), - fallbackSwapContract: - config.valueOrNull('fallback_swap_contract'), + fallbackSwapContract: config.valueOrNull( + 'fallback_swap_contract', + ), priority: priorityCoinsAbbrMap[id.id] ?? 0, state: CoinState.inactive, walletOnly: config.valueOrNull('wallet_only') ?? false, @@ -49,8 +53,11 @@ extension AssetCoinExtension on Asset { ); } - String? get contractAddress => protocol.config - .valueOrNull('protocol', 'protocol_data', 'contract_address'); + String? get contractAddress => protocol.config.valueOrNull( + 'protocol', + 'protocol_data', + 'contract_address', + ); String? get platform => protocol.config.valueOrNull('protocol', 'protocol_data', 'platform'); } @@ -96,6 +103,8 @@ extension CoinTypeExtension on CoinSubClass { return CoinType.erc20; case CoinSubClass.krc20: return CoinType.krc20; + case CoinSubClass.zhtlc: + return CoinType.zhtlc; default: return CoinType.utxo; } @@ -166,6 +175,8 @@ extension CoinSubClassExtension on CoinType { return CoinSubClass.erc20; case CoinType.krc20: return CoinSubClass.krc20; + case CoinType.zhtlc: + return CoinSubClass.zhtlc; } } } @@ -201,10 +212,7 @@ extension AssetBalanceExtension on Coin { KomodoDefiSdk sdk, { bool activateIfNeeded = true, }) { - return sdk.balances.watchBalance( - id, - activateIfNeeded: activateIfNeeded, - ); + return sdk.balances.watchBalance(id, activateIfNeeded: activateIfNeeded); } /// Get the last-known balance for this coin. @@ -227,3 +235,109 @@ extension AssetBalanceExtension on Coin { return (balance * price).spendable.toDouble(); } } + +extension AssetListOps on List { + Future> removeInactiveAssets(KomodoDefiSdk sdk) async { + final activeAssets = await sdk.assets.getActivatedAssets(); + final activeAssetsMap = activeAssets.map((e) => e.id).toSet(); + + return where( + (asset) => activeAssetsMap.contains(asset.id), + ).unmodifiable().toList(); + } + + Future> removeActiveAssets(KomodoDefiSdk sdk) async { + final activeAssets = await sdk.assets.getActivatedAssets(); + final activeAssetsMap = activeAssets.map((e) => e.id).toSet(); + + return where( + (asset) => !activeAssetsMap.contains(asset.id), + ).unmodifiable().toList(); + } +} + +extension CoinSupportOps on Iterable { + /// Returns a list excluding test coins. Useful when filtering coins before + /// running portfolio calculations that assume production assets only. + List withoutTestCoins() => + where((coin) => !coin.isTestCoin).unmodifiable().toList(); + + /// Filters out unsupported coins by first removing test coins and then + /// evaluating the optional [isSupported] predicate. When the predicate is not + /// provided, only test coins are removed. + Future> filterSupportedCoins([ + Future Function(Coin coin)? isSupported, + ]) async { + final predicate = isSupported ?? _alwaysSupported; + final supportedCoins = []; + for (final coin in this) { + if (coin.isTestCoin) continue; + if (await predicate(coin)) { + supportedCoins.add(coin); + } + } + return supportedCoins.unmodifiable().toList(); + } + + static Future _alwaysSupported(Coin _) async => true; + + Future> removeInactiveCoins(KomodoDefiSdk sdk) async { + final activeCoins = await sdk.assets.getActivatedAssets(); + final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); + + return where( + (coin) => activeCoinsMap.contains(coin.id), + ).unmodifiable().toList(); + } + + Future> removeActiveCoins(KomodoDefiSdk sdk) async { + final activeCoins = await sdk.assets.getActivatedAssets(); + final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); + + return where( + (coin) => !activeCoinsMap.contains(coin.id), + ).unmodifiable().toList(); + } + + double totalLastKnownUsdBalance(KomodoDefiSdk sdk) { + double total = fold( + 0.00, + (prev, coin) => prev + (coin.lastKnownUsdBalance(sdk) ?? 0), + ); + + // Return at least 0.01 if total is positive but very small + if (total > 0 && total < 0.01) { + return 0.01; + } + + return total; + } + + Future totalChange24h(KomodoDefiSdk sdk) async { + Rational totalChange = Rational.zero; + for (final coin in this) { + final double usdBalance = coin.lastKnownUsdBalance(sdk) ?? 0.0; + final usdBalanceDecimal = Decimal.parse(usdBalance.toString()); + final change24h = + await sdk.marketData.priceChange24h(coin.id) ?? Decimal.zero; + totalChange += change24h * usdBalanceDecimal / Decimal.fromInt(100); + } + return totalChange; + } + + Future percentageChange24h(KomodoDefiSdk sdk) async { + final double totalBalance = totalLastKnownUsdBalance(sdk); + final Rational totalBalanceRational = Rational.parse( + totalBalance.toString(), + ); + final Rational totalChange = await totalChange24h(sdk); + + // Avoid division by zero or very small balances + if (totalBalanceRational <= Rational.fromInt(1, 100)) { + return Rational.zero; + } + + // Return the percentage change + return (totalChange / totalBalanceRational) * Rational.fromInt(100); + } +} diff --git a/lib/bloc/coins_bloc/coins_bloc.dart b/lib/bloc/coins_bloc/coins_bloc.dart index 3981424fe3..928121ea39 100644 --- a/lib/bloc/coins_bloc/coins_bloc.dart +++ b/lib/bloc/coins_bloc/coins_bloc.dart @@ -3,15 +3,16 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/foundation.dart' show mapEquals; 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/app_config/app_config.dart'; +import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +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/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -20,10 +21,8 @@ part 'coins_state.dart'; /// Responsible for coin activation, deactivation, syncing, and fiat price class CoinsBloc extends Bloc { - CoinsBloc( - this._kdfSdk, - this._coinsRepo, - ) : super(CoinsState.initial()) { + CoinsBloc(this._kdfSdk, this._coinsRepo, this._tradingStatusService) + : super(CoinsState.initial()) { on(_onCoinsStarted, transformer: droppable()); // TODO: move auth listener to ui layer: bloclistener should fire auth events on(_onCoinsBalanceMonitoringStarted); @@ -43,12 +42,14 @@ class CoinsBloc extends Bloc { final KomodoDefiSdk _kdfSdk; final CoinsRepo _coinsRepo; + final TradingStatusService _tradingStatusService; final _log = Logger('CoinsBloc'); StreamSubscription? _enabledCoinsSubscription; Timer? _updateBalancesTimer; Timer? _updatePricesTimer; + bool _isInitialActivationInProgress = false; @override Future close() async { @@ -64,25 +65,31 @@ class CoinsBloc extends Bloc { Emitter emit, ) async { try { - // Return early if the coin is not yet in wallet coins, meaning that - // it's not yet activated. - // TODO: update this once coin activation is fully handled by the SDK + if (_isInitialActivationInProgress) { + _log.info( + 'Skipping pubkeys request for ${event.coinId} while initial activation is in progress.', + ); + return; + } + + // Coins are added to walletCoins before activation even starts + // to show them in the UI regardless of activation state. + // If the coin is not found here, it means the auth state handler + // has not pre-populated the list with activating coins yet. final coin = state.walletCoins[event.coinId]; - if (coin == null) return; + if (coin == null) { + _log.warning( + 'Coin ${event.coinId} not found in wallet coins, cannot fetch pubkeys', + ); + return; + } // Get pubkeys from the SDK through the repo final asset = _kdfSdk.assets.available[coin.id]!; final pubkeys = await _kdfSdk.pubkeys.getPubkeys(asset); // Update state with new pubkeys - emit( - state.copyWith( - pubkeys: { - ...state.pubkeys, - event.coinId: pubkeys, - }, - ), - ); + emit(state.copyWith(pubkeys: {...state.pubkeys, event.coinId: pubkeys})); } catch (e, s) { _log.shout('Failed to get pubkeys for ${event.coinId}', e, s); } @@ -92,6 +99,18 @@ class CoinsBloc extends Bloc { CoinsStarted event, Emitter emit, ) async { + // Wait for trading status service to receive initial status before + // populating coins list. This ensures geo-blocked assets are properly + // filtered from the start, preventing them from appearing in the UI + // before filtering is applied. + // + // TODO: UX Improvement - For faster startup, populate coins immediately + // and reactively filter when trading status updates arrive. This would + // eliminate startup delay (~100-500ms) but requires UI to handle dynamic + // removal of blocked assets. See TradingStatusService._currentStatus for + // related trade-offs. + await _tradingStatusService.initialStatusReady; + emit(state.copyWith(coins: _coinsRepo.getKnownCoinsMap())); final existingUser = await _kdfSdk.auth.currentUser; @@ -137,17 +156,6 @@ class CoinsBloc extends Bloc { ); }, ); - - final coinUpdates = _syncIguanaCoinsStates(); - await emit.forEach( - coinUpdates, - onData: (coin) => - state.copyWith(walletCoins: {...state.walletCoins, coin.id.id: coin}), - onError: (error, stackTrace) { - _log.severe('Error syncing iguana coins states', error, stackTrace); - return state; - }, - ); } Future _onWalletCoinUpdated( @@ -201,17 +209,6 @@ class CoinsBloc extends Bloc { emit(_prePopulateListWithActivatingCoins(event.coinIds)); await _activateCoins(event.coinIds, emit); - final currentWallet = await _kdfSdk.currentWallet(); - if (currentWallet?.config.type == WalletType.iguana || - currentWallet?.config.type == WalletType.hdwallet) { - final coinUpdates = _syncIguanaCoinsStates(); - await emit.forEach( - coinUpdates, - onData: (coin) => state - .copyWith(walletCoins: {...state.walletCoins, coin.id.id: coin}), - ); - } - add(CoinsBalancesRefreshed()); } @@ -261,8 +258,9 @@ class CoinsBloc extends Bloc { Map currentCoins, ) { final updatedWalletCoins = Map.fromEntries( - currentWalletCoins.entries - .where((entry) => !coinsToDisable.contains(entry.key)), + currentWalletCoins.entries.where( + (entry) => !coinsToDisable.contains(entry.key), + ), ); final updatedCoins = Map.of(currentCoins); for (final assetId in coinsToDisable) { @@ -276,57 +274,82 @@ class CoinsBloc extends Bloc { CoinsPricesUpdated event, Emitter emit, ) async { - final prices = await _coinsRepo.fetchCurrentPrices(); - if (prices == null) { - _log.severe('Coin prices list empty/null'); - return; - } - final didPricesChange = !mapEquals(state.prices, prices); - if (!didPricesChange) { - _log.info('Coin prices list unchanged'); - return; - } + try { + final prices = await _coinsRepo.fetchCurrentPrices(); + if (prices == null) { + _log.severe('Coin prices list empty/null'); + return; + } + final didPricesChange = !mapEquals(state.prices, prices); + if (!didPricesChange) { + _log.info('Coin prices list unchanged'); + return; + } - Map updateCoinsWithPrices(Map coins) { - 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]; - if (price != null) { - return MapEntry(key, coin.copyWith(usdPrice: price)); - } - return MapEntry(key, coin); - }); + Map updateCoinsWithPrices(Map coins) { + 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]; + 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.of(map).unmodifiable(); + emit( + state.copyWith( + prices: prices.unmodifiable(), + coins: updateCoinsWithPrices(state.coins), + walletCoins: updateCoinsWithPrices(state.walletCoins), + ), + ); + } catch (e, s) { + _log.shout('Error on prices updated', e, s); } - - emit( - state.copyWith( - prices: prices.unmodifiable(), - coins: updateCoinsWithPrices(state.coins), - walletCoins: updateCoinsWithPrices(state.walletCoins), - ), - ); } Future _onLogin( CoinsSessionStarted event, Emitter emit, ) async { + _isInitialActivationInProgress = true; try { + // Ensure any cached addresses/pubkeys from a previous wallet are cleared + // so that UI fetches fresh pubkeys for the newly logged-in wallet. + emit(state.copyWith(pubkeys: {})); _coinsRepo.flushCache(); final Wallet currentWallet = event.signedInUser.wallet; // Start off by emitting the newly activated coins so that they all appear // in the list at once, rather than one at a time as they are activated final coinsToActivate = currentWallet.config.activatedCoins; - emit(_prePopulateListWithActivatingCoins(coinsToActivate)); - await _activateCoins(coinsToActivate, emit); - add(CoinsBalancesRefreshed()); - add(CoinsBalanceMonitoringStarted()); + // Filter out blocked coins before activation + final allowedCoins = coinsToActivate.where((coinId) { + final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); + if (assets.isEmpty) return false; + return !_tradingStatusService.isAssetBlocked(assets.single.id); + }); + + emit(_prePopulateListWithActivatingCoins(allowedCoins)); + _scheduleInitialBalanceRefresh(allowedCoins); + final activationFuture = _activateCoins(allowedCoins, emit); + unawaited(() async { + try { + await activationFuture; + } catch (e, s) { + _log.shout('Error during initial coin activation', e, s); + } finally { + _isInitialActivationInProgress = false; + } + }()); } catch (e, s) { + _isInitialActivationInProgress = false; _log.shout('Error on login', e, s); } } @@ -335,16 +358,74 @@ class CoinsBloc extends Bloc { CoinsSessionEnded event, Emitter emit, ) async { + _resetInitialActivationState(); add(CoinsBalanceMonitoringStopped()); emit( state.copyWith( walletCoins: {}, + // Clear pubkeys to avoid showing addresses from the previous wallet + // after logout or wallet switch. + pubkeys: {}, ), ); _coinsRepo.flushCache(); } + void _scheduleInitialBalanceRefresh(Iterable coinsToActivate) { + if (isClosed) return; + + final knownCoins = _coinsRepo.getKnownCoinsMap(); + final walletCoinsForThreshold = coinsToActivate + .map((coinId) => knownCoins[coinId]) + .whereType() + .toList(); + + if (walletCoinsForThreshold.isEmpty) { + add(CoinsBalancesRefreshed()); + add(CoinsBalanceMonitoringStarted()); + return; + } + + unawaited(() async { + var triggeredByThreshold = false; + try { + triggeredByThreshold = await _kdfSdk.waitForEnabledCoinsToPassThreshold( + walletCoinsForThreshold, + threshold: 0.8, + timeout: const Duration(minutes: 1), + ); + } catch (e, s) { + _log.shout( + 'Failed while waiting for enabled coins threshold during login', + e, + s, + ); + } + + if (isClosed) { + return; + } + + if (triggeredByThreshold) { + _log.fine( + 'Initial balance refresh triggered after 80% of coins activated.', + ); + } else { + _log.fine( + 'Initial balance refresh triggered after timeout while waiting for coin activation.', + ); + } + + add(CoinsBalancesRefreshed()); + add(CoinsBalanceMonitoringStarted()); + }()); + } + + void _resetInitialActivationState() { + _isInitialActivationInProgress = false; + } + Future _activateCoins( Iterable coins, Emitter emit, @@ -358,11 +439,16 @@ class CoinsBloc extends Bloc { // activation loops for assets not supported by the SDK.this may happen if the wallet // has assets that were removed from the SDK or the config has unsupported default // assets. - final coinsToActivate = coins + final availableAssets = coins .map((coin) => _kdfSdk.assets.findAssetsByConfigId(coin)) .where((assetsSet) => assetsSet.isNotEmpty) .map((assetsSet) => assetsSet.single); + // Filter out blocked assets + final coinsToActivate = _tradingStatusService.filterAllowedAssets( + availableAssets.toList(), + ); + final enableFutures = coinsToActivate .map((asset) => _coinsRepo.activateAssetsSync([asset])) .toList(); @@ -376,14 +462,15 @@ class CoinsBloc extends Bloc { final knownCoins = _coinsRepo.getKnownCoinsMap(); final activatingCoins = Map.fromIterable( coins - .map( - (coin) { - final sdkCoin = knownCoins[coin]; - return sdkCoin?.copyWith(state: CoinState.activating); - }, - ) + .map((coin) { + final sdkCoin = knownCoins[coin]; + return sdkCoin?.copyWith(state: CoinState.activating); + }) .where((coin) => coin != null) - .cast(), + .cast() + // Do not pre-populate zhtlc coins, as they require configuration + // and longer activation times, and are handled separately. + .where((coin) => coin.id.subClass != CoinSubClass.zhtlc), key: (element) => (element as Coin).id.id, ); return state.copyWith( @@ -391,72 +478,4 @@ class CoinsBloc extends Bloc { coins: {...knownCoins, ...state.coins, ...activatingCoins}, ); } - - /// Yields one coin at a time to provide visual feedback to the user as - /// coins are activated. - /// - /// When multiple coins are found for the provided IDs, - Stream _syncIguanaCoinsStates() async* { - final coinsBlocWalletCoinsState = state.walletCoins; - final previouslyActivatedCoinIds = - (await _kdfSdk.currentWallet())?.config.activatedCoins ?? []; - - final walletAssets = []; - for (final coinId in previouslyActivatedCoinIds) { - final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); - if (assets.isEmpty) { - _log.warning( - 'No assets found for activated coin ID: $coinId. ' - 'This coin will be skipped during synchronization.', - ); - continue; - } - if (assets.length > 1) { - final assetIds = assets.map((a) => a.id.id).join(', '); - _log.shout('Multiple assets found for activated coin ID: $coinId. ' - 'Expected single asset, found ${assets.length}: $assetIds. '); - } - - // This is expected to throw if there are multiple assets, to stick - // to the strategy of using `.single` elsewhere in the codebase. - walletAssets.add(assets.single); - } - - final coinsToSync = - _getWalletCoinsNotInState(walletAssets, coinsBlocWalletCoinsState); - if (coinsToSync.isNotEmpty) { - _log.info( - 'Found ${coinsToSync.length} wallet coins not in state, ' - 'syncing them to state as suspended', - ); - yield* Stream.fromIterable(coinsToSync); - } - } - - List _getWalletCoinsNotInState( - List walletAssets, Map coinsBlocWalletCoinsState) { - final List coinsToSyncToState = []; - - final enabledAssetsNotInState = walletAssets - .where((asset) => !coinsBlocWalletCoinsState.containsKey(asset.id.id)) - .toList(); - - // Show assets that are in the wallet metadata but not in the state. This might - // happen if activation occurs outside of the coins bloc, like the dex or - // coins manager auto-activation or deactivation. - for (final asset in enabledAssetsNotInState) { - final coin = _coinsRepo.getCoinFromId(asset.id); - if (coin == null) { - _log.shout( - 'Coin ${asset.id.id} not found in coins repository, ' - 'skipping sync from wallet metadata to coins bloc state.', - ); - continue; - } - - coinsToSyncToState.add(coin.copyWith(state: CoinState.suspended)); - } - - return coinsToSyncToState; - } } diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index cd2620ea18..cf5fb37f94 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -1,9 +1,9 @@ import 'dart:async'; -import 'dart:convert'; +import 'dart:math' show min; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; +import 'package:flutter/material.dart' show NetworkImage; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' as kdf_rpc; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; @@ -14,6 +14,8 @@ import 'package:komodo_ui/komodo_ui.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/app_config/app_config.dart' show excludedAssetList; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_service.dart' + show TradingStatusService; 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'; @@ -25,16 +27,19 @@ 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/text_error.dart'; -import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/model/withdraw_details/withdraw_details.dart'; -import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; class CoinsRepo { CoinsRepo({ required KomodoDefiSdk kdfSdk, required MM2 mm2, - }) : _kdfSdk = kdfSdk, - _mm2 = mm2 { + required TradingStatusService tradingStatusService, + required ArrrActivationService arrrActivationService, + }) : _kdfSdk = kdfSdk, + _mm2 = mm2, + _tradingStatusService = tradingStatusService, + _arrrActivationService = arrrActivationService { enabledAssetsChanges = StreamController.broadcast( onListen: () => _enabledAssetListenerCount += 1, onCancel: () => _enabledAssetListenerCount -= 1, @@ -43,6 +48,8 @@ class CoinsRepo { final KomodoDefiSdk _kdfSdk; final MM2 _mm2; + final TradingStatusService _tradingStatusService; + final ArrrActivationService _arrrActivationService; final _log = Logger('CoinsRepo'); @@ -50,7 +57,7 @@ class CoinsRepo { final Map> _addressCache = {}; // TODO: Remove since this is also being cached in the SDK - Map _pricesCache = {}; + final Map _pricesCache = {}; // Cache structure for storing balance information to reduce SDK calls // This is a temporary solution until the full migration to SDK is complete @@ -85,19 +92,25 @@ class CoinsRepo { BalanceInfo? lastKnownBalance(AssetId id) => _kdfSdk.balances.lastKnown(id); /// Subscribe to balance updates for an asset using the SDK's balance manager - void _subscribeToBalanceUpdates(Asset asset, Coin coin) { + void _subscribeToBalanceUpdates(Asset asset) { // Cancel any existing subscription for this asset _balanceWatchers[asset.id]?.cancel(); + if (_tradingStatusService.isAssetBlocked(asset.id)) { + _log.info('Asset ${asset.id.id} is blocked. Skipping balance updates.'); + return; + } + // 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(), - ); - }); + _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(), + ); + }, + ); } void flushCache() { @@ -130,7 +143,11 @@ class CoinsRepo { if (excludeExcludedAssets) { assets.removeWhere((key, _) => excludedAssetList.contains(key.id)); } - return assets.values.map(_assetToCoinWithoutAddress).toList(); + // Filter out blocked assets + final allowedAssets = _tradingStatusService.filterAllowedAssets( + assets.values.toList(), + ); + return allowedAssets.map(_assetToCoinWithoutAddress).toList(); } /// Returns a map of all known coins, optionally filtering out excluded assets. @@ -141,8 +158,11 @@ class CoinsRepo { if (excludeExcludedAssets) { assets.removeWhere((key, _) => excludedAssetList.contains(key.id)); } + final allowedAssets = _tradingStatusService.filterAllowedAssets( + assets.values.toList(), + ); return Map.fromEntries( - assets.values.map( + allowedAssets.map( (asset) => MapEntry(asset.id.id, _assetToCoinWithoutAddress(asset)), ), ); @@ -172,32 +192,14 @@ class CoinsRepo { } } - @Deprecated('Use KomodoDefiSdk assets or the ' - 'Wallet [KdfUser].wallet extension instead.') + @Deprecated( + 'Use KomodoDefiSdk assets or the ' + 'Wallet [KdfUser].wallet extension instead.', + ) Future> getWalletCoins() async { - final currentUser = await _kdfSdk.auth.currentUser; - if (currentUser == null) { - return []; - } - - return currentUser.wallet.config.activatedCoins - .map( - (coinId) { - final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); - if (assets.isEmpty) { - _log.warning('No assets found for coinId: $coinId'); - return null; - } - if (assets.length > 1) { - _log.shout( - 'Multiple assets found for coinId: $coinId (${assets.length} assets). ' - 'Selecting the first asset: ${assets.first.id.id}', - ); - } - return assets.single; - }, - ) - .whereType() + final walletAssets = await _kdfSdk.getWalletAssets(); + return _tradingStatusService + .filterAllowedAssets(walletAssets) .map(_assetToCoinWithoutAddress) .toList(); } @@ -205,7 +207,7 @@ class CoinsRepo { Coin _assetToCoinWithoutAddress(Asset asset) { final coin = asset.toCoin(); final balanceInfo = _balancesCache[coin.id.id]; - final price = _pricesCache[coin.id.symbol.configSymbol]; + final price = _pricesCache[coin.id.symbol.configSymbol.toUpperCase()]; Coin? parentCoin; if (asset.id.isChildAsset) { @@ -247,14 +249,14 @@ class CoinsRepo { /// exponential backoff for up to the specified duration. /// /// **Retry Configuration:** - /// - Default: 500ms → 1s → 2s → 4s → 8s → 10s → 10s... (30 attempts ≈ 3 minutes) + /// - Default: 500ms → 1s → 2s → 4s → 8s → 10s → 10s... (15 attempts ≈ 105 seconds) /// - Configurable via [maxRetryAttempts], [initialRetryDelay], and [maxRetryDelay] /// /// **Parameters:** /// - [assets]: List of assets to activate - /// - [notify]: Whether to broadcast state changes to listeners (default: true) + /// - [notifyListeners]: Whether to broadcast state changes to listeners (default: true) /// - [addToWalletMetadata]: Whether to add assets to wallet metadata (default: true) - /// - [maxRetryAttempts]: Maximum number of retry attempts (default: 30) + /// - [maxRetryAttempts]: Maximum number of retry attempts (default: 15) /// - [initialRetryDelay]: Initial delay between retries (default: 500ms) /// - [maxRetryDelay]: Maximum delay between retries (default: 10s) /// @@ -269,21 +271,43 @@ class CoinsRepo { /// **Note:** Assets are added to wallet metadata even if activation fails. Future activateAssetsSync( List assets, { - bool notify = true, + bool notifyListeners = true, bool addToWalletMetadata = true, - int maxRetryAttempts = 30, + int maxRetryAttempts = 15, Duration initialRetryDelay = const Duration(milliseconds: 500), Duration maxRetryDelay = const Duration(seconds: 10), }) async { final isSignedIn = await _kdfSdk.auth.isSignedIn(); if (!isSignedIn) { final coinIdList = assets.map((e) => e.id.id).join(', '); - _log.warning( - 'No wallet signed in. Skipping activation of [$coinIdList]', - ); + _log.warning('No wallet signed in. Skipping activation of [$coinIdList]'); return; } + // Separate ZHTLC and regular assets + final zhtlcAssets = assets + .where((asset) => asset.id.subClass == CoinSubClass.zhtlc) + .toList(); + final regularAssets = assets + .where((asset) => asset.id.subClass != CoinSubClass.zhtlc) + .toList(); + + // Process ZHTLC assets separately + if (zhtlcAssets.isNotEmpty) { + await _activateZhtlcAssets( + zhtlcAssets, + zhtlcAssets.map((asset) => _assetToCoinWithoutAddress(asset)).toList(), + notifyListeners: notifyListeners, + addToWalletMetadata: addToWalletMetadata, + ); + } + + // Continue with regular asset processing for non-ZHTLC assets + if (regularAssets.isEmpty) return; + + // Update assets list to only include regular assets for remaining processing + assets = regularAssets; + if (addToWalletMetadata) { // Ensure the wallet metadata is updated with the assets before activation // This is to ensure that the wallet metadata is always in sync with the assets @@ -296,7 +320,9 @@ class CoinsRepo { for (final asset in assets) { final coin = _assetToCoinWithoutAddress(asset); try { - if (notify) _broadcastAsset(coin.copyWith(state: CoinState.activating)); + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.activating)); + } // Use retry with exponential backoff for activation await retry( @@ -313,8 +339,9 @@ class CoinsRepo { final progress = await _kdfSdk.assets.activateAsset(asset).last; if (!progress.isSuccess) { - throw Exception(progress.errorMessage ?? - 'Activation failed for ${asset.id.id}'); + throw Exception( + progress.errorMessage ?? 'Activation failed for ${asset.id.id}', + ); } }, maxAttempts: maxRetryAttempts, @@ -325,31 +352,33 @@ class CoinsRepo { ); _log.info('Asset activated: ${asset.id.id}'); - if (notify) { + if (notifyListeners) { _broadcastAsset(coin.copyWith(state: CoinState.active)); if (coin.id.parentId != null) { final parentCoin = _assetToCoinWithoutAddress( - _kdfSdk.assets.available[coin.id.parentId]!); + _kdfSdk.assets.available[coin.id.parentId]!, + ); _broadcastAsset(parentCoin.copyWith(state: CoinState.active)); } } - _subscribeToBalanceUpdates(asset, coin); + _subscribeToBalanceUpdates(asset); if (coin.id.parentId != null) { final parentAsset = _kdfSdk.assets.available[coin.id.parentId]; if (parentAsset == null) { _log.warning('Parent asset not found: ${coin.id.parentId}'); } else { - _subscribeToBalanceUpdates(parentAsset, coin); + _subscribeToBalanceUpdates(parentAsset); } } } catch (e, s) { lastActivationException = e is Exception ? e : Exception(e.toString()); _log.shout( - 'Error activating asset after retries: ${asset.id.id}', e, s); - if (notify) { - _broadcastAsset( - asset.toCoin().copyWith(state: CoinState.suspended), - ); + 'Error activating asset after retries: ${asset.id.id}', + e, + s, + ); + if (notifyListeners) { + _broadcastAsset(asset.toCoin().copyWith(state: CoinState.suspended)); } } finally { // Register outside of the try-catch to ensure icon is available even @@ -390,14 +419,14 @@ class CoinsRepo { /// activated coins and retry failed activations with exponential backoff. /// /// **Retry Configuration:** - /// - Default: 500ms → 1s → 2s → 4s → 8s → 10s → 10s... (30 attempts ≈ 3 minutes) + /// - Default: 500ms → 1s → 2s → 4s → 8s → 10s → 10s... (15 attempts ≈ 105 seconds) /// - Configurable via [maxRetryAttempts], [initialRetryDelay], and [maxRetryDelay] /// /// **Parameters:** /// - [coins]: List of coins to activate /// - [notify]: Whether to broadcast state changes to listeners (default: true) /// - [addToWalletMetadata]: Whether to add assets to wallet metadata (default: true) - /// - [maxRetryAttempts]: Maximum number of retry attempts (default: 30) + /// - [maxRetryAttempts]: Maximum number of retry attempts (default: 15) /// - [initialRetryDelay]: Initial delay between retries (default: 500ms) /// - [maxRetryDelay]: Maximum delay between retries (default: 10s) /// @@ -417,7 +446,7 @@ class CoinsRepo { List coins, { bool notify = true, bool addToWalletMetadata = true, - int maxRetryAttempts = 30, + int maxRetryAttempts = 15, Duration initialRetryDelay = const Duration(milliseconds: 500), Duration maxRetryDelay = const Duration(seconds: 10), }) async { @@ -431,7 +460,7 @@ class CoinsRepo { return activateAssetsSync( assets, - notify: notify, + notifyListeners: notify, addToWalletMetadata: addToWalletMetadata, maxRetryAttempts: maxRetryAttempts, initialRetryDelay: initialRetryDelay, @@ -513,10 +542,28 @@ class CoinsRepo { } } - @Deprecated('Use SDK pubkeys.getPubkeys instead and let the user ' - 'select from the available options.') + @Deprecated( + 'Use SDK pubkeys.getPubkeys instead and let the user ' + 'select from the available options.', + ) Future getFirstPubkey(String coinId) async { - final asset = _kdfSdk.assets.findAssetsByConfigId(coinId).single; + final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); + if (assets.isEmpty) { + _log.warning( + 'Unable to fetch pubkey for coinId $coinId because the asset is no longer available.', + ); + return null; + } + + if (assets.length > 1) { + final assetIds = assets.map((asset) => asset.id.id).join(', '); + final message = + 'Multiple assets found for coinId $coinId while fetching pubkey: $assetIds'; + _log.shout(message); + throw StateError(message); + } + + final asset = assets.single; final pubkeys = await _kdfSdk.pubkeys.getPubkeys(asset); if (pubkeys.keys.isEmpty) { return null; @@ -527,7 +574,7 @@ class CoinsRepo { double? getUsdPriceByAmount(String amount, String coinAbbr) { final Coin? coin = getCoin(coinAbbr); final double? parsedAmount = double.tryParse(amount); - final double? usdPrice = coin?.usdPrice?.price; + final double? usdPrice = coin?.usdPrice?.price?.toDouble(); if (coin == null || usdPrice == null || parsedAmount == null) { return null; @@ -535,159 +582,86 @@ class CoinsRepo { return parsedAmount * usdPrice; } + /// 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 + /// also see prices and 24h changes in lists and charts. + /// + /// Prefer activated assets if available (to limit requests when logged in), + /// otherwise fall back to all available SDK assets. Future?> fetchCurrentPrices() async { - try { - // Try to use the SDK's price manager to get prices for active coins - final activatedAssets = await _kdfSdk.assets.getActivatedAssets(); - for (final asset in activatedAssets) { - try { - // Use maybeFiatPrice to avoid errors for assets not tracked by CEX - final fiatPrice = await _kdfSdk.marketData.maybeFiatPrice(asset.id); - if (fiatPrice != null) { - // Use configSymbol to lookup for backwards compatibility with the old, - // string-based price list (and fallback) - double? change24h; - try { - final change24hDecimal = await _kdfSdk.marketData.priceChange24h(asset.id); - change24h = change24hDecimal?.toDouble(); - } catch (e) { - _log.warning('Failed to get 24h change for ${asset.id.id}: $e'); - // Continue with null change24h rather than failing the entire price update - } - - _pricesCache[asset.id.symbol.configSymbol] = CexPrice( - ticker: asset.id.id, - price: fiatPrice.toDouble(), - lastUpdated: DateTime.now(), - change24h: change24h, - ); - } - } catch (e) { - _log.warning('Failed to get price for ${asset.id.id}: $e'); - } - } - - // Still use the backup methods for other coins or if SDK fails - final Map? fallbackPrices = - await _updateFromMain() ?? await _updateFromFallback(); - - if (fallbackPrices != null) { - // Merge fallback prices with SDK prices (don't overwrite SDK prices) - fallbackPrices.forEach((key, value) { - if (!_pricesCache.containsKey(key)) { - _pricesCache[key] = value; - } - }); - } - } catch (e, s) { - _log.shout('Error refreshing prices from SDK', e, s); + // NOTE: key assumption here is that the Komodo Prices API supports most + // (ideally all) assets being requested, resulting in minimal requests to + // 3rd party fallback providers. If this assumption does not hold, then we + // will hit rate limits and have reduced market metrics functionality. + // This will happen regardless of chunk size. The rate limits are per IP + // per hour. + final activatedAssets = await _kdfSdk.getWalletAssets(); + final Iterable targetAssets = activatedAssets.isNotEmpty + ? activatedAssets + : _kdfSdk.assets.available.values; + + // Filter out excluded and testnet assets, as they are not expected + // to have valid prices available at any of the providers + final filteredAssets = targetAssets + .where((asset) => !excludedAssetList.contains(asset.id.id)) + .where((asset) => !asset.protocol.isTestnet) + .toList(); - // Fallback to the existing methods - final Map? prices = - await _updateFromMain() ?? await _updateFromFallback(); + // Filter out blocked assets + final validAssets = _tradingStatusService.filterAllowedAssets( + filteredAssets, + ); - if (prices != null) { - _pricesCache = prices; - } - } + // Process assets with bounded parallelism to avoid overwhelming providers + await _fetchAssetPricesInChunks(validAssets); return _pricesCache; } - Future?> _updateFromMain() async { - http.Response res; - String body; - try { - res = await http.get(pricesUrlV3); - body = res.body; - } catch (e, s) { - _log.shout('Error updating price from main: $e', e, s); - return null; - } + /// Processes assets in chunks with bounded parallelism to avoid + /// overloading providers. + Future _fetchAssetPricesInChunks( + List assets, { + int chunkSize = 12, + }) async { + final boundedChunkSize = min(assets.length, chunkSize); + final chunks = assets.slices(boundedChunkSize); - Map? json; - try { - json = jsonDecode(body) as Map; - } catch (e, s) { - _log.shout('Error parsing of update price from main response', e, s); + for (final chunk in chunks) { + await Future.wait(chunk.map(_fetchAssetPrice), eagerError: false); } - - if (json == null) return null; - final Map prices = {}; - json.forEach((String priceTicker, dynamic pricesData) { - final pricesJson = pricesData as Map? ?? {}; - prices[priceTicker] = CexPrice( - ticker: priceTicker, - price: double.tryParse(pricesJson['last_price'] as String? ?? '') ?? 0, - lastUpdated: DateTime.fromMillisecondsSinceEpoch( - (pricesJson['last_updated_timestamp'] as int? ?? 0) * 1000, - ), - priceProvider: - cexDataProvider(pricesJson['price_provider'] as String? ?? ''), - change24h: double.tryParse(pricesJson['change_24h'] as String? ?? ''), - changeProvider: - cexDataProvider(pricesJson['change_24h_provider'] as String? ?? ''), - volume24h: double.tryParse(pricesJson['volume24h'] as String? ?? ''), - volumeProvider: - cexDataProvider(pricesJson['volume_provider'] as String? ?? ''), - ); - }); - return prices; } - Future?> _updateFromFallback() async { - final List ids = (await _kdfSdk.assets.getActivatedAssets()) - .map((c) => c.id.symbol.coinGeckoId ?? '') - .toList() - ..removeWhere((id) => id.isEmpty); - final Uri fallbackUri = Uri.parse( - 'https://api.coingecko.com/api/v3/simple/price?ids=' - '${ids.join(',')}&vs_currencies=usd', - ); - - http.Response res; - String body; + /// Fetches price data for a single asset and updates the cache + Future _fetchAssetPrice(Asset asset) async { try { - res = await http.get(fallbackUri); - body = res.body; - } catch (e, s) { - _log.shout('Error updating price from fallback', e, s); - return null; - } - - Map? json; - try { - json = jsonDecode(body) as Map?; - } catch (e, s) { - _log.shout('Error parsing of update price from fallback response', e, s); - } - - if (json == null) return null; - final Map prices = {}; - - for (final MapEntry entry in json.entries) { - final coingeckoId = entry.key; - final pricesData = entry.value as Map? ?? {}; - if (coingeckoId == 'test-coin') continue; - - // Coins with the same coingeckoId supposedly have same usd price - // (e.g. KMD == KMD-BEP20) - final Iterable samePriceCoins = - getKnownCoins().where((coin) => coin.coingeckoId == coingeckoId); + // Use maybeFiatPrice to avoid errors for assets not tracked by CEX + final fiatPrice = await _kdfSdk.marketData.maybeFiatPrice(asset.id); + if (fiatPrice != null) { + // Use configSymbol to lookup for backwards compatibility with the old, + // string-based price list (and fallback) + Decimal? change24h; + try { + change24h = await _kdfSdk.marketData.priceChange24h(asset.id); + } catch (e) { + _log.warning('Failed to get 24h change for ${asset.id.id}: $e'); + // Continue without 24h change data + } - for (final Coin coin in samePriceCoins) { - prices[coin.id.symbol.configSymbol] = CexPrice( - ticker: coin.id.id, - price: double.parse(pricesData['usd'].toString()), + final symbolKey = asset.id.symbol.configSymbol.toUpperCase(); + _pricesCache[symbolKey] = CexPrice( + assetId: asset.id, + price: fiatPrice, + lastUpdated: DateTime.now(), + change24h: change24h, ); } + } catch (e) { + _log.warning('Failed to get price for ${asset.id.id}: $e'); } - - return prices; } - // updateTrezorBalances removed (TrezorRepo deleted) - /// Updates balances for active coins by querying the SDK /// Yields coins that have balance changes Stream updateIguanaBalances(Map walletCoins) async* { @@ -695,8 +669,11 @@ class CoinsRepo { // the SDK's balance watchers to get live updates. We still // implement it for backward compatibility. final walletCoinsCopy = Map.from(walletCoins); - final coins = - walletCoinsCopy.values.where((coin) => coin.isActive).toList(); + final coins = _tradingStatusService + .filterAllowedAssetsMap(walletCoinsCopy, (coin) => coin.id) + .values + .where((coin) => coin.isActive) + .toList(); // Get balances from the SDK for all active coins for (final coin in coins) { @@ -721,15 +698,15 @@ class CoinsRepo { // Only yield if there's a change if (balanceChanged || spendableChanged) { // Update the cache - _balancesCache[coin.id.id] = - (balance: newBalance, spendable: newSpendable); + _balancesCache[coin.id.id] = ( + balance: newBalance, + spendable: newSpendable, + ); // Yield updated coin with new balance // We still set both the deprecated fields and rely on the SDK // for future access to maintain backward compatibility - yield coin.copyWith( - sendableBalance: newSpendable, - ); + yield coin.copyWith(sendableBalance: newSpendable); } } catch (e, s) { _log.warning('Failed to update balance for ${coin.id}', e, s); @@ -737,8 +714,10 @@ class CoinsRepo { } } - @Deprecated('Use KomodoDefiSdk withdraw method instead. ' - 'This will be removed in the future.') + @Deprecated( + 'Use KomodoDefiSdk withdraw method instead. ' + 'This will be removed in the future.', + ) Future> withdraw( WithdrawRequest request, ) async { @@ -767,15 +746,176 @@ class CoinsRepo { response['result'] as Map? ?? {}, ); - return BlocResponse( - result: withdrawDetails, - ); + return BlocResponse(result: withdrawDetails); } - /// Get a cached price for a given coin symbol - /// - /// This returns the price from the cache without fetching new data - CexPrice? getCachedPrice(String symbol) { - return _pricesCache[symbol]; + Future _activateZhtlcAssets( + List assets, + List coins, { + bool notifyListeners = true, + bool addToWalletMetadata = true, + }) async { + final activatedAssets = await _kdfSdk.assets.getActivatedAssets(); + + for (final asset in assets) { + final coin = coins.firstWhere((coin) => coin.id == asset.id); + + // Check if asset is already activated + final isAlreadyActivated = activatedAssets.any((a) => a.id == asset.id); + + if (isAlreadyActivated) { + _log.info( + 'ZHTLC coin ${coin.id} is already activated. Broadcasting active state.', + ); + + // Add to wallet metadata if requested + if (addToWalletMetadata) { + await _addAssetsToWalletMetdata([asset.id]); + } + + // Broadcast active state for already activated assets + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.active)); + if (coin.id.parentId != null) { + final parentCoin = _assetToCoinWithoutAddress( + _kdfSdk.assets.available[coin.id.parentId]!, + ); + _broadcastAsset(parentCoin.copyWith(state: CoinState.active)); + } + } + + // Subscribe to balance updates for already activated assets + _subscribeToBalanceUpdates(asset); + if (coin.id.parentId != null) { + final parentAsset = _kdfSdk.assets.available[coin.id.parentId]; + if (parentAsset == null) { + _log.warning('Parent asset not found: ${coin.id.parentId}'); + } else { + _subscribeToBalanceUpdates(parentAsset); + } + } + + // Register custom icon if available + if (coin.logoImageUrl?.isNotEmpty ?? false) { + AssetIcon.registerCustomIcon( + coin.id, + NetworkImage(coin.logoImageUrl!), + ); + } + } else { + // Asset needs activation + await _activateZhtlcAsset( + asset, + coin, + notifyListeners: notifyListeners, + addToWalletMetadata: addToWalletMetadata, + ); + } + } + } + + /// Activates a ZHTLC asset using ArrrActivationService + /// This will wait for user configuration if needed before proceeding with activation + /// Mirrors the notify and addToWalletMetadata functionality of activateAssetsSync + Future _activateZhtlcAsset( + Asset asset, + Coin coin, { + bool notifyListeners = true, + bool addToWalletMetadata = true, + }) async { + try { + _log.info('Starting ZHTLC activation for ${asset.id.id}'); + + // Use the service's future-based activation which will handle configuration + // The service will emit to its stream for UI to handle, and this future will + // complete only after configuration is provided and activation succeeds. + // This ensures CoinsRepo waits for user inputs for config params from the dialog + // before proceeding with activation, and doesn't broadcast activation status + // until config parameters are received and (desktop) params files downloaded. + final result = await _arrrActivationService.activateArrr(asset); + result.when( + success: (progress) async { + _log.info('ZHTLC asset activated successfully: ${asset.id.id}'); + + // Add assets after activation regardless of success or failure + if (addToWalletMetadata) { + await _addAssetsToWalletMetdata([asset.id]); + } + + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.activating)); + } + + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.active)); + if (coin.id.parentId != null) { + final parentCoin = _assetToCoinWithoutAddress( + _kdfSdk.assets.available[coin.id.parentId]!, + ); + _broadcastAsset(parentCoin.copyWith(state: CoinState.active)); + } + } + + _subscribeToBalanceUpdates(asset); + if (coin.id.parentId != null) { + final parentAsset = _kdfSdk.assets.available[coin.id.parentId]; + if (parentAsset == null) { + _log.warning('Parent asset not found: ${coin.id.parentId}'); + } else { + _subscribeToBalanceUpdates(parentAsset); + } + } + + if (coin.logoImageUrl?.isNotEmpty ?? false) { + AssetIcon.registerCustomIcon( + coin.id, + NetworkImage(coin.logoImageUrl!), + ); + } + }, + error: (message) { + _log.severe( + 'ZHTLC asset activation failed: ${asset.id.id} - $message', + ); + + // Only broadcast suspended state if it's not a user cancellation + // User cancellations have the message "Configuration cancelled by user or timed out" + final isUserCancellation = message.contains('cancelled by user'); + + if (notifyListeners && !isUserCancellation) { + _broadcastAsset(coin.copyWith(state: CoinState.suspended)); + } + + if (!isUserCancellation) { + throw Exception("zcoin activaiton failed: $message"); + } + }, + needsConfiguration: (coinId, requiredSettings) { + _log.severe( + 'ZHTLC activation should not return needsConfiguration in future-based call', + ); + _log.severe( + 'Unexpected needsConfiguration result for ${asset.id.id}', + ); + + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.suspended)); + } + + throw Exception( + 'ZHTLC activation configuration not handled properly', + ); + }, + ); + } catch (e, s) { + _log.severe('Error activating ZHTLC asset ${asset.id.id}', e, s); + + // Broadcast suspended state if requested + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.suspended)); + } + + rethrow; + } } } diff --git a/lib/bloc/coins_bloc/coins_state.dart b/lib/bloc/coins_bloc/coins_state.dart index 1e30bbc5fe..4037ac451d 100644 --- a/lib/bloc/coins_bloc/coins_state.dart +++ b/lib/bloc/coins_bloc/coins_state.dart @@ -70,7 +70,7 @@ class CoinsState extends Equatable { /// Gets the 24h price change percentage for a given asset ID double? get24hChangeForAsset(AssetId assetId) { - return getPriceForAsset(assetId)?.change24h; + return getPriceForAsset(assetId)?.change24h?.toDouble(); } /// Calculates the USD price for a given amount of a coin @@ -90,7 +90,7 @@ class CoinsState extends Equatable { final Coin? coin = coins[coinAbbr]; final double? parsedAmount = double.tryParse(amount); final CexPrice? cexPrice = prices[coinAbbr.toUpperCase()]; - final double? usdPrice = cexPrice?.price; + final double? usdPrice = cexPrice?.price?.toDouble(); if (coin == null || usdPrice == null || parsedAmount == null) { return null; diff --git a/lib/bloc/coins_manager/coins_manager_bloc.dart b/lib/bloc/coins_manager/coins_manager_bloc.dart index d253b8f667..9e0d9741fc 100644 --- a/lib/bloc/coins_manager/coins_manager_bloc.dart +++ b/lib/bloc/coins_manager/coins_manager_bloc.dart @@ -6,6 +6,7 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/analytics/events/portfolio_events.dart'; +import 'package:web_dex/analytics/events/misc_events.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/coins_manager/coins_manager_sort.dart'; @@ -14,7 +15,7 @@ import 'package:web_dex/blocs/trading_entities_bloc.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_type.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'; @@ -56,14 +57,24 @@ class CoinsManagerBloc extends Bloc { final TradingEntitiesBloc _tradingEntitiesBloc; final _log = Logger('CoinsManagerBloc'); + // Cache for expensive operations + Map? _cachedKnownCoinsMap; + List? _cachedWalletCoins; + bool? _cachedTestCoinsEnabled; + Future _onCoinsUpdate( CoinsManagerCoinsUpdate event, Emitter emit, ) async { final List filters = []; - final mergedCoinsList = mergeCoinLists( - await _getOriginalCoinList(_coinsRepo, event.action), + final mergedCoinsList = _mergeCoinLists( + await _getOriginalCoinList( + _coinsRepo, + event.action, + cachedKnownCoinsMap: _cachedKnownCoinsMap, + cachedWalletCoins: _cachedWalletCoins, + ), state.coins, ).toList(); @@ -109,19 +120,38 @@ class CoinsManagerBloc extends Bloc { CoinsManagerCoinsListReset event, Emitter emit, ) async { + _cachedWalletCoins = null; + _cachedTestCoinsEnabled = null; + emit( state.copyWith( action: event.action, - coins: [], + coins: _cachedKnownCoinsMap?.values.toList() ?? [], selectedCoins: const [], searchPhrase: '', selectedCoinTypes: const [], isSwitching: false, ), ); + + // Cache expensive operations when opening the list, as these values + // should not change while the list is open. + // Known coins map can be cached for longer, but would need to add an + // auth listener to clear it on logout/login, so leaving as-is for now. + // Wallet and test coins can be changed by the user outside of this + // bloc within the same auth session, so they must always be cleared. + _cachedKnownCoinsMap = _coinsRepo.getKnownCoinsMap( + excludeExcludedAssets: true, + ); + _cachedWalletCoins = await _coinsRepo.getWalletCoins(); + _cachedTestCoinsEnabled = + (await _settingsRepository.loadSettings()).testCoinsEnabled; + final List coins = await _getOriginalCoinList( _coinsRepo, event.action, + cachedKnownCoinsMap: _cachedKnownCoinsMap, + cachedWalletCoins: _cachedWalletCoins, ); // Add wallet coins to selected coins if in add mode so that they @@ -225,10 +255,9 @@ class CoinsManagerBloc extends Bloc { } _analyticsBloc.logEvent( AssetDisabledEventData( - assetSymbol: coin.abbr, - assetNetwork: coin.protocolType, - walletType: - (await _sdk.auth.currentUser)?.wallet.config.type.name ?? '', + asset: coin.abbr, + network: coin.protocolType, + hdType: (await _sdk.auth.currentUser)?.type ?? '', ), ); } @@ -241,10 +270,9 @@ class CoinsManagerBloc extends Bloc { } _analyticsBloc.logEvent( AssetEnabledEventData( - assetSymbol: coin.abbr, - assetNetwork: coin.protocolType, - walletType: - (await _sdk.auth.currentUser)?.wallet.config.type.name ?? '', + asset: coin.abbr, + network: coin.protocolType, + hdType: (await _sdk.auth.currentUser)?.type ?? '', ), ); } @@ -272,6 +300,14 @@ class CoinsManagerBloc extends Bloc { Emitter emit, ) { emit(state.copyWith(searchPhrase: event.text)); + final query = event.text.trim(); + final matchedCoin = _coinsRepo.getCoin(query.toUpperCase()); + _analyticsBloc.logEvent( + SearchbarInputEventData( + queryLength: query.length, + assetSymbol: matchedCoin?.abbr, + ), + ); add(CoinsManagerCoinsUpdate(state.action)); } @@ -288,8 +324,9 @@ class CoinsManagerBloc extends Bloc { } Future> _filterTestCoinsIfNeeded(List coins) async { - final settings = await _settingsRepository.loadSettings(); - return settings.testCoinsEnabled ? coins : removeTestCoins(coins); + _cachedTestCoinsEnabled ??= + (await _settingsRepository.loadSettings()).testCoinsEnabled; + return _cachedTestCoinsEnabled! ? coins : removeTestCoins(coins); } List _filterByPhrase(List coins) { @@ -317,7 +354,8 @@ class CoinsManagerBloc extends Bloc { return selectedCoins; } - final walletCoins = await _coinsRepo.getWalletCoins(); + _cachedWalletCoins ??= await _coinsRepo.getWalletCoins(); + final walletCoins = _cachedWalletCoins!; final result = List.from(selectedCoins); final selectedCoinIds = result.map((c) => c.id.id).toSet(); @@ -330,7 +368,7 @@ class CoinsManagerBloc extends Bloc { return result; } - Set mergeCoinLists(List originalList, List newList) { + Set _mergeCoinLists(List originalList, List newList) { final Map coinMap = {}; for (final Coin coin in originalList) { @@ -491,16 +529,18 @@ class CoinsManagerBloc extends Bloc { Future> _getOriginalCoinList( CoinsRepo coinsRepo, - CoinsManagerAction action, -) async { + CoinsManagerAction action, { + Map? cachedKnownCoinsMap, + List? cachedWalletCoins, +}) async { switch (action) { case CoinsManagerAction.add: - return coinsRepo - .getKnownCoinsMap(excludeExcludedAssets: true) - .values - .toList(); + final knownCoinsMap = + cachedKnownCoinsMap ?? + coinsRepo.getKnownCoinsMap(excludeExcludedAssets: true); + return knownCoinsMap.values.toList(); case CoinsManagerAction.remove: - return coinsRepo.getWalletCoins(); + return cachedWalletCoins ?? await coinsRepo.getWalletCoins(); case CoinsManagerAction.none: return []; } 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 15d659ea76..d5bdecb2b7 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 @@ -1,30 +1,28 @@ +import 'dart:async' show TimeoutException; + import 'package:decimal/decimal.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart' show poll; import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:logging/logging.dart'; +import 'package:web_dex/analytics/events/portfolio_events.dart'; +import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.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/analytics/analytics_bloc.dart'; -import 'package:web_dex/analytics/events/portfolio_events.dart'; -import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:web_dex/model/coin_type.dart'; -import 'package:web_dex/model/wallet.dart'; -import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/utils/extensions/kdf_user_extensions.dart'; class CustomTokenImportBloc extends Bloc { - final ICustomTokenImportRepository repository; - final CoinsRepo _coinsRepo; - final KomodoDefiSdk sdk; - final AnalyticsBloc analyticsBloc; - CustomTokenImportBloc( - this.repository, + this._repository, this._coinsRepo, - this.sdk, - this.analyticsBloc, + this._sdk, + this._analyticsBloc, ) : super(CustomTokenImportState.defaults()) { on(_onUpdateAsset); on(_onUpdateAddress); @@ -33,19 +31,25 @@ class CustomTokenImportBloc on(_onResetFormStatus); } + final ICustomTokenImportRepository _repository; + final CoinsRepo _coinsRepo; + final KomodoDefiSdk _sdk; + final AnalyticsBloc _analyticsBloc; + final _log = Logger('CustomTokenImportBloc'); + void _onResetFormStatus( ResetFormStatusEvent event, Emitter emit, ) { - final availableCoinTypes = - CoinType.values.map((CoinType type) => type.toCoinSubClass()); - final items = CoinSubClass.values - .where( - (CoinSubClass type) => - type.isEvmProtocol() && availableCoinTypes.contains(type), - ) - .toList() - ..sort((a, b) => a.name.compareTo(b.name)); + 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; + }).toList()..sort((a, b) => a.name.compareTo(b.name)); emit( state.copyWith( @@ -81,21 +85,38 @@ class CustomTokenImportBloc ) async { emit(state.copyWith(formStatus: FormStatus.submitting)); + Asset? tokenData; try { - final networkAsset = _coinsRepo.getCoin(state.network.ticker); - if (networkAsset == null) { - throw Exception('Network asset ${state.network.formatted} not found'); - } + final networkAsset = _sdk.getSdkAsset(state.network.ticker); + + // Network (parent) asset must be active before attempting to fetch the + // custom token data + await _coinsRepo.activateAssetsSync( + [networkAsset], + notifyListeners: false, + addToWalletMetadata: false, + ); - await _coinsRepo.activateCoinsSync([networkAsset]); - final tokenData = - await repository.fetchCustomToken(state.network, state.address); - await _coinsRepo.activateAssetsSync([tokenData]); + tokenData = await _repository.fetchCustomToken( + networkAsset.id, + state.address, + ); + await _coinsRepo.activateAssetsSync( + [tokenData], + addToWalletMetadata: false, + notifyListeners: false, + // The default coin activation is generous, assuming background retries, + // but we limit it here to avoid waiting too long in the dialog. + maxRetryAttempts: 10, + ); + await _waitForCustomTokenPropagation(tokenData); final balanceInfo = await _coinsRepo.tryGetBalanceInfo(tokenData.id); final balance = balanceInfo.spendable; - final usdBalance = - _coinsRepo.getUsdPriceByAmount(balance.toString(), tokenData.id.id); + final usdBalance = _coinsRepo.getUsdPriceByAmount( + balance.toString(), + tokenData.id.id, + ); emit( state.copyWith( @@ -107,15 +128,8 @@ class CustomTokenImportBloc formErrorMessage: '', ), ); - - await _coinsRepo.deactivateCoinsSync([tokenData.toCoin()]); } catch (e, s) { - log( - 'Error fetching custom token: ${e.toString()}', - path: 'CustomTokenImportBloc._onSubmitFetchCustomToken', - isError: true, - trace: s, - ); + _log.severe('Error fetching custom token', e, s); emit( state.copyWith( formStatus: FormStatus.failure, @@ -123,6 +137,35 @@ class CustomTokenImportBloc formErrorMessage: e.toString(), ), ); + } 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()]); + } + } + } + + /// wait for the asset to appear in the known asset list with a 5-second + /// timeout using the poll function from sdk type utils package + /// and ignore timeout exception + Future _waitForCustomTokenPropagation( + Asset tokenData, { + Duration timeout = const Duration(seconds: 10), + }) async { + try { + await poll( + () async { + await Future.delayed(const Duration(seconds: 1)); + return _sdk.assets.available.containsKey(tokenData.id); + }, + isComplete: (assetExists) => assetExists, + maxDuration: timeout, + ); + } on TimeoutException catch (_) { + _log.warning( + 'Timeout waiting for asset to appear in the known asset list', + ); } } @@ -133,15 +176,14 @@ class CustomTokenImportBloc emit(state.copyWith(importStatus: FormStatus.submitting)); try { - await repository.importCustomToken(state.coin!); + await _repository.importCustomToken(state.coin!); - final walletType = - (await sdk.auth.currentUser)?.wallet.config.type.name ?? ''; - analyticsBloc.logEvent( + final walletType = (await _sdk.auth.currentUser)?.type ?? ''; + _analyticsBloc.logEvent( AssetAddedEventData( - assetSymbol: state.coin!.id.id, - assetNetwork: state.network.ticker, - walletType: walletType, + asset: state.coin!.id.id, + network: state.network.ticker, + hdType: walletType, ), ); @@ -152,12 +194,7 @@ class CustomTokenImportBloc ), ); } catch (e, s) { - log( - 'Error importing custom token: ${e.toString()}', - path: 'CustomTokenImportBloc._onSubmitImportCustomToken', - isError: true, - trace: s, - ); + _log.severe('Error importing custom token', e, s); emit( state.copyWith( importStatus: FormStatus.failure, 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 603c017451..74b783094d 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 @@ -1,3 +1,4 @@ +import 'dart:async' show TimeoutException; import 'dart:convert'; import 'package:flutter/material.dart'; @@ -6,39 +7,56 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui/komodo_ui.dart'; +import 'package:logging/logging.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; -import 'package:web_dex/shared/utils/utils.dart'; +/// Abstraction for fetching and importing custom tokens. +/// +/// Implementations should resolve token metadata and activate tokens so they +/// become available to the user within the wallet. abstract class ICustomTokenImportRepository { - Future fetchCustomToken(CoinSubClass network, String address); + /// Fetch an [Asset] for a custom token on [network] using [address]. + /// + /// May return an existing known asset or construct a new one when absent. + Future fetchCustomToken(AssetId networkId, String address); + + /// Import the provided custom token [asset] into the wallet (e.g. activate it). Future importCustomToken(Asset asset); + + /// Get the API name for the given coin subclass. + String? getNetworkApiName(CoinSubClass coinType); } class KdfCustomTokenImportRepository implements ICustomTokenImportRepository { - KdfCustomTokenImportRepository(this._kdfSdk, this._coinsRepo); + KdfCustomTokenImportRepository( + this._kdfSdk, + this._coinsRepo, { + http.Client? httpClient, + }) : _httpClient = httpClient ?? http.Client(); final CoinsRepo _coinsRepo; final KomodoDefiSdk _kdfSdk; + final http.Client _httpClient; + final _log = Logger('KdfCustomTokenImportRepository'); @override - Future fetchCustomToken(CoinSubClass network, String address) async { - final convertAddressResponse = - await _kdfSdk.client.rpc.address.convertAddress( - from: address, - coin: network.ticker, - toFormat: AddressFormat.fromCoinSubClass(CoinSubClass.erc20), - ); + Future fetchCustomToken(AssetId networkId, String address) async { + final networkSubclass = networkId.subClass; + final convertAddressResponse = await _kdfSdk.client.rpc.address + .convertAddress( + from: address, + coin: networkSubclass.ticker, + toFormat: AddressFormat.fromCoinSubClass(CoinSubClass.erc20), + ); final contractAddress = convertAddressResponse.address; final knownCoin = _kdfSdk.assets.available.values.firstWhereOrNull( - (asset) => asset.contractAddress == contractAddress, + (asset) => + asset.contractAddress == contractAddress && + asset.id.subClass == networkSubclass, ); if (knownCoin == null) { - return await _createNewCoin( - contractAddress, - network, - address, - ); + return _createNewCoin(contractAddress, networkId); } return knownCoin; @@ -46,90 +64,88 @@ class KdfCustomTokenImportRepository implements ICustomTokenImportRepository { Future _createNewCoin( String contractAddress, - CoinSubClass network, - String address, + AssetId networkId, ) async { + final network = networkId.subClass; + + _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.formatted, + protocolType: + CoinSubClass.erc20.tokenStandardSuffix ?? + CoinSubClass.erc20.name.toUpperCase(), ); final platformAssets = _kdfSdk.assets.findAssetsByConfigId(network.ticker); if (platformAssets.length != 1) { - throw Exception('Platform asset not found. ${platformAssets.length} ' - 'results returned.'); + throw Exception( + 'Platform asset not found. ${platformAssets.length} ' + 'results returned.', + ); } + final platformAsset = platformAssets.single; final platformConfig = platformAsset.protocol.config; final String ticker = response.info.symbol; final tokenApi = await fetchTokenInfoFromApi(network, contractAddress); - - final coinId = '$ticker-${network.ticker}'; - final logoImageUrl = tokenApi?['image']?['large'] ?? + 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 newCoin = Asset( signMessagePrefix: null, id: AssetId( id: coinId, name: tokenApi?['name'] ?? ticker, symbol: AssetSymbol( - assetConfigId: '$ticker-${network.ticker}', + assetConfigId: coinId, coinGeckoId: tokenApi?['id'], coinPaprikaId: tokenApi?['id'], ), - chainId: AssetChainId(chainId: 0), + chainId: platformAsset.id.chainId, subClass: network, - derivationPath: '', + derivationPath: platformAsset.id.derivationPath, + parentId: platformAsset.id, ), isWalletOnly: false, - protocol: Erc20Protocol.fromJson({ - 'type': network.formatted, - 'chain_id': 0, - 'nodes': [], - 'swap_contract_address': - platformConfig.valueOrNull('swap_contract_address'), - 'fallback_swap_contract': - platformConfig.valueOrNull('fallback_swap_contract'), - 'protocol': { - 'protocol_data': { - 'platform': network.ticker, - 'contract_address': address, - }, - }, - 'logo_image_url': logoImageUrl, - 'explorer_url': platformConfig.valueOrNull('explorer_url'), - 'explorer_url_tx': - platformConfig.valueOrNull('explorer_url_tx'), - 'explorer_url_address': - platformConfig.valueOrNull('explorer_url_address'), - }).copyWith(isCustomToken: true), - ); - - AssetIcon.registerCustomIcon( - newCoin.id, - NetworkImage( - tokenApi?['image']?['large'] ?? - 'assets/coin_icons/png/${ticker.toLowerCase()}.png', + protocol: Erc20Protocol.fromJson(platformConfig).copyWithProtocolData( + coin: coinId, + type: network.tokenStandardSuffix, + chainId: platformChainId, + contractAddress: contractAddress, + platform: network.ticker, + logoImageUrl: logoImageUrl, + isCustomToken: true, ), ); + if (logoImageUrl != null && logoImageUrl.isNotEmpty) { + AssetIcon.registerCustomIcon(newCoin.id, NetworkImage(logoImageUrl)); + } + return newCoin; } @override Future importCustomToken(Asset asset) async { - await _coinsRepo.activateAssetsSync([asset]); + await _coinsRepo.activateAssetsSync([asset], maxRetryAttempts: 10); } Future?> fetchTokenInfoFromApi( CoinSubClass coinType, - String contractAddress, - ) async { + String contractAddress, { + Duration timeout = const Duration(seconds: 10), + }) async { final platform = getNetworkApiName(coinType); if (platform == null) { - log('Unsupported Image URL Network: $coinType'); + _log.warning('Unsupported Image URL Network: $coinType'); return null; } @@ -139,45 +155,85 @@ class KdfCustomTokenImportRepository implements ICustomTokenImportRepository { ); try { - final response = await http.get(url); + final response = await _httpClient + .get(url) + .timeout( + timeout, + onTimeout: () { + throw TimeoutException('Timeout fetching token data from $url'); + }, + ); final data = jsonDecode(response.body); return data; - } catch (e) { - log('Error fetching token data from $url: $e'); + } catch (e, s) { + _log.severe('Error fetching token data from $url', e, s); return null; } } - // this does not appear to match the coingecko id field in the coins config. - // notable differences are bep20, matic, and hrc20 - // these could possibly be mapped with another field, or it should be changed - // to the subclass formatted/ticker fields + // TODO: when migrating to the API, change this to fetch the coingecko + // asset_platforms: https://api.coingecko.com/api/v3/asset_platforms + @override String? getNetworkApiName(CoinSubClass coinType) { switch (coinType) { case CoinSubClass.erc20: - return 'ethereum'; // https://api.coingecko.com/api/v3/coins/ethereum/contract/0x56072C95FAA701256059aa122697B133aDEd9279 + return 'ethereum'; // https://api.coingecko.com/api/v3/coins/ethereum/contract/0x56072C95FAA701256059aa122697B133aDEd9279 case CoinSubClass.bep20: - return 'bsc'; // https://api.coingecko.com/api/v3/coins/bsc/contract/0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0 + return 'bsc'; // https://api.coingecko.com/api/v3/coins/bsc/contract/0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0 case CoinSubClass.arbitrum: - return 'arbitrum-one'; // https://api.coingecko.com/api/v3/coins/arbitrum-one/contract/0xCBeb19549054CC0a6257A77736FC78C367216cE7 + // TODO: re-enable once the ticker->Asset issue is figured out + // temporarily disabled to avoid confusion with failed activations + return null; + // return 'arbitrum-one'; // https://api.coingecko.com/api/v3/coins/arbitrum-one/contract/0xCBeb19549054CC0a6257A77736FC78C367216cE7 case CoinSubClass.avx20: - return 'avalanche'; // https://api.coingecko.com/api/v3/coins/avalanche/contract/0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E + return 'avalanche'; // https://api.coingecko.com/api/v3/coins/avalanche/contract/0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E case CoinSubClass.moonriver: - return 'moonriver'; // https://api.coingecko.com/api/v3/coins/moonriver/contract/0x0caE51e1032e8461f4806e26332c030E34De3aDb + return 'moonriver'; // https://api.coingecko.com/api/v3/coins/moonriver/contract/0x0caE51e1032e8461f4806e26332c030E34De3aDb case CoinSubClass.matic: - return 'polygon-pos'; // https://api.coingecko.com/api/v3/coins/polygon-pos/contract/0xdF7837DE1F2Fa4631D716CF2502f8b230F1dcc32 + return 'polygon-pos'; // https://api.coingecko.com/api/v3/coins/polygon-pos/contract/0xdF7837DE1F2Fa4631D716CF2502f8b230F1dcc32 case CoinSubClass.krc20: - return 'kcc'; // https://api.coingecko.com/api/v3/coins/kcc/contract/0x0039f574ee5cc39bdd162e9a88e3eb1f111baf48 + return 'kcc'; // https://api.coingecko.com/api/v3/coins/kcc/contract/0x0039f574ee5cc39bdd162e9a88e3eb1f111baf48 case CoinSubClass.qrc20: - return null; // Unable to find working url + return null; // Unable to find working url case CoinSubClass.ftm20: - return null; // Unable to find working url + return null; // Unable to find working url case CoinSubClass.hecoChain: - return null; // Unable to find working url + return null; // Unable to find working url case CoinSubClass.hrc20: - return null; // Unable to find working url + return null; // Unable to find working url default: return null; } } } + +extension on Erc20Protocol { + Erc20Protocol copyWithProtocolData({ + String? coin, + String? type, + String? contractAddress, + String? platform, + String? logoImageUrl, + bool? isCustomToken, + int? chainId, + }) { + final currentConfig = JsonMap.from(config); + currentConfig.addAll({ + if (coin != null) 'coin': coin, + if (type != null) 'type': type, + if (chainId != null) 'chain_id': chainId, + 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': { + 'protocol_data': { + 'contract_address': contractAddress ?? this.contractAddress, + 'platform': platform ?? subClass.ticker, + }, + }, + }); + return Erc20Protocol.fromJson(currentConfig); + } +} diff --git a/lib/bloc/feedback_form/feedback_form_bloc.dart b/lib/bloc/feedback_form/feedback_form_bloc.dart index 57f34f572c..01fdbd360b 100644 --- a/lib/bloc/feedback_form/feedback_form_bloc.dart +++ b/lib/bloc/feedback_form/feedback_form_bloc.dart @@ -15,6 +15,7 @@ class FeedbackFormBloc extends Bloc { on(_onMessageChanged); on(_onContactMethodChanged); on(_onContactDetailsChanged); + on(_onContactOptOutChanged); on(_onSubmitted); } @@ -29,10 +30,12 @@ class FeedbackFormBloc extends Bloc { event.type, state.contactMethod, ); - emit(state.copyWith( - feedbackType: event.type, - contactDetailsError: contactError, - )); + emit( + state.copyWith( + feedbackType: event.type, + contactDetailsError: contactError, + ), + ); } void _onMessageChanged( @@ -40,10 +43,12 @@ class FeedbackFormBloc extends Bloc { Emitter emit, ) { final text = _sanitizeInput(event.message); - emit(state.copyWith( - feedbackText: text, - feedbackTextError: _validateFeedbackText(text), - )); + emit( + state.copyWith( + feedbackText: text, + feedbackTextError: _validateFeedbackText(text), + ), + ); } void _onContactMethodChanged( @@ -55,8 +60,9 @@ class FeedbackFormBloc extends Bloc { state.feedbackType, event.method, ); - emit(state.copyWith( - contactMethod: event.method, contactDetailsError: error)); + emit( + state.copyWith(contactMethod: event.method, contactDetailsError: error), + ); } void _onContactDetailsChanged( @@ -72,6 +78,33 @@ class FeedbackFormBloc extends Bloc { emit(state.copyWith(contactDetails: details, contactDetailsError: error)); } + void _onContactOptOutChanged( + FeedbackFormContactOptOutChanged event, + Emitter emit, + ) { + if (event.optOut && !state.isSupportType) { + emit( + state.copyWith( + contactOptOut: true, + contactMethod: null, + contactDetails: '', + contactDetailsError: null, + ), + ); + return; + } + + final error = _validateContactDetails( + state.contactDetails, + state.feedbackType, + state.contactMethod, + contactOptOut: event.optOut, + ); + emit( + state.copyWith(contactOptOut: event.optOut, contactDetailsError: error), + ); + } + Future _onSubmitted( FeedbackFormSubmitted event, Emitter emit, @@ -86,10 +119,12 @@ class FeedbackFormBloc extends Bloc { if (state.feedbackType == null || feedbackErr != null || contactErr != null) { - emit(state.copyWith( - feedbackTextError: feedbackErr, - contactDetailsError: contactErr, - )); + emit( + state.copyWith( + feedbackTextError: feedbackErr, + contactDetailsError: contactErr, + ), + ); return; } @@ -99,17 +134,16 @@ class FeedbackFormBloc extends Bloc { feedbackType: state.feedbackType, feedbackText: state.feedbackText, contactMethod: state.contactMethod, - contactDetails: - state.contactDetails.isNotEmpty ? state.contactDetails : null, - ); - await _onSubmit( - data.toFormattedDescription(), - extras: data.toMap(), + contactDetails: state.contactDetails.isNotEmpty + ? state.contactDetails + : null, ); + await _onSubmit(data.toFormattedDescription(), extras: data.toMap()); emit(state.copyWith(status: FeedbackFormStatus.success)); } catch (e) { - emit(state.copyWith( - status: FeedbackFormStatus.failure, errorMessage: '$e')); + emit( + state.copyWith(status: FeedbackFormStatus.failure, errorMessage: '$e'), + ); } } @@ -129,19 +163,27 @@ class FeedbackFormBloc extends Bloc { String? _validateContactDetails( String value, FeedbackType? type, - ContactMethod? method, - ) { + ContactMethod? method, { + bool? contactOptOut, + }) { final trimmed = value.trim(); final hasMethod = method != null; final hasDetails = trimmed.isNotEmpty; + final optedOut = contactOptOut ?? state.contactOptOut; if (type == FeedbackType.support || type == FeedbackType.missingCoins) { if (!hasMethod || !hasDetails) { return LocaleKeys.contactRequiredError.tr(); } } else { - if ((hasMethod && !hasDetails) || (!hasMethod && hasDetails)) { - return LocaleKeys.contactRequiredError.tr(); + if (!optedOut) { + if (!hasMethod || !hasDetails) { + return LocaleKeys.contactRequiredError.tr(); + } + } else { + if ((hasMethod && !hasDetails) || (!hasMethod && hasDetails)) { + return LocaleKeys.contactRequiredError.tr(); + } } } @@ -187,9 +229,12 @@ class FeedbackFormBloc extends Bloc { .trim() .replaceAll(RegExp(r'<[^>]*>'), '') .replaceAll( - RegExp(r')<[^<]*)*<\/script>', - caseSensitive: false), - '') + RegExp( + r')<[^<]*)*<\/script>', + caseSensitive: false, + ), + '', + ) .replaceAll(RegExp(r'javascript:', caseSensitive: false), '') .replaceAll(RegExp(r'data:[^,]*script[^,]*,', caseSensitive: false), '') .replaceAll(RegExp(r'\n{3,}'), '\n\n'); diff --git a/lib/bloc/feedback_form/feedback_form_event.dart b/lib/bloc/feedback_form/feedback_form_event.dart index 1cb1ec640a..e8d5bd6186 100644 --- a/lib/bloc/feedback_form/feedback_form_event.dart +++ b/lib/bloc/feedback_form/feedback_form_event.dart @@ -43,6 +43,15 @@ class FeedbackFormContactDetailsChanged extends FeedbackFormEvent { List get props => [details]; } +class FeedbackFormContactOptOutChanged extends FeedbackFormEvent { + const FeedbackFormContactOptOutChanged(this.optOut); + + final bool optOut; + + @override + List get props => [optOut]; +} + class FeedbackFormSubmitted extends FeedbackFormEvent { const FeedbackFormSubmitted(); } diff --git a/lib/bloc/feedback_form/feedback_form_state.dart b/lib/bloc/feedback_form/feedback_form_state.dart index b7a63d3c2c..0b0b65817b 100644 --- a/lib/bloc/feedback_form/feedback_form_state.dart +++ b/lib/bloc/feedback_form/feedback_form_state.dart @@ -10,6 +10,7 @@ class FeedbackFormState extends Equatable { this.contactMethod, this.contactDetails = '', this.contactDetailsError, + this.contactOptOut = false, this.status = FeedbackFormStatus.initial, this.errorMessage, }); @@ -20,6 +21,7 @@ class FeedbackFormState extends Equatable { final ContactMethod? contactMethod; final String contactDetails; final String? contactDetailsError; + final bool contactOptOut; final FeedbackFormStatus status; final String? errorMessage; @@ -29,6 +31,19 @@ class FeedbackFormState extends Equatable { contactDetailsError == null && feedbackText.trim().isNotEmpty; + bool get isSubmitting => status == FeedbackFormStatus.submitting; + + bool get isSupportType => + feedbackType == FeedbackType.support || + feedbackType == FeedbackType.missingCoins; + + bool get isContactOptOutVisible => !isSupportType; + + bool get isContactRequired => isSupportType || !contactOptOut; + + bool get isContactRowDisabled => + isSubmitting || (isContactOptOutVisible && contactOptOut); + FeedbackFormState copyWith({ FeedbackType? feedbackType, String? feedbackText, @@ -36,6 +51,7 @@ class FeedbackFormState extends Equatable { ContactMethod? contactMethod, String? contactDetails, String? contactDetailsError, + bool? contactOptOut, FeedbackFormStatus? status, String? errorMessage, }) { @@ -46,6 +62,7 @@ class FeedbackFormState extends Equatable { contactMethod: contactMethod ?? this.contactMethod, contactDetails: contactDetails ?? this.contactDetails, contactDetailsError: contactDetailsError, + contactOptOut: contactOptOut ?? this.contactOptOut, status: status ?? this.status, errorMessage: errorMessage ?? this.errorMessage, ); @@ -53,13 +70,14 @@ class FeedbackFormState extends Equatable { @override List get props => [ - feedbackType, - feedbackText, - feedbackTextError, - contactMethod, - contactDetails, - contactDetailsError, - status, - errorMessage, - ]; + feedbackType, + feedbackText, + feedbackTextError, + contactMethod, + contactDetails, + contactDetailsError, + contactOptOut, + status, + errorMessage, + ]; } diff --git a/lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart b/lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart index a7882f1eb6..979b0012a9 100644 --- a/lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart +++ b/lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart @@ -8,7 +8,7 @@ import 'package:decimal/decimal.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:formz/formz.dart'; -import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart' show KomodoDefiSdk; import 'package:komodo_defi_types/komodo_defi_type_utils.dart' show ConstantBackoff, retry; import 'package:komodo_defi_types/komodo_defi_types.dart'; @@ -34,11 +34,11 @@ class FiatFormBloc extends Bloc { required KomodoDefiSdk sdk, int pubkeysMaxRetryAttempts = 20, Duration pubkeysRetryDelay = const Duration(milliseconds: 500), - }) : _fiatRepository = repository, - _sdk = sdk, - _pubkeysMaxRetryAttempts = pubkeysMaxRetryAttempts, - _pubkeysRetryDelay = pubkeysRetryDelay, - super(FiatFormState.initial()) { + }) : _fiatRepository = repository, + _sdk = sdk, + _pubkeysMaxRetryAttempts = pubkeysMaxRetryAttempts, + _pubkeysRetryDelay = pubkeysRetryDelay, + super(FiatFormState.initial()) { on(_onFiatSelected); // Use restartable here since this is called for auth changes, which // can happen frequently and we want to avoid race conditions. @@ -187,8 +187,9 @@ class FiatFormBloc extends Bloc { walletAddress: state.selectedAssetAddress!.address, paymentMethod: state.selectedPaymentMethod, sourceAmount: state.fiatAmount.value, - returnUrlOnSuccess: - BaseFiatProvider.successUrl(state.selectedAssetAddress!.address), + returnUrlOnSuccess: BaseFiatProvider.successUrl( + state.selectedAssetAddress!.address, + ), ); if (!newOrder.error.isNone) { @@ -199,11 +200,7 @@ class FiatFormBloc extends Bloc { var checkoutUrl = newOrder.checkoutUrl as String? ?? ''; if (checkoutUrl.isEmpty) { _log.severe('Invalid checkout URL received.'); - return emit( - state.copyWith( - fiatOrderStatus: FiatOrderStatus.failed, - ), - ); + return emit(state.copyWith(fiatOrderStatus: FiatOrderStatus.failed)); } // Only Ramp on web requires the intermediate html page to satisfy cors @@ -223,12 +220,7 @@ class FiatFormBloc extends Bloc { ); } catch (e, s) { _log.shout('Error submitting fiat form', e, s); - emit( - state.copyWith( - status: FiatFormStatus.failure, - checkoutUrl: '', - ), - ); + emit(state.copyWith(status: FiatFormStatus.failure, checkoutUrl: '')); } } @@ -320,17 +312,10 @@ class FiatFormBloc extends Bloc { FiatFormCoinAddressSelected event, Emitter emit, ) { - emit( - state.copyWith( - selectedAssetAddress: () => event.address, - ), - ); + emit(state.copyWith(selectedAssetAddress: () => event.address)); } - void _onModeUpdated( - FiatFormModeUpdated event, - Emitter emit, - ) { + void _onModeUpdated(FiatFormModeUpdated event, Emitter emit) { emit(state.copyWith(fiatMode: event.mode)); } @@ -341,8 +326,9 @@ class FiatFormBloc extends Bloc { try { final fiatList = await _fiatRepository.getFiatList(); final coinList = await _fiatRepository.getCoinList(); - coinList - .removeWhere((coin) => excludedAssetList.contains(coin.getAbbr())); + coinList.removeWhere( + (coin) => excludedAssetList.contains(coin.getAbbr()), + ); emit(state.copyWith(fiatList: fiatList, coinList: coinList)); } catch (e, s) { _log.shout('Error loading currency list', e, s); @@ -548,10 +534,7 @@ class FiatFormBloc extends Bloc { ); } catch (e, s) { _log.shout('Error updating payment methods', e, s); - return state.copyWith( - paymentMethods: [], - providerError: () => null, - ); + return state.copyWith(paymentMethods: [], providerError: () => null); } } diff --git a/lib/bloc/fiat/models/i_currency.dart b/lib/bloc/fiat/models/i_currency.dart index c6a3a5855a..e5940eca80 100644 --- a/lib/bloc/fiat/models/i_currency.dart +++ b/lib/bloc/fiat/models/i_currency.dart @@ -55,10 +55,10 @@ class FiatCurrency extends ICurrency { }); factory FiatCurrency.usd() => FiatCurrency( - symbol: 'USD', - name: 'United States Dollar', - minPurchaseAmount: Decimal.zero, - ); + symbol: 'USD', + name: 'United States Dollar', + minPurchaseAmount: Decimal.zero, + ); @override bool get isFiat => true; @@ -92,11 +92,11 @@ class CryptoCurrency extends ICurrency { }); factory CryptoCurrency.bitcoin() => CryptoCurrency( - symbol: 'BTC-segwit', - name: 'Bitcoin', - chainType: CoinType.utxo, - minPurchaseAmount: Decimal.zero, - ); + symbol: 'BTC-segwit', + name: 'Bitcoin', + chainType: CoinType.utxo, + minPurchaseAmount: Decimal.zero, + ); factory CryptoCurrency.fromAsset( Asset asset, { @@ -105,7 +105,7 @@ class CryptoCurrency extends ICurrency { final coin = asset.toCoin(); return CryptoCurrency( symbol: coin.id.id, - name: coin.name, + name: coin.displayName, chainType: coin.type, minPurchaseAmount: minPurchaseAmount, ); @@ -143,12 +143,12 @@ class CryptoCurrency extends ICurrency { return symbol; } - return '$symbol-${getCoinTypeName(chainType).replaceAll('-', '')}'; + return '$symbol-${getCoinTypeName(chainType, symbol).replaceAll('-', '')}'; } @override String formatNameShort() { - final coinType = ' (${getCoinTypeName(chainType)})'; + final coinType = ' (${getCoinTypeName(chainType, symbol)})'; return '$name$coinType'; } diff --git a/lib/bloc/fiat/ramp/ramp_fiat_provider.dart b/lib/bloc/fiat/ramp/ramp_fiat_provider.dart index 00d3fa8cbf..59db0a0b95 100644 --- a/lib/bloc/fiat/ramp/ramp_fiat_provider.dart +++ b/lib/bloc/fiat/ramp/ramp_fiat_provider.dart @@ -12,7 +12,7 @@ import 'package:web_dex/bloc/fiat/ramp/models/host_assets_config.dart'; import 'package:web_dex/bloc/fiat/ramp/models/onramp_purchase_quotation/onramp_purchase_quotation.dart'; import 'package:web_dex/bloc/fiat/ramp/ramp_api_utils.dart'; -const komodoLogoUrl = 'https://app.komodoplatform.com/icons/logo_icon.png'; +const komodoLogoUrl = 'https://app.komodoplatform.com/icons/logo_icon.webp'; class RampFiatProvider extends BaseFiatProvider { RampFiatProvider(); @@ -42,59 +42,48 @@ class RampFiatProvider extends BaseFiatProvider { String source, CryptoCurrency target, { String? sourceAmount, - }) => - apiRequest( - 'POST', - apiEndpoint, - queryParams: { - 'endpoint': '/onramp/quote/all', - }, - body: { - 'fiatCurrency': source, - 'cryptoAssetSymbol': getFullCoinCode(target), - // fiatValue has to be a number, and not a string. Force it to be a - // double here to ensure that it is in the expected format. - 'fiatValue': sourceAmount != null - ? Decimal.tryParse(sourceAmount)?.toDouble() - : null, - }, - ); + }) => apiRequest( + 'POST', + apiEndpoint, + queryParams: {'endpoint': '/onramp/quote/all'}, + body: { + 'fiatCurrency': source, + 'cryptoAssetSymbol': getFullCoinCode(target), + // fiatValue has to be a number, and not a string. Force it to be a + // double here to ensure that it is in the expected format. + 'fiatValue': sourceAmount != null + ? Decimal.tryParse(sourceAmount)?.toDouble() + : null, + }, + ); Future _getPricesWithPaymentMethod( String source, CryptoCurrency target, String sourceAmount, FiatPaymentMethod paymentMethod, - ) => - apiRequest( - 'POST', - apiEndpoint, - queryParams: { - 'endpoint': '/onramp/quote/all', - }, - body: { - 'fiatCurrency': source, - 'cryptoAssetSymbol': getFullCoinCode(target), - 'fiatValue': Decimal.tryParse(sourceAmount)?.toDouble(), - }, - ); + ) => apiRequest( + 'POST', + apiEndpoint, + queryParams: {'endpoint': '/onramp/quote/all'}, + body: { + 'fiatCurrency': source, + 'cryptoAssetSymbol': getFullCoinCode(target), + 'fiatValue': Decimal.tryParse(sourceAmount)?.toDouble(), + }, + ); - Future _getFiats() => apiRequest( - 'GET', - apiEndpoint, - queryParams: { - 'endpoint': '/currencies', - }, - ); + Future _getFiats() => + apiRequest('GET', apiEndpoint, queryParams: {'endpoint': '/currencies'}); Future _getCoins({String? currencyCode}) => apiRequest( - 'GET', - apiEndpoint, - queryParams: { - 'endpoint': '/assets', - if (currencyCode != null) 'currencyCode': currencyCode, - }, - ); + 'GET', + apiEndpoint, + queryParams: { + 'endpoint': '/assets', + if (currencyCode != null) 'currencyCode': currencyCode, + }, + ); @override Future> getFiatList() async { @@ -176,21 +165,28 @@ class RampFiatProvider extends BaseFiatProvider { final List paymentMethodsList = []; - final paymentMethodsFuture = - _getPaymentMethods(source, target, sourceAmount: sourceAmount); + final paymentMethodsFuture = _getPaymentMethods( + source, + target, + sourceAmount: sourceAmount, + ); final coinsFuture = _getCoins(currencyCode: source); final results = await Future.wait([paymentMethodsFuture, coinsFuture]); - final quoteResult = - RampQuoteResult.fromJson(results[0] as Map); - final coins = - HostAssetsConfig.fromJson(results[1] as Map); + final quoteResult = RampQuoteResult.fromJson( + results[0] as Map, + ); + final coins = HostAssetsConfig.fromJson( + results[1] as Map, + ); final asset = quoteResult.asset; final sourceAmountValue = Decimal.parse(sourceAmount); - quoteResult.paymentMethods - .forEach((String key, RampQuoteResultForPaymentMethod value) { + quoteResult.paymentMethods.forEach(( + String key, + RampQuoteResultForPaymentMethod value, + ) { _log.fine('Processing payment method key $key: $value'); final feeAmount = value.baseRampFee / sourceAmountValue; @@ -200,24 +196,24 @@ class RampFiatProvider extends BaseFiatProvider { 'transaction_fees': [ { 'fees': [ - { - 'amount': feeAmount.toDouble(), - }, + {'amount': feeAmount.toDouble()}, ], - } + }, ], 'transaction_limits': [ { 'fiat_code': source, - 'min': (asset.hasValidMinPurchaseAmount() - ? asset.minPurchaseAmount - : coins.minPurchaseAmount) - .toString(), - 'max': (asset.hasValidMaxPurchaseAmount() - ? asset.maxPurchaseAmount - : coins.maxPurchaseAmount) - .toString(), - } + 'min': + (asset.hasValidMinPurchaseAmount() + ? asset.minPurchaseAmount + : coins.minPurchaseAmount) + .toString(), + 'max': + (asset.hasValidMaxPurchaseAmount() + ? asset.maxPurchaseAmount + : coins.maxPurchaseAmount) + .toString(), + }, ], 'price_info': { 'coin_amount': getFormattedCryptoAmount( @@ -245,8 +241,9 @@ class RampFiatProvider extends BaseFiatProvider { if (fee >= Decimal.one) { throw ArgumentError.value(fee, 'fee', 'Fee ratio must be < 1'); } - return (price / (Decimal.one - fee)) - .toDecimal(scaleOnInfinitePrecision: scaleOnInfinitePrecision); + return (price / (Decimal.one - fee)).toDecimal( + scaleOnInfinitePrecision: scaleOnInfinitePrecision, + ); } String getFormattedCryptoAmount(String cryptoAmount, int decimals) { @@ -286,8 +283,10 @@ class RampFiatProvider extends BaseFiatProvider { final priceInfo = { 'fiat_code': source, 'coin_code': target.configSymbol, - 'spot_price_including_fee': - _getFeeAdjustedPrice(paymentMethod, price).toString(), + 'spot_price_including_fee': _getFeeAdjustedPrice( + paymentMethod, + price, + ).toString(), 'coin_amount': getFormattedCryptoAmount( response[paymentMethod.id]['cryptoAmount'] as String, asset['decimals'] as int, @@ -329,9 +328,11 @@ class RampFiatProvider extends BaseFiatProvider { // "swapAsset": fullAssetCode, // This limits the crypto asset list at the redirect page }; - final queryString = payload.entries.map((entry) { - return '${Uri.encodeComponent(entry.key)}=${Uri.encodeComponent(entry.value)}'; - }).join('&'); + final queryString = payload.entries + .map((entry) { + return '${Uri.encodeComponent(entry.key)}=${Uri.encodeComponent(entry.value)}'; + }) + .join('&'); final checkoutUrl = '$orderDomain?$queryString'; return FiatBuyOrderInfo.fromCheckoutUrl(checkoutUrl); diff --git a/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_bloc.dart b/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_bloc.dart index 45f07d86d9..5fd8e4ce22 100644 --- a/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_bloc.dart +++ b/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_bloc.dart @@ -10,6 +10,9 @@ import 'package:web_dex/bloc/settings/settings_repository.dart'; import 'package:web_dex/mm2/mm2_api/rpc/market_maker_bot/trade_coin_pair_config.dart'; import 'package:web_dex/mm2/mm2_api/rpc/rpc_error.dart'; import 'package:web_dex/mm2/mm2_api/rpc/rpc_error_type.dart'; +import 'package:web_dex/bloc/analytics/analytics_repo.dart'; +import 'package:web_dex/analytics/events/market_bot_events.dart'; +import 'package:get_it/get_it.dart'; part 'market_maker_bot_event.dart'; part 'market_maker_bot_state.dart'; @@ -22,9 +25,9 @@ class MarketMakerBotBloc MarketMakerBotBloc( MarketMakerBotRepository marketMaketBotRepository, MarketMakerBotOrderListRepository orderRepository, - ) : _botRepository = marketMaketBotRepository, - _orderRepository = orderRepository, - super(const MarketMakerBotState.initial()) { + ) : _botRepository = marketMaketBotRepository, + _orderRepository = orderRepository, + super(const MarketMakerBotState.initial()) { on( _onStartRequested, transformer: restartable(), @@ -65,6 +68,13 @@ class MarketMakerBotBloc emit(const MarketMakerBotState.running()); return; } + // Log bot error + GetIt.I().queueEvent( + MarketbotErrorEventData( + failureDetail: 'start_failed', + strategyType: 'simple', + ), + ); emit(const MarketMakerBotState.stopped().copyWith(error: e.toString())); } } @@ -82,9 +92,17 @@ class MarketMakerBotBloc ); emit(const MarketMakerBotState.stopped()); } catch (e) { + // Log bot error + GetIt.I().queueEvent( + MarketbotErrorEventData( + failureDetail: 'stop_failed', + strategyType: 'simple', + ), + ); emit( - const MarketMakerBotState.stopped() - .copyWith(error: 'Failed to stop the bot'), + const MarketMakerBotState.stopped().copyWith( + error: 'Failed to stop the bot', + ), ); } } @@ -103,8 +121,8 @@ class MarketMakerBotBloc // Cancel the order immediately to provide feedback to the user that // the bot is being updated, since the restart process may take some time. await _orderRepository.cancelOrders([event.tradePair]); - final Stream botStatusStream = - _botRepository.updateOrder(event.tradePair, botId: event.botId); + final Stream botStatusStream = _botRepository + .updateOrder(event.tradePair, botId: event.botId); await for (final botStatus in botStatusStream) { emit(state.copyWith(status: botStatus)); } @@ -116,9 +134,17 @@ class MarketMakerBotBloc return; } - final stoppingState = - const MarketMakerBotState.stopping().copyWith(error: e.toString()); + final stoppingState = const MarketMakerBotState.stopping().copyWith( + error: e.toString(), + ); emit(stoppingState); + // Log bot error + GetIt.I().queueEvent( + MarketbotErrorEventData( + failureDetail: 'update_failed', + strategyType: 'simple', + ), + ); await _botRepository.stop(botId: event.botId); emit(stoppingState.copyWith(status: MarketMakerBotStatus.stopped)); } @@ -144,8 +170,9 @@ class MarketMakerBotBloc // Remove the trade pairs from the stored settings after the orders have // been cancelled to prevent the lag between the orders being cancelled // and the trade pairs being removed from the settings. - await _botRepository - .removeTradePairsFromStoredSettings(event.tradePairs.toList()); + await _botRepository.removeTradePairsFromStoredSettings( + event.tradePairs.toList(), + ); } catch (e) { final isAlreadyStarted = e is RpcException && e.error.errorType == RpcErrorType.alreadyStarted; @@ -154,9 +181,17 @@ class MarketMakerBotBloc return; } - final stoppingState = - const MarketMakerBotState.stopping().copyWith(error: e.toString()); + final stoppingState = const MarketMakerBotState.stopping().copyWith( + error: e.toString(), + ); emit(stoppingState); + // Log bot error + GetIt.I().queueEvent( + MarketbotErrorEventData( + failureDetail: 'cancel_failed', + strategyType: 'simple', + ), + ); await _botRepository.stop(botId: event.botId); emit(stoppingState.copyWith(status: MarketMakerBotStatus.stopped)); } @@ -175,6 +210,13 @@ class MarketMakerBotBloc while (orders.any((order) => order.order != null)) { if (DateTime.now().difference(start) > timeout) { if (fatalTimeout) { + // Log bot error + GetIt.I().queueEvent( + MarketbotErrorEventData( + failureDetail: 'timeout_cancelling', + strategyType: 'simple', + ), + ); throw TimeoutException('Failed to cancel orders in time'); } return; diff --git a/lib/bloc/market_maker_bot/market_maker_order_list/market_maker_bot_order_list_repository.dart b/lib/bloc/market_maker_bot/market_maker_order_list/market_maker_bot_order_list_repository.dart index 586185b44a..728df249c3 100644 --- a/lib/bloc/market_maker_bot/market_maker_order_list/market_maker_bot_order_list_repository.dart +++ b/lib/bloc/market_maker_bot/market_maker_order_list/market_maker_bot_order_list_repository.dart @@ -44,8 +44,9 @@ class MarketMakerBotOrderListRepository { Future> getTradePairs() async { final settings = await _settingsRepository.loadSettings(); final configs = settings.marketMakerBotSettings.tradeCoinPairConfigs; - final makerOrders = (await _ordersService.getOrders()) - ?.where((order) => order.orderType == TradeSide.maker); + final makerOrders = (await _ordersService.getOrders())?.where( + (order) => order.orderType == TradeSide.maker, + ); final tradePairs = configs.map((TradeCoinPairConfig config) { final order = makerOrders @@ -87,7 +88,7 @@ class MarketMakerBotOrderListRepository { final baseCoinBalance = baseCoin == null ? Decimal.zero : _coinsRepository.lastKnownBalance(baseCoin.id)?.spendable ?? - Decimal.zero; + Decimal.zero; return baseCoinBalance.toRational() * Rational.parse(baseCoinBalance.toString()); } @@ -97,19 +98,24 @@ class MarketMakerBotOrderListRepository { TradeCoinPairConfig config, MyOrder? order, ) { - final double? baseUsdPrice = - _coinsRepository.getCoin(config.baseCoinId)?.usdPrice?.price; - final double? relUsdPrice = - _coinsRepository.getCoin(config.relCoinId)?.usdPrice?.price; + final Decimal? baseUsdPrice = _coinsRepository + .getCoin(config.baseCoinId) + ?.usdPrice + ?.price; + final Decimal? relUsdPrice = _coinsRepository + .getCoin(config.relCoinId) + ?.usdPrice + ?.price; final price = relUsdPrice != null && baseUsdPrice != null ? baseUsdPrice / relUsdPrice : null; Rational relAmount = Rational.zero; if (price != null) { - final double priceWithMargin = price * (1 + (config.margin / 100)); - final double amount = baseCoinAmount.toDouble() * priceWithMargin; - return Rational.parse(amount.toString()); + final Rational marginFraction = + Decimal.parse(config.margin.toString()) / Decimal.fromInt(100); + final Rational priceWithMargin = price * (Rational.one + marginFraction); + return baseCoinAmount * priceWithMargin; } return relAmount; diff --git a/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_bloc.dart b/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_bloc.dart index 1159f54040..b194e0a87c 100644 --- a/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_bloc.dart +++ b/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_bloc.dart @@ -42,9 +42,9 @@ class MarketMakerTradeFormBloc MarketMakerTradeFormBloc({ required DexRepository dexRepo, required CoinsRepo coinsRepo, - }) : _dexRepository = dexRepo, - _coinsRepo = coinsRepo, - super(MarketMakerTradeFormState.initial()) { + }) : _dexRepository = dexRepo, + _coinsRepo = coinsRepo, + super(MarketMakerTradeFormState.initial()) { on(_onSellCoinChanged); on(_onBuyCoinChanged); on(_onTradeVolumeChanged); @@ -83,6 +83,17 @@ class MarketMakerTradeFormBloc (state.maximumTradeVolume.value * sellCoinBalance).toString(), ); + // Calculate buy amount if applicable + CoinTradeAmountInput? newBuyAmount; + if (!identicalBuyAndSellCoins && state.buyCoin.value != null) { + final double buyAmountValue = _getBuyAmountFromSellAmount( + newSellAmount.value, + state.priceFromUsdWithMargin, + ); + newBuyAmount = CoinTradeAmountInput.dirty(buyAmountValue.toString()); + } + + // Emit immediately with new coin selection for fast UI update emit( state.copyWith( sellCoin: CoinSelectInput.dirty(event.sellCoin), @@ -90,29 +101,22 @@ class MarketMakerTradeFormBloc buyCoin: identicalBuyAndSellCoins ? const CoinSelectInput.dirty(null, -1) : state.buyCoin, + buyAmount: newBuyAmount, status: MarketMakerTradeFormStatus.success, ), ); - if (!identicalBuyAndSellCoins && state.buyCoin.value != null) { - final double newBuyAmount = _getBuyAmountFromSellAmount( - newSellAmount.value, - state.priceFromUsdWithMargin, - ); - emit( - state.copyWith( - buyAmount: CoinTradeAmountInput.dirty(newBuyAmount.toString()), - ), - ); - } - + // Activate coin before checking preimage + // TODO: consider removing this, as only enabled coins with a balance are + // displayed in the sell coins dropdown await _autoActivateCoin(event.sellCoin); + // Check for preimage errors using the current state asynchronously if (state.buyCoin.value != null) { final preImage = await _getPreimageData(state); - final preImageError = await _getPreImageError(preImage.error, state); - if (preImageError != MarketMakerTradeFormError.none) { - emit(state.copyWith(preImageError: preImageError)); + final error = await _getPreImageError(preImage.error, state); + if (error != MarketMakerTradeFormError.none) { + emit(state.copyWith(preImageError: error)); } } } @@ -127,6 +131,8 @@ class MarketMakerTradeFormBloc // here and pass that to the function, but that would require a lot of // code duplication and would be harder to maintain. final areBuyAndSellCoinsIdentical = event.buyCoin == state.sellCoin.value; + + // Emit immediately with new coin selection for fast UI update emit( state.copyWith( buyCoin: CoinSelectInput.dirty(event.buyCoin, -1), @@ -138,6 +144,7 @@ class MarketMakerTradeFormBloc ); await _autoActivateCoin(event.buyCoin); + // Buy coin does not have to have a balance, so set the minimum balance to // -1 to avoid the insufficient balance error final newBuyAmount = _getBuyAmountFromSellAmount( @@ -145,6 +152,7 @@ class MarketMakerTradeFormBloc state.priceFromUsdWithMargin, ); + // Emit updated buy amount emit( state.copyWith( buyAmount: newBuyAmount > 0 @@ -154,8 +162,10 @@ class MarketMakerTradeFormBloc ), ); + // Check for preimage errors asynchronously final preImage = await _getPreimageData(state); final preImageError = await _getPreImageError(preImage.error, state); + if (preImageError != MarketMakerTradeFormError.none) { emit(state.copyWith(preImageError: preImageError)); } @@ -172,15 +182,17 @@ class MarketMakerTradeFormBloc final maximumTradeVolume = double.tryParse(event.maximumTradeVolume.toString()) ?? 0.0; final newSellAmount = CoinTradeAmountInput.dirty( - (maximumTradeVolume * spendableBalance).toString(), - 0, - spendableBalance); + (maximumTradeVolume * spendableBalance).toString(), + 0, + spendableBalance, + ); final newBuyAmount = _getBuyAmountFromSellAmount( newSellAmount.value, state.priceFromUsdWithMargin, ); + // Emit immediately with new volume values for fast UI update emit( state.copyWith( sellAmount: newSellAmount, @@ -190,6 +202,7 @@ class MarketMakerTradeFormBloc ), ); + // Check for preimage errors asynchronously final preImage = await _getPreimageData(state); final preImageError = await _getPreImageError(preImage.error, state); final newSellAmountFromPreImage = await _getMaxSellAmountFromPreImage( @@ -198,12 +211,14 @@ class MarketMakerTradeFormBloc state.sellCoin, ); + // Emit error and adjusted sell amount if preimage validation fails if (preImageError != MarketMakerTradeFormError.none) { emit( state.copyWith( preImageError: preImageError, - sellAmount: - CoinTradeAmountInput.dirty(newSellAmountFromPreImage.toString()), + sellAmount: CoinTradeAmountInput.dirty( + newSellAmountFromPreImage.toString(), + ), ), ); } @@ -249,9 +264,7 @@ class MarketMakerTradeFormBloc Emitter emit, ) async { emit( - state.copyWith( - tradeMargin: TradeMarginInput.dirty(event.tradeMargin), - ), + state.copyWith(tradeMargin: TradeMarginInput.dirty(event.tradeMargin)), ); if (state.buyCoin.value != null) { @@ -458,9 +471,11 @@ class MarketMakerTradeFormBloc final requiredAmount = double.tryParse(preImageError.required) ?? 0; final sellCoinBalance = balance ?? BalanceInfo.zero(); - final newSellAmount = sellAmountValue - + final newSellAmount = + sellAmountValue - (requiredAmount - sellCoinBalance.spendable.toDouble()); - return newSellAmount; + // Clamp to minimum of 0 to prevent negative sell amounts + return newSellAmount.clamp(0, double.infinity); } return sellAmount.valueAsRational.toDouble(); diff --git a/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_state.dart b/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_state.dart index 72233b3d56..1c8e632cc9 100644 --- a/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_state.dart +++ b/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_state.dart @@ -97,7 +97,7 @@ class MarketMakerTradeFormState extends Equatable with FormzMixin { ? baseUsdPrice / relUsdPrice : null; - return price; + return price?.toDouble(); } /// The price of the trade pair derived from the USD price of the coins diff --git a/lib/bloc/nft_image/nft_image_bloc.dart b/lib/bloc/nft_image/nft_image_bloc.dart new file mode 100644 index 0000000000..1dd3c35ebb --- /dev/null +++ b/lib/bloc/nft_image/nft_image_bloc.dart @@ -0,0 +1,256 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart' show Equatable; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/shared/utils/ipfs_gateway_manager.dart'; + +part 'nft_image_event.dart'; +part 'nft_image_state.dart'; + +/// BLoC for managing NFT image loading with fallback mechanism +class NftImageBloc extends Bloc { + NftImageBloc({required IpfsGatewayManager ipfsGatewayManager}) + : _ipfsGatewayManager = ipfsGatewayManager, + super(const NftImageState()) { + on(_onImageLoadStarted); + on(_onImageLoadFailed); + on(_onImageLoadSucceeded); + on(_onImageRetryStarted); + on(_onImageCleared); + } + + final IpfsGatewayManager _ipfsGatewayManager; + + static const int maxRetryAttempts = 3; + static const Duration baseRetryDelay = Duration(seconds: 1); + + Timer? _retryTimer; + + /// Find the first working URL from the list + Future _findWorkingUrl(List urls, int startIndex) async { + return _ipfsGatewayManager.findWorkingUrl( + urls, + startIndex: startIndex, + onUrlTested: (url, success, errorMessage) { + if (!success) { + // Log failed attempts are handled by the gateway manager + // Additional logging can be done here if needed + } + }, + ); + } + + /// Detect media type from URL + static NftMediaType _detectMediaType(String url) { + final lowerUrl = url.toLowerCase(); + if (lowerUrl.endsWith('.svg')) return NftMediaType.svg; + if (lowerUrl.endsWith('.gif')) return NftMediaType.gif; + if (lowerUrl.endsWith('.mp4') || + lowerUrl.endsWith('.webm') || + lowerUrl.endsWith('.mov')) { + return NftMediaType.video; + } + return NftMediaType.image; + } + + /// Generates all possible URLs for the image including normalized URL and fallbacks + Future> _generateAllUrls(String imageUrl) async { + final List urls = []; + + // First, try to normalize the URL if it's an IPFS URL + final normalizedUrl = _ipfsGatewayManager.normalizeIpfsUrl(imageUrl); + if (normalizedUrl != null && normalizedUrl != imageUrl) { + urls.add(normalizedUrl); + } + + // Add the original URL if not already added + if (!urls.contains(imageUrl)) { + urls.add(imageUrl); + } + + // Generate IPFS gateway alternatives if it's an IPFS URL + if (IpfsGatewayManager.isIpfsUrl(imageUrl)) { + final ipfsUrls = await _ipfsGatewayManager.getReliableGatewayUrls( + imageUrl, + ); + // Add URLs that aren't already in the list + for (final url in ipfsUrls) { + if (!urls.contains(url)) { + urls.add(url); + } + } + } + + return urls; + } + + /// Handles the load image started event + Future _onImageLoadStarted( + NftImageLoadStarted event, + Emitter emit, + ) async { + _retryTimer?.cancel(); + + final allUrls = await _generateAllUrls(event.imageUrl); + final mediaType = _detectMediaType(event.imageUrl); + + if (allUrls.isEmpty) { + emit( + state.copyWith( + status: NftImageStatus.failure, + errorMessage: 'No URLs available to load', + mediaType: mediaType, + ), + ); + return; + } + + // Emit initial state with all URLs but no current URL yet + emit( + state.copyWith( + status: NftImageStatus.loading, + currentUrl: null, + currentUrlIndex: 0, + retryCount: 0, + allUrls: allUrls, + errorMessage: null, + isRetrying: false, + mediaType: mediaType, + ), + ); + + // Find the first working URL + final workingUrl = await _findWorkingUrl(allUrls, 0); + + if (workingUrl != null) { + final urlIndex = allUrls.indexOf(workingUrl); + emit( + state.copyWith( + status: NftImageStatus.loading, + currentUrl: workingUrl, + currentUrlIndex: urlIndex, + ), + ); + } else { + emit( + state.copyWith( + status: NftImageStatus.exhausted, + errorMessage: 'No accessible URLs found', + ), + ); + } + } + + /// Handles image load failure - try next URL immediately + Future _onImageLoadFailed( + NftImageLoadFailed event, + Emitter emit, + ) async { + // Log the failed attempt + _ipfsGatewayManager.logGatewayAttempt( + event.failedUrl, + false, + errorMessage: event.errorMessage, + ); + + // Try to find the next working URL + final nextWorkingUrl = await _findWorkingUrl( + state.allUrls, + state.currentUrlIndex + 1, + ); + + if (nextWorkingUrl != null && state.retryCount < maxRetryAttempts) { + final urlIndex = state.allUrls.indexOf(nextWorkingUrl); + emit( + state.copyWith( + status: NftImageStatus.loading, + currentUrl: nextWorkingUrl, + currentUrlIndex: urlIndex, + retryCount: state.retryCount + 1, + errorMessage: null, + isRetrying: false, + ), + ); + } else { + // All URLs exhausted or max retries reached + emit( + state.copyWith( + status: NftImageStatus.exhausted, + errorMessage: event.errorMessage ?? 'All image URLs failed to load', + isRetrying: false, + ), + ); + } + } + + /// Handles successful image load + Future _onImageLoadSucceeded( + NftImageLoadSucceeded event, + Emitter emit, + ) async { + // Return early if this URL is already successfully loaded + if (state.status == NftImageStatus.success && + state.currentUrl == event.loadedUrl) { + return; + } + + _retryTimer?.cancel(); + + // Log the successful attempt + _ipfsGatewayManager.logGatewayAttempt( + event.loadedUrl, + true, + loadTime: event.loadTime, + ); + + emit( + state.copyWith( + status: NftImageStatus.success, + currentUrl: event.loadedUrl, + errorMessage: null, + isRetrying: false, + ), + ); + } + + /// Handles manual retry started event (only used for failed states) + Future _onImageRetryStarted( + NftImageRetryStarted event, + Emitter emit, + ) async { + if (state.status == NftImageStatus.exhausted || + state.status == NftImageStatus.failure) { + // Try to find any working URL from the beginning + final workingUrl = await _findWorkingUrl(state.allUrls, 0); + + if (workingUrl != null) { + final urlIndex = state.allUrls.indexOf(workingUrl); + emit( + state.copyWith( + status: NftImageStatus.loading, + currentUrl: workingUrl, + currentUrlIndex: urlIndex, + retryCount: 0, + errorMessage: null, + isRetrying: false, + ), + ); + } + } + } + + /// Handles clear event + Future _onImageCleared( + NftImageCleared event, + Emitter emit, + ) async { + _retryTimer?.cancel(); + emit(const NftImageState()); + } + + @override + Future close() { + _retryTimer?.cancel(); + return super.close(); + } +} diff --git a/lib/bloc/nft_image/nft_image_event.dart b/lib/bloc/nft_image/nft_image_event.dart new file mode 100644 index 0000000000..9b557cfaa7 --- /dev/null +++ b/lib/bloc/nft_image/nft_image_event.dart @@ -0,0 +1,54 @@ +part of 'nft_image_bloc.dart'; + +/// Events for NFT image loading with fallback mechanism +abstract class NftImageEvent extends Equatable { + const NftImageEvent(); +} + +/// Event to start loading an image (bloc will generate fallback URLs) +class NftImageLoadStarted extends NftImageEvent { + const NftImageLoadStarted({required this.imageUrl}); + + final String imageUrl; + + @override + List get props => [imageUrl]; +} + +/// Event triggered when an image fails to load +class NftImageLoadFailed extends NftImageEvent { + const NftImageLoadFailed({required this.failedUrl, this.errorMessage}); + + final String failedUrl; + final String? errorMessage; + + @override + List get props => [failedUrl, errorMessage]; +} + +/// Event triggered when an image loads successfully +class NftImageLoadSucceeded extends NftImageEvent { + const NftImageLoadSucceeded({required this.loadedUrl, this.loadTime}); + + final String loadedUrl; + final Duration? loadTime; + + @override + List get props => [loadedUrl, loadTime]; +} + +/// Event to start retrying with the next URL in the fallback list +class NftImageRetryStarted extends NftImageEvent { + const NftImageRetryStarted(); + + @override + List get props => []; +} + +/// Event when the image loading state has been cleared +class NftImageCleared extends NftImageEvent { + const NftImageCleared(); + + @override + List get props => []; +} diff --git a/lib/bloc/nft_image/nft_image_state.dart b/lib/bloc/nft_image/nft_image_state.dart new file mode 100644 index 0000000000..e1bdca385d --- /dev/null +++ b/lib/bloc/nft_image/nft_image_state.dart @@ -0,0 +1,88 @@ +part of 'nft_image_bloc.dart'; + +/// Image loading states for NFT image fallback mechanism +enum NftImageStatus { initial, loading, success, retrying, exhausted, failure } + +/// NFT media types for display handling +enum NftMediaType { image, video, svg, gif, unknown } + +/// State for NFT image loading with fallback mechanism +class NftImageState extends Equatable { + const NftImageState({ + this.status = NftImageStatus.initial, + this.currentUrl, + this.currentUrlIndex = 0, + this.retryCount = 0, + this.allUrls = const [], + this.errorMessage, + this.isRetrying = false, + this.mediaType = NftMediaType.unknown, + }); + + final NftImageStatus status; + final String? currentUrl; + final int currentUrlIndex; + final int retryCount; + final List allUrls; + final String? errorMessage; + final bool isRetrying; + final NftMediaType mediaType; + + /// Whether there are more URLs to try + bool get hasMoreUrls => currentUrlIndex < allUrls.length - 1; + + /// Whether all URLs have been exhausted + bool get isExhausted => + currentUrlIndex >= allUrls.length - 1 && status == NftImageStatus.failure; + + /// The next URL to try + String? get nextUrl { + if (!hasMoreUrls) return null; + return allUrls[currentUrlIndex + 1]; + } + + /// Whether the widget should show a placeholder + bool get shouldShowPlaceholder => + status == NftImageStatus.exhausted || + (status == NftImageStatus.failure && !hasMoreUrls); + + /// Whether the widget is in a loading state + bool get isLoading => + status == NftImageStatus.loading || + status == NftImageStatus.retrying || + currentUrl == null; + + NftImageState copyWith({ + NftImageStatus? status, + String? currentUrl, + int? currentUrlIndex, + int? retryCount, + List? allUrls, + String? errorMessage, + bool? isRetrying, + NftMediaType? mediaType, + }) { + return NftImageState( + status: status ?? this.status, + currentUrl: currentUrl ?? this.currentUrl, + currentUrlIndex: currentUrlIndex ?? this.currentUrlIndex, + retryCount: retryCount ?? this.retryCount, + allUrls: allUrls ?? this.allUrls, + errorMessage: errorMessage, + isRetrying: isRetrying ?? this.isRetrying, + mediaType: mediaType ?? this.mediaType, + ); + } + + @override + List get props => [ + status, + currentUrl, + currentUrlIndex, + retryCount, + allUrls, + errorMessage, + isRetrying, + mediaType, + ]; +} diff --git a/lib/bloc/nft_transactions/nft_txn_repository.dart b/lib/bloc/nft_transactions/nft_txn_repository.dart index f8641cfda5..847bacee3b 100644 --- a/lib/bloc/nft_transactions/nft_txn_repository.dart +++ b/lib/bloc/nft_transactions/nft_txn_repository.dart @@ -99,7 +99,7 @@ class NftTxnRepository { final coins = _coinsRepo.getKnownCoins(); for (final abbr in coinAbbr) { final coin = coins.firstWhere((c) => c.abbr == abbr); - _abbrToUsdPrices[abbr] = coin.usdPrice?.price; + _abbrToUsdPrices[abbr] = coin.usdPrice?.price?.toDouble(); } } } diff --git a/lib/bloc/nft_withdraw/nft_withdraw_bloc.dart b/lib/bloc/nft_withdraw/nft_withdraw_bloc.dart index 6272ac8e69..c3c2c32bcf 100644 --- a/lib/bloc/nft_withdraw/nft_withdraw_bloc.dart +++ b/lib/bloc/nft_withdraw/nft_withdraw_bloc.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.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:web_dex/analytics/events/nft_events.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/bloc/nft_withdraw/nft_withdraw_repo.dart'; @@ -17,6 +18,9 @@ import 'package:web_dex/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transactio import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/nft.dart'; import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/bloc/analytics/analytics_repo.dart'; +import 'package:get_it/get_it.dart'; +import 'package:web_dex/shared/utils/extensions/kdf_user_extensions.dart'; part 'nft_withdraw_event.dart'; part 'nft_withdraw_state.dart'; @@ -27,10 +31,10 @@ class NftWithdrawBloc extends Bloc { required NftToken nft, required KomodoDefiSdk kdfSdk, required CoinsRepo coinsRepository, - }) : _repo = repo, - _coinsRepository = coinsRepository, - _kdfSdk = kdfSdk, - super(NftWithdrawFillState.initial(nft)) { + }) : _repo = repo, + _coinsRepository = coinsRepository, + _kdfSdk = kdfSdk, + super(NftWithdrawFillState.initial(nft)) { on(_onAddressChanged); on(_onAmountChanged); on(_onSend); @@ -80,21 +84,35 @@ class NftWithdrawBloc extends Bloc { return; } - final BaseError? amountError = - _validateAmount(amount, int.parse(nft.amount), nft.contractType); + final BaseError? amountError = _validateAmount( + amount, + int.parse(nft.amount), + nft.contractType, + ); if (amountError != null) { emit( - state.copyWith( - isSending: () => false, - amountError: () => amountError, - ), + state.copyWith(isSending: () => false, amountError: () => amountError), ); return; } + final walletType = (await _kdfSdk.auth.currentUser)?.type ?? 'unknown'; + try { + // Log initiated + GetIt.I().queueEvent( + NftTransferInitiatedEventData( + collectionName: nft.collectionName ?? nft.symbol ?? 'unknown', + tokenId: nft.tokenId, + hdType: walletType, + ), + ); + final WithdrawNftResponse response = await _repo.withdraw( - nft: nft, address: validatedAddress, amount: amount); + nft: nft, + address: validatedAddress, + amount: amount, + ); final NftTransactionDetails result = response.result; @@ -107,8 +125,27 @@ class NftWithdrawBloc extends Bloc { ), ); } on ApiError catch (e) { + // Log failure + GetIt.I().queueEvent( + NftTransferFailureEventData( + collectionName: + state.nft.collectionName ?? state.nft.symbol ?? 'unknown', + failureDetail: e.message, + hdType: walletType, + ), + ); + emit(state.copyWith(sendError: () => e, isSending: () => false)); } on TransportError catch (e) { + GetIt.I().queueEvent( + NftTransferFailureEventData( + collectionName: + state.nft.collectionName ?? state.nft.symbol ?? 'unknown', + failureDetail: e.message, + hdType: walletType, + ), + ); + emit(state.copyWith(sendError: () => e, isSending: () => false)); } on ParsingApiJsonError catch (e) { if (kDebugMode) { @@ -125,19 +162,29 @@ class NftWithdrawBloc extends Bloc { final state = this.state; if (state is! NftWithdrawConfirmState) return; - emit( - state.copyWith( - isSending: () => true, - sendError: () => null, - ), - ); + emit(state.copyWith(isSending: () => true, sendError: () => null)); final txDetails = state.txDetails; - final SendRawTransactionResponse response = - await _repo.confirmSend(txDetails.coin, txDetails.txHex); + final SendRawTransactionResponse response = await _repo.confirmSend( + txDetails.coin, + txDetails.txHex, + ); final BaseError? responseError = response.error; final String? txHash = response.txHash; + + final walletType = (await _kdfSdk.auth.currentUser)?.type ?? 'unknown'; + if (txHash == null) { + // Log failure + GetIt.I().queueEvent( + NftTransferFailureEventData( + collectionName: + state.nft.collectionName ?? state.nft.symbol ?? 'unknown', + failureDetail: responseError?.message ?? 'unknown', + hdType: walletType, + ), + ); + emit( state.copyWith( isSending: () => false, @@ -146,6 +193,19 @@ class NftWithdrawBloc extends Bloc { ), ); } else { + // Log success with fee + final fee = + double.tryParse(state.txDetails.feeDetails.feeValue ?? '0') ?? 0.0; + GetIt.I().queueEvent( + NftTransferSuccessEventData( + collectionName: + state.nft.collectionName ?? state.nft.symbol ?? 'unknown', + tokenId: state.txDetails.tokenId, + fee: fee, + hdType: walletType, + ), + ); + emit( NftWithdrawSuccessState( txHash: txHash, @@ -188,10 +248,7 @@ class NftWithdrawBloc extends Bloc { ); } - Future _validateAddress( - Coin coin, - String address, - ) async { + Future _validateAddress(Coin coin, String address) async { if (address.isEmpty) { throw TextError(error: LocaleKeys.invalidAddress.tr(args: [coin.abbr])); } @@ -305,11 +362,7 @@ class NftWithdrawBloc extends Bloc { ); add(NftWithdrawAddressChanged(mixedCaseAddress)); } catch (e) { - emit( - state.copyWith( - addressError: () => TextError(error: e.toString()), - ), - ); + emit(state.copyWith(addressError: () => TextError(error: e.toString()))); } } diff --git a/lib/bloc/taker_form/taker_bloc.dart b/lib/bloc/taker_form/taker_bloc.dart index b884f82514..727a70bde5 100644 --- a/lib/bloc/taker_form/taker_bloc.dart +++ b/lib/bloc/taker_form/taker_bloc.dart @@ -26,15 +26,21 @@ import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/model/trade_preimage.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/views/dex/dex_helpers.dart'; +import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; +import 'package:web_dex/analytics/events/transaction_events.dart'; +import 'package:web_dex/model/wallet.dart'; class TakerBloc extends Bloc { TakerBloc({ required DexRepository dexRepository, required CoinsRepo coinsRepository, required KomodoDefiSdk kdfSdk, - }) : _dexRepo = dexRepository, - _coinsRepo = coinsRepository, - super(TakerState.initial()) { + required AnalyticsBloc analyticsBloc, + }) : _dexRepo = dexRepository, + _coinsRepo = coinsRepository, + _sdk = kdfSdk, + _analyticsBloc = analyticsBloc, + super(TakerState.initial()) { _validator = TakerValidator( bloc: this, coinsRepo: _coinsRepo, @@ -75,12 +81,18 @@ class TakerBloc extends Bloc { if (event != null && state.step == TakerStep.confirm) { add(TakerBackButtonClick()); } + if (event == null) { + add(TakerClear()); + add(TakerSetDefaults()); + } _isLoggedIn = event != null; }); } final DexRepository _dexRepo; final CoinsRepo _coinsRepo; + final KomodoDefiSdk _sdk; + final AnalyticsBloc _analyticsBloc; Timer? _maxSellAmountTimer; bool _activatingAssets = false; bool _waitingForWallet = true; @@ -89,58 +101,78 @@ class TakerBloc extends Bloc { late StreamSubscription _authorizationSubscription; Future _onStartSwap( - TakerStartSwap event, Emitter emit) async { - emit(state.copyWith( - inProgress: () => true, - )); - - final SellResponse response = await _dexRepo.sell(SellRequest( - base: state.sellCoin!.abbr, - rel: state.selectedOrder!.coin, - volume: state.sellAmount!, - price: state.selectedOrder!.price, - orderType: SellBuyOrderType.fillOrKill, - )); + TakerStartSwap event, + Emitter emit, + ) async { + emit(state.copyWith(inProgress: () => true)); + + final int callStart = DateTime.now().millisecondsSinceEpoch; + final SellResponse response = await _dexRepo.sell( + SellRequest( + base: state.sellCoin!.abbr, + rel: state.selectedOrder!.coin, + volume: state.sellAmount!, + price: state.selectedOrder!.price, + orderType: SellBuyOrderType.fillOrKill, + ), + ); + final int durationMs = DateTime.now().millisecondsSinceEpoch - callStart; if (response.error != null) { add(TakerAddError(DexFormError(error: response.error!.message))); + + // Log swap failure analytics event for immediate RPC errors + final walletType = + (await _sdk.auth.currentUser)?.wallet.config.type.name ?? + 'unknown'; + _analyticsBloc.logEvent( + SwapFailedEventData( + asset: state.sellCoin!.abbr, + secondaryAsset: state.selectedOrder!.coin, + network: state.sellCoin!.protocolType, + secondaryNetwork: + _coinsRepo.getCoin(state.selectedOrder!.coin)?.protocolType ?? + 'unknown', + failureStage: 'order_submission', + hdType: walletType, + durationMs: durationMs, + ), + ); } final String? uuid = response.result?.uuid; - emit(state.copyWith( - inProgress: uuid == null ? () => false : null, - swapUuid: () => uuid, - )); + emit( + state.copyWith( + inProgress: uuid == null ? () => false : null, + swapUuid: () => uuid, + ), + ); } void _onBackButtonClick( TakerBackButtonClick event, Emitter emit, ) { - emit(state.copyWith( - step: () => TakerStep.form, - errors: () => [], - )); + emit(state.copyWith(step: () => TakerStep.form, errors: () => [])); } Future _onFormSubmitClick( TakerFormSubmitClick event, Emitter emit, ) async { - emit(state.copyWith( - inProgress: () => true, - autovalidate: () => true, - )); + emit(state.copyWith(inProgress: () => true, autovalidate: () => true)); await pauseWhile(() => _waitingForWallet || _activatingAssets); final bool isValid = await _validator.validate(); - emit(state.copyWith( - inProgress: () => false, - step: () => isValid ? TakerStep.confirm : TakerStep.form, - )); + emit( + state.copyWith( + inProgress: () => false, + step: () => isValid ? TakerStep.confirm : TakerStep.form, + ), + ); } void _onAmountButtonClick( @@ -150,8 +182,10 @@ class TakerBloc extends Bloc { final Rational? maxSellAmount = state.maxSellAmount; if (maxSellAmount == null) return; - final Rational sellAmount = - getFractionOfAmount(maxSellAmount, event.fraction); + final Rational sellAmount = getFractionOfAmount( + maxSellAmount, + event.fraction, + ); add(TakerSetSellAmount(sellAmount)); } @@ -160,8 +194,9 @@ class TakerBloc extends Bloc { TakerSellAmountChange event, Emitter emit, ) { - final Rational? amount = - event.value.isNotEmpty ? Rational.parse(event.value) : null; + final Rational? amount = event.value.isNotEmpty + ? Rational.parse(event.value) + : null; if (amount == state.sellAmount) return; @@ -172,13 +207,15 @@ class TakerBloc extends Bloc { TakerSetSellAmount event, Emitter emit, ) async { - emit(state.copyWith( - sellAmount: () => event.amount, - buyAmount: () => calculateBuyAmount( - selectedOrder: state.selectedOrder, - sellAmount: event.amount, + emit( + state.copyWith( + sellAmount: () => event.amount, + buyAmount: () => calculateBuyAmount( + selectedOrder: state.selectedOrder, + sellAmount: event.amount, + ), ), - )); + ); if (state.autovalidate) { await _validator.validateForm(); @@ -188,10 +225,7 @@ class TakerBloc extends Bloc { add(TakerUpdateFees()); } - void _onAddError( - TakerAddError event, - Emitter emit, - ) { + void _onAddError(TakerAddError event, Emitter emit) { final List errorsList = List.from(state.errors); if (errorsList.any((e) => e.error == event.error.error)) { // Avoid adding duplicate errors @@ -199,39 +233,46 @@ class TakerBloc extends Bloc { } errorsList.add(event.error); - emit(state.copyWith( - errors: () => errorsList, - )); + emit(state.copyWith(errors: () => errorsList)); } - void _onClearErrors( - TakerClearErrors event, - Emitter emit, - ) { - emit(state.copyWith( - errors: () => [], - )); + void _onClearErrors(TakerClearErrors event, Emitter emit) { + emit(state.copyWith(errors: () => [])); } Future _onSelectOrder( TakerSelectOrder event, Emitter emit, ) async { - final bool switchingCoin = state.selectedOrder != null && + final bool switchingCoin = + state.selectedOrder != null && event.order != null && state.selectedOrder!.coin != event.order!.coin; - emit(state.copyWith( - selectedOrder: () => event.order, - showOrderSelector: () => false, - buyAmount: () => calculateBuyAmount( - sellAmount: state.sellAmount, - selectedOrder: event.order, + emit( + state.copyWith( + selectedOrder: () => event.order, + showOrderSelector: () => false, + buyAmount: () => calculateBuyAmount( + sellAmount: state.sellAmount, + selectedOrder: event.order, + ), + tradePreimage: () => null, + errors: () => [], + autovalidate: switchingCoin ? () => false : null, ), - tradePreimage: () => null, - errors: () => [], - autovalidate: switchingCoin ? () => false : null, - )); + ); + + // Auto-fill the exact maker amount when an order is selected + final hasUserSetSellAmount = + (state.sellAmount ?? Rational.zero) > Rational.zero; + if (event.order != null && !hasUserSetSellAmount) { + final maxSellAmount = state.maxSellAmount ?? Rational.zero; + final desiredSellAmount = event.order!.maxVolume < maxSellAmount + ? event.order!.maxVolume + : maxSellAmount; + add(TakerSetSellAmount(desiredSellAmount)); + } if (!state.autovalidate) add(TakerVerifyOrderVolume()); @@ -256,20 +297,22 @@ class TakerBloc extends Bloc { ) async { if (event.setOnlyIfNotSet && state.sellCoin != null) return; - emit(state.copyWith( - sellCoin: () => event.coin, - showCoinSelector: () => false, - selectedOrder: () => null, - bestOrders: () => null, - sellAmount: () => null, - buyAmount: () => null, - tradePreimage: () => null, - maxSellAmount: () => null, - minSellAmount: () => null, - errors: () => [], - autovalidate: () => false, - availableBalanceState: () => AvailableBalanceState.initial, - )); + emit( + state.copyWith( + sellCoin: () => event.coin, + showCoinSelector: () => false, + selectedOrder: () => null, + bestOrders: () => null, + sellAmount: () => null, + buyAmount: () => null, + tradePreimage: () => null, + maxSellAmount: () => null, + minSellAmount: () => null, + errors: () => [], + autovalidate: () => false, + availableBalanceState: () => AvailableBalanceState.initial, + ), + ); add(TakerUpdateBestOrders(autoSelectOrderAbbr: event.autoSelectOrderAbbr)); @@ -284,9 +327,7 @@ class TakerBloc extends Bloc { ) async { final Coin? coin = state.sellCoin; - emit(state.copyWith( - bestOrders: () => null, - )); + emit(state.copyWith(bestOrders: () => null)); if (coin == null) return; @@ -301,8 +342,9 @@ class TakerBloc extends Bloc { /// Unsupported coins like ARRR cause downstream errors, so we need to /// remove them from the list here - bestOrders.result - ?.removeWhere((coinId, _) => excludedAssetList.contains(coinId)); + bestOrders.result?.removeWhere( + (coinId, _) => excludedAssetList.contains(coinId), + ); emit(state.copyWith(bestOrders: () => bestOrders)); @@ -319,10 +361,12 @@ class TakerBloc extends Bloc { TakerCoinSelectorClick event, Emitter emit, ) { - emit(state.copyWith( - showCoinSelector: () => !state.showCoinSelector, - showOrderSelector: () => false, - )); + emit( + state.copyWith( + showCoinSelector: () => !state.showCoinSelector, + showOrderSelector: () => false, + ), + ); } Future _onOrderSelectorClick( @@ -334,11 +378,13 @@ class TakerBloc extends Bloc { return; } - emit(state.copyWith( - showOrderSelector: () => !state.showOrderSelector, - showCoinSelector: () => false, - bestOrders: _haveBestOrders ? () => state.bestOrders : () => null, - )); + emit( + state.copyWith( + showOrderSelector: () => !state.showOrderSelector, + showCoinSelector: () => false, + bestOrders: _haveBestOrders ? () => state.bestOrders : () => null, + ), + ); if (state.showOrderSelector && !_haveBestOrders) { add(TakerUpdateBestOrders()); @@ -355,29 +401,24 @@ class TakerBloc extends Bloc { TakerCoinSelectorOpen event, Emitter emit, ) { - emit(state.copyWith( - showCoinSelector: () => event.isOpen, - )); + emit(state.copyWith(showCoinSelector: () => event.isOpen)); } void _onOrderSelectorOpen( TakerOrderSelectorOpen event, Emitter emit, ) { - emit(state.copyWith( - showOrderSelector: () => event.isOpen, - )); + emit(state.copyWith(showOrderSelector: () => event.isOpen)); } - void _onClear( - TakerClear event, - Emitter emit, - ) { + void _onClear(TakerClear event, Emitter emit) { _maxSellAmountTimer?.cancel(); - emit(TakerState.initial().copyWith( - availableBalanceState: () => AvailableBalanceState.unavailable, - )); + emit( + TakerState.initial().copyWith( + availableBalanceState: () => AvailableBalanceState.unavailable, + ), + ); } void _subscribeMaxSellAmount() { @@ -399,29 +440,59 @@ class TakerBloc extends Bloc { } if (state.availableBalanceState == AvailableBalanceState.initial || event.setLoadingStatus) { - emitter(state.copyWith( - availableBalanceState: () => AvailableBalanceState.loading)); + emitter( + state.copyWith( + availableBalanceState: () => AvailableBalanceState.loading, + ), + ); + } + + // Required here because of the manual RPC calls that bypass the sdk + final activeAssets = await _sdk.assets.getActivatedAssets(); + final isAssetActive = activeAssets.any( + (asset) => asset.id == state.sellCoin!.id, + ); + if (!isAssetActive) { + // Intentionally leave the state as loading so that a spinner is shown + // instead of a "0.00" balance hinting that the asset is active when it + // is not. + if (state.availableBalanceState != AvailableBalanceState.loading) { + emitter( + state.copyWith( + availableBalanceState: () => AvailableBalanceState.loading, + ), + ); + } + return; } if (!_isLoggedIn) { - emitter(state.copyWith( - availableBalanceState: () => AvailableBalanceState.unavailable)); + emitter( + state.copyWith( + availableBalanceState: () => AvailableBalanceState.unavailable, + ), + ); } else { - Rational? maxSellAmount = - await _dexRepo.getMaxTakerVolume(state.sellCoin!.abbr); + Rational? maxSellAmount = await _dexRepo.getMaxTakerVolume( + state.sellCoin!.abbr, + ); if (maxSellAmount != null) { - emitter(state.copyWith( - maxSellAmount: () => maxSellAmount, - availableBalanceState: () => AvailableBalanceState.success, - )); + emitter( + state.copyWith( + maxSellAmount: () => maxSellAmount, + availableBalanceState: () => AvailableBalanceState.success, + ), + ); } else { maxSellAmount = await _frequentlyGetMaxTakerVolume(); - emitter(state.copyWith( - maxSellAmount: () => maxSellAmount, - availableBalanceState: maxSellAmount == null - ? () => AvailableBalanceState.failure - : () => AvailableBalanceState.success, - )); + emitter( + state.copyWith( + maxSellAmount: () => maxSellAmount, + availableBalanceState: maxSellAmount == null + ? () => AvailableBalanceState.failure + : () => AvailableBalanceState.success, + ), + ); } } } @@ -433,11 +504,10 @@ class TakerBloc extends Bloc { try { return await retry( () => _dexRepo.getMaxTakerVolume(abbr), - maxAttempts: 5, + maxAttempts: 3, backoffStrategy: LinearBackoff( - initialDelay: const Duration(seconds: 2), - increment: const Duration(seconds: 2), - maxDelay: const Duration(seconds: 10), + initialDelay: const Duration(milliseconds: 500), + maxDelay: const Duration(seconds: 2), ), ); } catch (_) { @@ -451,27 +521,22 @@ class TakerBloc extends Bloc { ) async { if (state.sellCoin == null) return; if (!_isLoggedIn) { - emit(state.copyWith( - minSellAmount: () => null, - )); + emit(state.copyWith(minSellAmount: () => null)); return; } - final Rational? minSellAmount = - await _dexRepo.getMinTradingVolume(state.sellCoin!.abbr); + final Rational? minSellAmount = await _dexRepo.getMinTradingVolume( + state.sellCoin!.abbr, + ); - emit(state.copyWith( - minSellAmount: () => minSellAmount, - )); + emit(state.copyWith(minSellAmount: () => minSellAmount)); } Future _onUpdateFees( TakerUpdateFees event, Emitter emit, ) async { - emit(state.copyWith( - tradePreimage: () => null, - )); + emit(state.copyWith(tradePreimage: () => null)); if (!_validator.canRequestPreimage) return; @@ -479,10 +544,7 @@ class TakerBloc extends Bloc { add(TakerSetPreimage(preimageData.data)); } - void _onSetPreimage( - TakerSetPreimage event, - Emitter emit, - ) { + void _onSetPreimage(TakerSetPreimage event, Emitter emit) { emit(state.copyWith(tradePreimage: () => event.tradePreimage)); } @@ -496,8 +558,12 @@ class TakerBloc extends Bloc { state.sellAmount, ); } catch (e, s) { - log(e.toString(), - trace: s, path: 'taker_bloc::_getFeesData', isError: true); + log( + e.toString(), + trace: s, + path: 'taker_bloc::_getFeesData', + isError: true, + ); return DataFromService(error: TextError(error: 'Failed to request fees')); } } @@ -506,8 +572,10 @@ class TakerBloc extends Bloc { if (abbr == null || !_isLoggedIn) return; _activatingAssets = true; - final List activationErrors = - await activateCoinIfNeeded(abbr, _coinsRepo); + final List activationErrors = await activateCoinIfNeeded( + abbr, + _coinsRepo, + ); _activatingAssets = false; if (activationErrors.isNotEmpty) { @@ -515,19 +583,11 @@ class TakerBloc extends Bloc { } } - void _onSetInProgress( - TakerSetInProgress event, - Emitter emit, - ) { - emit(state.copyWith( - inProgress: () => event.value, - )); + void _onSetInProgress(TakerSetInProgress event, Emitter emit) { + emit(state.copyWith(inProgress: () => event.value)); } - void _onSetWalletReady( - TakerSetWalletIsReady event, - Emitter _, - ) { + void _onSetWalletReady(TakerSetWalletIsReady event, Emitter _) { _waitingForWallet = !event.ready; } @@ -539,10 +599,7 @@ class TakerBloc extends Bloc { } Future _onReInit(TakerReInit event, Emitter emit) async { - emit(state.copyWith( - errors: () => [], - autovalidate: () => false, - )); + emit(state.copyWith(errors: () => [], autovalidate: () => false)); await _autoActivateCoin(state.sellCoin?.abbr); await _autoActivateCoin(state.selectedOrder?.coin); } diff --git a/lib/bloc/taker_form/taker_validator.dart b/lib/bloc/taker_form/taker_validator.dart index e52843a028..e636fd59fc 100644 --- a/lib/bloc/taker_form/taker_validator.dart +++ b/lib/bloc/taker_form/taker_validator.dart @@ -32,7 +32,7 @@ class TakerValidator { _coinsRepo = coinsRepo, _dexRepo = dexRepo, _sdk = sdk, - add = bloc.add; + add = bloc.add; final TakerBloc _bloc; final CoinsRepo _coinsRepo; @@ -316,7 +316,7 @@ class TakerValidator { } DexFormError _coinNotActiveError(String abbr) { - return DexFormError(error: '$abbr is not active.'); + return DexFormError(error: LocaleKeys.coinIsNotActive.tr(args: [abbr])); } DexFormError _selectSellCoinError() => diff --git a/lib/bloc/trading_status/app_geo_status.dart b/lib/bloc/trading_status/app_geo_status.dart new file mode 100644 index 0000000000..ace28a6a37 --- /dev/null +++ b/lib/bloc/trading_status/app_geo_status.dart @@ -0,0 +1,28 @@ +import 'package:equatable/equatable.dart' show Equatable; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/trading_status/disallowed_feature.dart'; + +/// Structured status returned by the bouncer service. +class AppGeoStatus extends Equatable { + const AppGeoStatus({ + this.disallowedAssets = const {}, + this.disallowedFeatures = const {}, + }); + + /// Assets that are disallowed in the current geo location. + final Set disallowedAssets; + + /// Features that are disallowed in the current geo location. + final Set disallowedFeatures; + + /// Whether trading is enabled based on the current geo status. + bool get tradingEnabled => + !disallowedFeatures.contains(DisallowedFeature.trading); + + bool isAssetBlocked(AssetId asset) { + return disallowedAssets.contains(asset); + } + + @override + List get props => [disallowedAssets, disallowedFeatures]; +} diff --git a/lib/bloc/trading_status/disallowed_feature.dart b/lib/bloc/trading_status/disallowed_feature.dart new file mode 100644 index 0000000000..d968ec28a3 --- /dev/null +++ b/lib/bloc/trading_status/disallowed_feature.dart @@ -0,0 +1,12 @@ +enum DisallowedFeature { + trading; + + static DisallowedFeature parse(String value) { + switch (value.toUpperCase()) { + case 'TRADING': + return DisallowedFeature.trading; + default: + throw ArgumentError.value(value, 'value', 'Invalid disallowed feature'); + } + } +} diff --git a/lib/bloc/trading_status/trading_status_api_provider.dart b/lib/bloc/trading_status/trading_status_api_provider.dart new file mode 100644 index 0000000000..2d39068b08 --- /dev/null +++ b/lib/bloc/trading_status/trading_status_api_provider.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:web_dex/shared/constants.dart'; + +/// Provider responsible for making API calls to trading status endpoints. +class TradingStatusApiProvider { + TradingStatusApiProvider({http.Client? httpClient, Duration? timeout}) + : _httpClient = httpClient ?? http.Client(), + _timeout = timeout ?? const Duration(seconds: 10); + + final http.Client _httpClient; + final Duration _timeout; + final Logger _log = Logger('TradingStatusApiProvider'); + + static const String _apiKeyHeader = 'X-KW-KEY'; + + /// Fetches trading status from the geo blocker API. + /// + /// Throws [TimeoutException] on timeout. + /// Throws [http.ClientException] on HTTP client errors. + /// Throws [FormatException] on JSON parsing errors. + Future> fetchGeoStatus({required String apiKey}) async { + _log.fine('Fetching geo status from API'); + + final uri = Uri.parse(geoBlockerApiUrl); + final headers = {_apiKeyHeader: apiKey}; + + try { + final response = await _httpClient + .post(uri, headers: headers) + .timeout(_timeout); + + _log.fine('Geo status API response: ${response.statusCode}'); + + if (response.statusCode != 200) { + _log.warning('Geo status API returned status ${response.statusCode}'); + throw http.ClientException( + 'API returned status ${response.statusCode}', + uri, + ); + } + + final data = json.decode(response.body) as Map; + _log.fine('Successfully parsed geo status response'); + return data; + } on TimeoutException catch (e) { + _log.warning('Geo status API request timed out: $e'); + rethrow; + } on http.ClientException catch (e) { + _log.warning('HTTP client error fetching geo status: ${e.message}'); + rethrow; + } on FormatException catch (e) { + _log.severe('Failed to parse geo status JSON response: $e'); + rethrow; + } + } + + /// Fetches trading blacklist for testing purposes. + /// + /// Throws [TimeoutException] on timeout. + /// Throws [http.ClientException] on HTTP client errors. + Future fetchTradingBlacklist() async { + _log.fine('Fetching trading blacklist for testing'); + + final uri = Uri.parse(tradingBlacklistUrl); + + try { + final response = await _httpClient + .post(uri, headers: const {}) + .timeout(_timeout); + + _log.fine('Trading blacklist API response: ${response.statusCode}'); + return response; + } on TimeoutException catch (e) { + _log.warning('Trading blacklist API request timed out: $e'); + rethrow; + } on http.ClientException catch (e) { + _log.warning( + 'HTTP client error fetching trading blacklist: ${e.message}', + ); + rethrow; + } + } + + void dispose() { + _httpClient.close(); + } +} diff --git a/lib/bloc/trading_status/trading_status_bloc.dart b/lib/bloc/trading_status/trading_status_bloc.dart index 9dcf613459..af91c31bcc 100644 --- a/lib/bloc/trading_status/trading_status_bloc.dart +++ b/lib/bloc/trading_status/trading_status_bloc.dart @@ -1,32 +1,63 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; -import 'trading_status_repository.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/trading_status/disallowed_feature.dart'; +import 'package:web_dex/bloc/trading_status/app_geo_status.dart'; +import 'trading_status_service.dart'; part 'trading_status_event.dart'; part 'trading_status_state.dart'; class TradingStatusBloc extends Bloc { - TradingStatusBloc(this._repository) : super(TradingStatusInitial()) { + TradingStatusBloc(this._service) : super(TradingStatusInitial()) { on(_onCheckRequested); + on(_onWatchStarted); } - final TradingStatusRepository _repository; + final TradingStatusService _service; - // TODO (@takenagain): Retry periodically if the failure was caused by a - // network issue. Future _onCheckRequested( TradingStatusCheckRequested event, Emitter emit, ) async { emit(TradingStatusLoadInProgress()); try { - final enabled = await _repository.isTradingEnabled(); - emit(enabled ? TradingEnabled() : TradingDisabled()); - - // This catch will never be triggered by the repository. This will require - // changes to meet the "TODO" above. + final status = await _service.refreshStatus(); + emit( + TradingStatusLoadSuccess( + disallowedAssets: status.disallowedAssets, + disallowedFeatures: status.disallowedFeatures, + ), + ); } catch (_) { emit(TradingStatusLoadFailure()); } } + + Future _onWatchStarted( + TradingStatusWatchStarted event, + Emitter emit, + ) async { + emit(TradingStatusLoadInProgress()); + // Seed immediately with cached status if available; continue with stream. + try { + final status = _service.currentStatus; + emit( + TradingStatusLoadSuccess( + disallowedAssets: status.disallowedAssets, + disallowedFeatures: status.disallowedFeatures, + ), + ); + } catch (_) { + // Service not initialized yet; will emit once stream produces data. + } + await emit.forEach( + _service.statusStream, + onData: (AppGeoStatus status) => TradingStatusLoadSuccess( + disallowedAssets: status.disallowedAssets, + disallowedFeatures: status.disallowedFeatures, + ), + onError: (error, stackTrace) => TradingStatusLoadFailure(), + ); + } } diff --git a/lib/bloc/trading_status/trading_status_event.dart b/lib/bloc/trading_status/trading_status_event.dart index 77fdfd8971..ad033df8d9 100644 --- a/lib/bloc/trading_status/trading_status_event.dart +++ b/lib/bloc/trading_status/trading_status_event.dart @@ -5,4 +5,7 @@ abstract class TradingStatusEvent extends Equatable { List get props => []; } -class TradingStatusCheckRequested extends TradingStatusEvent {} +final class TradingStatusCheckRequested extends TradingStatusEvent {} + +/// Event emitted when the bloc should start watching trading status continuously +final class TradingStatusWatchStarted extends TradingStatusEvent {} diff --git a/lib/bloc/trading_status/trading_status_repository.dart b/lib/bloc/trading_status/trading_status_repository.dart index 918d7cf2bb..9027e80de9 100644 --- a/lib/bloc/trading_status/trading_status_repository.dart +++ b/lib/bloc/trading_status/trading_status_repository.dart @@ -1,55 +1,196 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; + +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; -import 'package:web_dex/shared/constants.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:logging/logging.dart'; +import 'package:web_dex/bloc/trading_status/app_geo_status.dart'; +import 'package:web_dex/bloc/trading_status/disallowed_feature.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_api_provider.dart'; class TradingStatusRepository { - TradingStatusRepository({http.Client? httpClient, Duration? timeout}) - : _httpClient = httpClient ?? http.Client(), - _timeout = timeout ?? const Duration(seconds: 10); + TradingStatusRepository(this._sdk, {TradingStatusApiProvider? apiProvider}) + : _apiProvider = apiProvider ?? TradingStatusApiProvider(); - final http.Client _httpClient; - final Duration _timeout; + final TradingStatusApiProvider _apiProvider; + final Logger _log = Logger('TradingStatusRepository'); + final KomodoDefiSdk _sdk; - Future isTradingEnabled({bool? forceFail}) async { + /// Fetches geo status and computes trading availability. + /// + /// Rules: + /// - If GEO_BLOCK=disabled, trading is enabled. + /// - Otherwise, trading is disabled if disallowed_features contains 'TRADING'. + Future fetchStatus({bool? forceFail}) async { try { - final apiKey = const String.fromEnvironment('FEEDBACK_API_KEY'); + if (_isGeoBlockDisabled()) { + _log.info('GEO_BLOCK is disabled. Trading enabled.'); + return const AppGeoStatus(); + } + final bool shouldFail = forceFail ?? false; + final String apiKey = _readFeedbackApiKey(); if (apiKey.isEmpty && !shouldFail) { - debugPrint('FEEDBACK_API_KEY not found. Trading disabled.'); - return false; + _log.warning('FEEDBACK_API_KEY not found. Trading disabled.'); + return const AppGeoStatus( + disallowedFeatures: {DisallowedFeature.trading}, + ); } - late final Uri uri; - final headers = {}; - + late final JsonMap data; if (shouldFail) { - uri = Uri.parse(tradingBlacklistUrl); - } else { - uri = Uri.parse(geoBlockerApiUrl); - headers['X-KW-KEY'] = apiKey; + final res = await _apiProvider.fetchTradingBlacklist(); + return AppGeoStatus( + disallowedFeatures: res.statusCode == 200 + ? const {} + : const {DisallowedFeature.trading}, + ); } - final res = - await _httpClient.post(uri, headers: headers).timeout(_timeout); + data = await _apiProvider.fetchGeoStatus(apiKey: apiKey); - if (shouldFail) { - return res.statusCode == 200; + final featuresParsed = _parseFeatures(data); + final Set disallowedAssets = _parseAssets(data); + + // If the API omitted the disallowed_features field entirely, + // block trading by default to be conservative. + if (!featuresParsed.hasFeatures) { + _log.warning( + 'disallowed_features missing in response. Blocking trading.', + ); + return AppGeoStatus( + disallowedAssets: disallowedAssets, + disallowedFeatures: const { + DisallowedFeature.trading, + }, + ); } - if (res.statusCode != 200) return false; - final JsonMap data = jsonFromString(res.body); - return !(data.valueOrNull('blocked') ?? true); - } catch (_) { - debugPrint('Network error: Trading status check failed'); - // Block trading features on network failure - return false; + return AppGeoStatus( + disallowedAssets: disallowedAssets, + disallowedFeatures: featuresParsed.features, + ); + } on Exception catch (e, s) { + _log.severe('Unexpected error during trading status check', e, s); + return const AppGeoStatus( + disallowedFeatures: {DisallowedFeature.trading}, + ); + } + } + + /// Backward-compatible helper for existing call sites. + Future isTradingEnabled({bool? forceFail}) async { + final status = await fetchStatus(forceFail: forceFail); + return status.tradingEnabled; + } + + /// Creates a stream that periodically polls for trading status using + /// Stream.periodic with fault tolerance. + /// + /// The stream emits immediately with the first status check, then continues + /// polling at the configured interval. Uses exponential backoff for error retry delays. + Stream watchTradingStatus({ + Duration pollingInterval = const Duration(minutes: 1), + BackoffStrategy? backoffStrategy, + bool? forceFail, + }) async* { + _log.info('Starting trading status polling stream'); + + backoffStrategy ??= ExponentialBackoff( + initialDelay: const Duration(seconds: 1), + maxDelay: const Duration(minutes: 5), + withJitter: true, + ); + + var consecutiveFailures = 0; + var currentDelay = Duration.zero; + + // Emit first status immediately + try { + final status = await fetchStatus(forceFail: forceFail); + yield status; + } catch (e) { + _log.warning('Error in initial trading status fetch: $e'); + yield const AppGeoStatus( + disallowedFeatures: {DisallowedFeature.trading}, + ); + } + + // Use Stream.periodic for clean, reliable polling + await for (final _ in Stream.periodic(pollingInterval)) { + try { + final status = await fetchStatus(forceFail: forceFail); + yield status; + + // Reset failure tracking on successful fetch + if (consecutiveFailures > 0) { + consecutiveFailures = 0; + currentDelay = Duration.zero; + _log.info('Trading status fetch recovered, resuming normal polling'); + } + } catch (e) { + _log.warning('Error in trading status fetch: $e'); + yield const AppGeoStatus( + disallowedFeatures: {DisallowedFeature.trading}, + ); + + // Apply exponential backoff delay for consecutive failures + currentDelay = backoffStrategy.nextDelay( + consecutiveFailures, + currentDelay, + ); + consecutiveFailures++; + + _log.info( + 'Backing off for ${currentDelay.inMilliseconds}ms (attempt $consecutiveFailures)', + ); + + // Add backoff delay before next poll + await Future.delayed(currentDelay); + } + } + } + + // --- Configuration helpers ------------------------------------------------- + String _readGeoBlockFlag() => const String.fromEnvironment('GEO_BLOCK'); + String _readFeedbackApiKey() => + const String.fromEnvironment('FEEDBACK_API_KEY'); + bool _isGeoBlockDisabled() => _readGeoBlockFlag() == 'disabled'; + + // --- Parsing helpers ------------------------------------------------------- + + ({Set features, bool hasFeatures}) _parseFeatures( + JsonMap data, + ) { + final List? raw = data.valueOrNull>( + 'disallowed_features', + ); + final Set parsed = raw == null + ? {} + : raw.map(DisallowedFeature.parse).toSet(); + return (features: parsed, hasFeatures: raw != null); + } + + Set _parseAssets(JsonMap data) { + final List? raw = data.valueOrNull>( + 'disallowed_assets', + ); + if (raw == null) return const {}; + + final Set out = {}; + for (final symbol in raw) { + try { + final assets = _sdk.assets.findAssetsByConfigId(symbol); + out.addAll(assets.map((a) => a.id)); + } catch (e, s) { + _log.warning('Failed to resolve asset "$symbol"', e, s); + } } + return out; } void dispose() { - _httpClient.close(); + _apiProvider.dispose(); } } diff --git a/lib/bloc/trading_status/trading_status_service.dart b/lib/bloc/trading_status/trading_status_service.dart new file mode 100644 index 0000000000..57f6d51aa8 --- /dev/null +++ b/lib/bloc/trading_status/trading_status_service.dart @@ -0,0 +1,229 @@ +import 'dart:async'; + +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:logging/logging.dart'; +import 'package:web_dex/bloc/trading_status/app_geo_status.dart'; +import 'package:web_dex/bloc/trading_status/disallowed_feature.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_repository.dart'; + +/// Service class that manages trading status state and provides cached access +/// to trading restrictions. This service watches the trading status stream +/// and maintains the current state for efficient lookups. +class TradingStatusService { + TradingStatusService(this._repository); + + final TradingStatusRepository _repository; + final Logger _log = Logger('TradingStatusService'); + + /// Current cached trading status + /// Starts with a restrictive state to prevent race conditions during app startup + /// + /// TODO: UX Improvement - For faster startup, consider starting with an + /// unrestricted state and only apply restrictions once the API responds. + /// This would show all assets initially and remove blocked ones when the + /// bouncer returns restrictions. Trade-off: Better UX vs. brief exposure of + /// potentially blocked assets during initial API call (~100-500ms). + AppGeoStatus _currentStatus = const AppGeoStatus( + disallowedFeatures: {DisallowedFeature.trading}, + ); + + /// Stream subscription for trading status updates + StreamSubscription? _statusSubscription; + + /// Stream controller for broadcasting status changes + final StreamController _statusController = + StreamController.broadcast(); + + /// Track whether initialize has been called + bool _isInitialized = false; + + /// Track whether we've received the initial status from the API + bool _hasInitialStatus = false; + + /// Completer to track when initial status is ready + final Completer _initialStatusCompleter = Completer(); + + /// Future that completes when the initial status has been received + Future get initialStatusReady => _initialStatusCompleter.future; + + /// Stream of trading status updates + Stream get statusStream => _statusController.stream; + + /// Current trading status (cached) + AppGeoStatus get currentStatus { + assert( + _isInitialized, + 'TradingStatusService must be initialized before use. Call initialize() first.', + ); + return _currentStatus; + } + + /// Whether trading is currently enabled + bool get isTradingEnabled { + assert( + _isInitialized, + 'TradingStatusService must be initialized before use. Call initialize() first.', + ); + return _currentStatus.tradingEnabled; + } + + /// Set of currently blocked asset IDs + Set get blockedAssets { + assert( + _isInitialized, + 'TradingStatusService must be initialized before use. Call initialize() first.', + ); + return _currentStatus.disallowedAssets; + } + + /// Initialize the service by starting to watch trading status + /// Must be called after constructing the service + Future initialize() async { + assert( + !_isInitialized, + 'TradingStatusService.initialize() can only be called once', + ); + _isInitialized = true; + _log.info('Initializing trading status service'); + + try { + final initialStatus = await _repository.fetchStatus(); + _updateStatus(initialStatus); + } catch (error, stackTrace) { + _log.severe( + 'Failed to fetch initial trading status, defaulting to blocked', + error, + stackTrace, + ); + _updateStatus( + const AppGeoStatus(disallowedFeatures: {DisallowedFeature.trading}), + ); + } + + _startWatching(); + } + + /// Start watching trading status updates from the repository + void _startWatching() { + _statusSubscription?.cancel(); + + _statusSubscription = _repository.watchTradingStatus().listen( + _updateStatus, + onError: (error, stackTrace) { + _log.severe('Error in trading status stream', error, stackTrace); + // On error, assume trading is disabled for safety + _updateStatus( + const AppGeoStatus(disallowedFeatures: {DisallowedFeature.trading}), + ); + }, + ); + } + + /// Update the current status and broadcast changes + void _updateStatus(AppGeoStatus newStatus) { + final previousStatus = _currentStatus; + _currentStatus = newStatus; + + // Mark that we've received the initial status + if (!_hasInitialStatus) { + _hasInitialStatus = true; + if (!_initialStatusCompleter.isCompleted) { + _initialStatusCompleter.complete(); + } + _log.info('Initial trading status received'); + } + + if (previousStatus.tradingEnabled != newStatus.tradingEnabled) { + _log.info( + 'Trading status changed: ' + '${newStatus.tradingEnabled ? 'enabled' : 'disabled'}', + ); + } + + if (previousStatus.disallowedAssets.length != + newStatus.disallowedAssets.length) { + _log.info( + 'Blocked assets count changed: ' + '${previousStatus.disallowedAssets.length} -> ' + '${newStatus.disallowedAssets.length}', + ); + } + + _statusController.add(newStatus); + } + + /// Check if a specific asset is currently blocked + bool isAssetBlocked(AssetId assetId) { + assert( + _isInitialized, + 'TradingStatusService must be initialized before use. Call initialize() first.', + ); + return _currentStatus.isAssetBlocked(assetId); + } + + /// Filter a list of assets to remove blocked ones + List filterAllowedAssets(List assets) { + assert( + _isInitialized, + 'TradingStatusService must be initialized before use. Call initialize() first.', + ); + if (_currentStatus.tradingEnabled && + _currentStatus.disallowedAssets.isEmpty) { + return assets; + } + + return assets.where((asset) => !isAssetBlocked(asset.id)).toList(); + } + + /// Filter a map of assets to remove blocked ones + Map filterAllowedAssetsMap( + Map assetsMap, + AssetId Function(T) getAssetId, + ) { + assert( + _isInitialized, + 'TradingStatusService must be initialized before use. Call initialize() first.', + ); + if (_currentStatus.tradingEnabled && + _currentStatus.disallowedAssets.isEmpty) { + return assetsMap; // No filtering needed + } + + return Map.fromEntries( + assetsMap.entries.where( + (entry) => !isAssetBlocked(getAssetId(entry.value)), + ), + ); + } + + /// Immediately refresh the trading status by fetching from the repository + /// Returns the fresh status and updates the cached status + Future refreshStatus({bool? forceFail}) async { + assert( + _isInitialized, + 'TradingStatusService must be initialized before use. Call initialize() first.', + ); + + _log.info('Refreshing trading status immediately'); + + try { + final freshStatus = await _repository.fetchStatus(forceFail: forceFail); + _updateStatus(freshStatus); + return freshStatus; + } catch (error, stackTrace) { + _log.severe('Error refreshing trading status', error, stackTrace); + // On error, assume trading is disabled for safety + const errorStatus = AppGeoStatus( + disallowedFeatures: {DisallowedFeature.trading}, + ); + _updateStatus(errorStatus); + rethrow; + } + } + + void dispose() { + _log.info('Disposing trading status service'); + _statusSubscription?.cancel(); + _statusController.close(); + } +} diff --git a/lib/bloc/trading_status/trading_status_state.dart b/lib/bloc/trading_status/trading_status_state.dart index 03a85be8a1..8595b3ef03 100644 --- a/lib/bloc/trading_status/trading_status_state.dart +++ b/lib/bloc/trading_status/trading_status_state.dart @@ -2,17 +2,56 @@ part of 'trading_status_bloc.dart'; abstract class TradingStatusState extends Equatable { @override - List get props => []; + List get props => [isEnabled]; - bool get isEnabled => this is TradingEnabled; + bool get isEnabled => + this is TradingStatusLoadSuccess && + !(this as TradingStatusLoadSuccess).disallowedFeatures.contains( + DisallowedFeature.trading, + ); } class TradingStatusInitial extends TradingStatusState {} class TradingStatusLoadInProgress extends TradingStatusState {} -class TradingEnabled extends TradingStatusState {} +class TradingStatusLoadSuccess extends TradingStatusState { + TradingStatusLoadSuccess({ + Set? disallowedAssets, + Set? disallowedFeatures, + }) : disallowedAssets = disallowedAssets ?? const {}, + disallowedFeatures = disallowedFeatures ?? const {}; -class TradingDisabled extends TradingStatusState {} + final Set disallowedAssets; + final Set disallowedFeatures; + + @override + bool get isEnabled => !disallowedFeatures.contains(DisallowedFeature.trading); + + @override + List get props => [disallowedAssets, disallowedFeatures]; +} class TradingStatusLoadFailure extends TradingStatusState {} + +extension TradingStatusStateX on TradingStatusState { + Set get disallowedAssetIds => this is TradingStatusLoadSuccess + ? (this as TradingStatusLoadSuccess).disallowedAssets + : const {}; + + bool isAssetBlocked(AssetId? asset) { + if (asset == null) return true; + if (this is! TradingStatusLoadSuccess) return true; + return (this as TradingStatusLoadSuccess).disallowedAssets.contains(asset); + } + + bool canTradeAssets(Iterable assets) { + if (!isEnabled) return false; + // Filter out nulls - only check assets that are actually selected + final nonNullAssets = assets.whereType(); + for (final asset in nonNullAssets) { + if (isAssetBlocked(asset)) return false; + } + return true; + } +} diff --git a/lib/bloc/transaction_history/transaction_history_repo.dart b/lib/bloc/transaction_history/transaction_history_repo.dart index 19208222c5..755e2e2fc3 100644 --- a/lib/bloc/transaction_history/transaction_history_repo.dart +++ b/lib/bloc/transaction_history/transaction_history_repo.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:logging/logging.dart'; /// Throws [TransactionFetchException] if the transaction history could not be /// fetched. @@ -15,6 +16,8 @@ class SdkTransactionHistoryRepository implements TransactionHistoryRepo { required KomodoDefiSdk sdk, }) : _sdk = sdk; final KomodoDefiSdk _sdk; + final Logger _logger = + Logger('SdkTransactionHistoryRepository'); @override Future?> fetch(AssetId assetId, {String? fromId}) async { @@ -39,7 +42,8 @@ class SdkTransactionHistoryRepository implements TransactionHistoryRepo { ), ); return transactionHistory.transactions; - } catch (e) { + } catch (e, s) { + _logger.severe('Failed to fetch transactions for $assetId', e, s); return null; } } diff --git a/lib/bloc/version_info/version_info_bloc.dart b/lib/bloc/version_info/version_info_bloc.dart new file mode 100644 index 0000000000..62abe28808 --- /dev/null +++ b/lib/bloc/version_info/version_info_bloc.dart @@ -0,0 +1,182 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart' show ValueGetter; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:logging/logging.dart'; +import 'package:web_dex/app_config/package_information.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; + +part 'version_info_event.dart'; +part 'version_info_state.dart'; + +class VersionInfoBloc extends Bloc { + VersionInfoBloc({ + required Mm2Api mm2Api, + required KomodoDefiSdk komodoDefiSdk, + Duration? pollInterval, + }) : _mm2Api = mm2Api, + _komodoDefiSdk = komodoDefiSdk, + _pollInterval = pollInterval ?? const Duration(minutes: 5), + super(const VersionInfoInitial()) { + on(_onLoadVersionInfo); + on(_onStartPeriodicPolling); + on(_onStopPeriodicPolling); + on(_onPollVersionInfo); + } + + final Mm2Api _mm2Api; + final KomodoDefiSdk _komodoDefiSdk; + final Duration _pollInterval; + Timer? _pollTimer; + static final Logger _logger = Logger('VersionInfoBloc'); + + Future _onLoadVersionInfo( + LoadVersionInfo event, + Emitter emit, + ) async { + emit(const VersionInfoLoading()); + + final appVersion = packageInformation.packageVersion; + final commitHash = packageInformation.commitHash != null + ? _tryParseCommitHash(packageInformation.commitHash!) + : null; + + _logger.info( + 'Basic app info retrieved - Version: $appVersion, ' + 'Commit: $commitHash', + ); + + var currentInfo = VersionInfoLoaded( + appVersion: appVersion, + commitHash: commitHash, + ); + emit(currentInfo); + + try { + final apiVersion = await _mm2Api.version(); + if (apiVersion == null) { + _logger.severe('Failed to load MM2 API version'); + } + + final apiCommitHash = apiVersion != null + ? () => _tryParseCommitHash(apiVersion) + : null; + currentInfo = currentInfo.copyWith(apiCommitHash: apiCommitHash); + emit(currentInfo); + _logger.info( + 'MM2 API version loaded successfully - Version: $apiVersion, ' + 'Commit: ${apiCommitHash?.call()}', + ); + } catch (e, s) { + _logger.severe('Failed to load MM2 API version', e, s); + // Continue without API version if it fails + } + + try { + final currentCommit = await _komodoDefiSdk.assets.currentCoinsCommit; + final latestCommit = await _komodoDefiSdk.assets.latestCoinsCommit; + if (currentCommit == null || latestCommit == null) { + _logger.severe( + 'Failed to load SDK coins commits. ' + 'Current commit: $currentCommit, latest commit: $latestCommit', + ); + } + + currentInfo = currentInfo.copyWith( + currentCoinsCommit: () => _tryParseCommitHash(currentCommit ?? '-'), + latestCoinsCommit: () => _tryParseCommitHash(latestCommit ?? '-'), + ); + emit(currentInfo); + _logger.info( + 'SDK coins commits loaded successfully - Current: $currentCommit, ' + 'Latest: $latestCommit', + ); + } catch (e, s) { + _logger.severe('Failed to load SDK coins commits', e, s); + // Continue without SDK commits if it fails + } + } + + Future _onStartPeriodicPolling( + StartPeriodicPolling event, + Emitter emit, + ) async { + _logger.info('Starting periodic polling for version updates'); + + _pollTimer?.cancel(); + _pollTimer = Timer.periodic(_pollInterval, (_) { + add(const PollVersionInfo()); + }); + } + + Future _onStopPeriodicPolling( + StopPeriodicPolling event, + Emitter emit, + ) async { + _logger.info('Stopping periodic polling for version updates'); + _pollTimer?.cancel(); + _pollTimer = null; + } + + @override + Future close() async { + _pollTimer?.cancel(); + return super.close(); + } + + Future _onPollVersionInfo( + PollVersionInfo event, + Emitter emit, + ) async { + try { + _logger.fine('Polling for latest commit hash update'); + final latestCommit = await _komodoDefiSdk.assets.latestCoinsCommit; + final currentCommit = await _komodoDefiSdk.assets.currentCoinsCommit; + if (latestCommit == null || currentCommit == null) { + _logger.severe( + 'Failed to poll commit hash updates. ' + 'Latest commit: $latestCommit, current commit: $currentCommit', + ); + return; + } + + final parsedLatest = _tryParseCommitHash(latestCommit); + final parsedCurrent = _tryParseCommitHash(currentCommit); + + if (state is VersionInfoLoaded) { + final currentState = state as VersionInfoLoaded; + if (currentState.latestCoinsCommit != parsedLatest || + currentState.currentCoinsCommit != parsedCurrent) { + _logger.info( + 'Commit hash update detected - Current: $parsedCurrent, Latest: $parsedLatest', + ); + emit( + currentState.copyWith( + currentCoinsCommit: () => parsedCurrent, + latestCoinsCommit: () => parsedLatest, + ), + ); + } + } + } catch (e, s) { + _logger.severe('Failed to poll commit hash updates', e, s); + } + } + + /// Returns the first 7 characters of the commit hash, + /// or the unmodified [commitHash] if it is not valid. + String _tryParseCommitHash(String commitHash) { + final RegExp regExp = RegExp(r'[0-9a-fA-F]{7,40}'); + final Match? match = regExp.firstMatch(commitHash); + + if (match == null || match.group(0) == null) { + _logger.fine('No valid commit hash pattern found in: $commitHash'); + return commitHash; + } + + // '!' is safe because we know that match.group(0) is not null + return match.group(0)!.substring(0, 7); + } +} diff --git a/lib/bloc/version_info/version_info_event.dart b/lib/bloc/version_info/version_info_event.dart new file mode 100644 index 0000000000..6607436987 --- /dev/null +++ b/lib/bloc/version_info/version_info_event.dart @@ -0,0 +1,21 @@ +part of 'version_info_bloc.dart'; + +abstract class VersionInfoEvent { + const VersionInfoEvent(); +} + +class LoadVersionInfo extends VersionInfoEvent { + const LoadVersionInfo(); +} + +class StartPeriodicPolling extends VersionInfoEvent { + const StartPeriodicPolling(); +} + +class StopPeriodicPolling extends VersionInfoEvent { + const StopPeriodicPolling(); +} + +class PollVersionInfo extends VersionInfoEvent { + const PollVersionInfo(); +} diff --git a/lib/bloc/version_info/version_info_state.dart b/lib/bloc/version_info/version_info_state.dart new file mode 100644 index 0000000000..adfe964e7c --- /dev/null +++ b/lib/bloc/version_info/version_info_state.dart @@ -0,0 +1,69 @@ +part of 'version_info_bloc.dart'; + +abstract class VersionInfoState extends Equatable { + const VersionInfoState(); +} + +class VersionInfoInitial extends VersionInfoState { + const VersionInfoInitial(); + + @override + List get props => []; +} + +class VersionInfoLoading extends VersionInfoState { + const VersionInfoLoading(); + + @override + List get props => []; +} + +class VersionInfoLoaded extends VersionInfoState { + const VersionInfoLoaded({ + required this.appVersion, + required this.commitHash, + this.apiCommitHash, + this.currentCoinsCommit, + this.latestCoinsCommit, + }); + + final String? appVersion; + final String? commitHash; + final String? apiCommitHash; + final String? currentCoinsCommit; + final String? latestCoinsCommit; + + VersionInfoLoaded copyWith({ + ValueGetter? appVersion, + ValueGetter? commitHash, + ValueGetter? apiCommitHash, + ValueGetter? currentCoinsCommit, + ValueGetter? latestCoinsCommit, + }) { + return VersionInfoLoaded( + appVersion: appVersion?.call() ?? this.appVersion, + commitHash: commitHash?.call() ?? this.commitHash, + apiCommitHash: apiCommitHash?.call() ?? this.apiCommitHash, + currentCoinsCommit: currentCoinsCommit?.call() ?? this.currentCoinsCommit, + latestCoinsCommit: latestCoinsCommit?.call() ?? this.latestCoinsCommit, + ); + } + + @override + List get props => [ + appVersion, + commitHash, + apiCommitHash, + currentCoinsCommit, + latestCoinsCommit, + ]; +} + +class VersionInfoError extends VersionInfoState { + const VersionInfoError(this.message); + + final String message; + + @override + List get props => [message]; +} diff --git a/lib/blocs/maker_form_bloc.dart b/lib/blocs/maker_form_bloc.dart index c40a435129..e24bd86807 100644 --- a/lib/blocs/maker_form_bloc.dart +++ b/lib/blocs/maker_form_bloc.dart @@ -275,6 +275,16 @@ class MakerFormBloc implements BlocBase { return; } + final activeAssets = await kdfSdk.assets.getActivatedAssets(); + final isAssetActive = activeAssets.any((asset) => asset.id == coin.id); + if (!isAssetActive) { + // Intentionally leave in the loading state to avoid showing a "0.00" balance + // while the asset is activating. + maxSellAmount = null; + availableBalanceState = AvailableBalanceState.loading; + return; + } + Rational? amount = await dexRepository.getMaxMakerVolume(coin.abbr); if (amount != null) { maxSellAmount = amount; diff --git a/lib/blocs/orderbook_bloc.dart b/lib/blocs/orderbook_bloc.dart index c5427de616..d06174ed88 100644 --- a/lib/blocs/orderbook_bloc.dart +++ b/lib/blocs/orderbook_bloc.dart @@ -1,13 +1,15 @@ 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'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/blocs/bloc_base.dart'; -import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_request.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_response.dart'; +import 'package:web_dex/shared/utils/utils.dart'; class OrderbookBloc implements BlocBase { - OrderbookBloc({required Mm2Api api}) { - _api = api; + OrderbookBloc({required KomodoDefiSdk sdk}) { + _sdk = sdk; _timer = Timer.periodic( const Duration(seconds: 3), @@ -15,7 +17,7 @@ class OrderbookBloc implements BlocBase { ); } - late Mm2Api _api; + late KomodoDefiSdk _sdk; Timer? _timer; // keys are 'base/rel' Strings @@ -27,21 +29,21 @@ class OrderbookBloc implements BlocBase { _subscriptions.forEach((pair, subs) => subs.controller.close()); } - OrderbookResponse? getInitialData(String base, String rel) { + OrderbookResult? getInitialData(String base, String rel) { final String pair = '$base/$rel'; final OrderbookSubscription? subscription = _subscriptions[pair]; return subscription?.initialData; } - Stream getOrderbookStream(String base, String rel) { + Stream getOrderbookStream(String base, String rel) { final String pair = '$base/$rel'; final OrderbookSubscription? subscription = _subscriptions[pair]; if (subscription != null) { return subscription.stream; } else { - final controller = StreamController.broadcast(); + final controller = StreamController.broadcast(); final sink = controller.sink; final stream = controller.stream; @@ -58,7 +60,7 @@ class OrderbookBloc implements BlocBase { } Future _updateOrderbooks() async { - final List pairs = List.from(_subscriptions.keys); + final List pairs = List.of(_subscriptions.keys); for (String pair in pairs) { final OrderbookSubscription? subscription = _subscriptions[pair]; @@ -79,13 +81,25 @@ class OrderbookBloc implements BlocBase { final List coins = pair.split('/'); - final OrderbookResponse response = await _api.getOrderbook(OrderbookRequest( - base: coins[0], - rel: coins[1], - )); - - subscription.initialData = response; - subscription.sink.add(response); + try { + final OrderbookResponse response = await _sdk.client.rpc.orderbook + .orderbook(base: coins[0], rel: coins[1]); + + final result = OrderbookResult(response: response); + subscription.initialData = result; + subscription.sink.add(result); + } catch (e, s) { + log( + // Exception message can contain RPC pass, so avoid displaying it and logging it + 'Unexpected orderbook error for pair $pair', + path: 'OrderbookBloc._fetchOrderbook', + trace: s, + isError: true, + ).ignore(); + final result = OrderbookResult(error: 'Unexpected error for pair $pair'); + subscription.initialData = result; + subscription.sink.add(result); + } } } @@ -97,8 +111,17 @@ class OrderbookSubscription { required this.stream, }); - OrderbookResponse? initialData; - final StreamController controller; - final Sink sink; - final Stream stream; + OrderbookResult? initialData; + final StreamController controller; + final Sink sink; + final Stream stream; +} + +class OrderbookResult { + const OrderbookResult({this.response, this.error}); + + final OrderbookResponse? response; + final String? error; + + bool get hasError => error != null; } diff --git a/lib/blocs/trading_entities_bloc.dart b/lib/blocs/trading_entities_bloc.dart index 94fcd863e2..48ec3dff41 100644 --- a/lib/blocs/trading_entities_bloc.dart +++ b/lib/blocs/trading_entities_bloc.dart @@ -36,6 +36,7 @@ class TradingEntitiesBloc implements BlocBase { List _myOrders = []; List _swaps = []; Timer? timer; + bool _closed = false; final StreamController> _myOrdersController = StreamController>.broadcast(); @@ -61,6 +62,7 @@ class TradingEntitiesBloc implements BlocBase { } Future fetch() async { + if (_closed) return; if (!await _kdfSdk.auth.isSignedIn()) return; myOrders = await _myOrdersService.getOrders() ?? []; @@ -76,12 +78,25 @@ class TradingEntitiesBloc implements BlocBase { bool updateInProgress = false; timer = Timer.periodic(const Duration(seconds: 1), (_) async { + if (_closed) return; if (updateInProgress) return; // TODO!: do not run for hidden login or HW updateInProgress = true; - await fetch(); - updateInProgress = false; + try { + await fetch(); + } catch (e) { + if (e is StateError && e.message.contains('disposed')) { + _closed = true; + } else { + await log( + 'fetch error: $e', + path: 'TradingEntitiesBloc.fetch', + ); + } + } finally { + updateInProgress = false; + } }); } diff --git a/lib/blocs/wallets_repository.dart b/lib/blocs/wallets_repository.dart index eb449ddc1b..187f3de976 100644 --- a/lib/blocs/wallets_repository.dart +++ b/lib/blocs/wallets_repository.dart @@ -20,8 +20,8 @@ class WalletsRepository { this._legacyWalletStorage, { EncryptionTool? encryptionTool, FileLoader? fileLoader, - }) : _encryptionTool = encryptionTool ?? EncryptionTool(), - _fileLoader = fileLoader ?? FileLoader.fromPlatform(); + }) : _encryptionTool = encryptionTool ?? EncryptionTool(), + _fileLoader = fileLoader ?? FileLoader.fromPlatform(); final KomodoDefiSdk _kdfSdk; final Mm2Api _mm2Api; @@ -30,38 +30,48 @@ class WalletsRepository { final FileLoader _fileLoader; List? _cachedWallets; + List? _cachedLegacyWallets; List? get wallets => _cachedWallets; + bool get isCacheLoaded => + _cachedWallets != null && _cachedLegacyWallets != null; Future> getWallets() async { final legacyWallets = await _getLegacyWallets(); + final sdkWallets = await _kdfSdk.wallets; // TODO: move wallet filtering logic to the SDK - _cachedWallets = (await _kdfSdk.wallets) + _cachedWallets = sdkWallets .where( (wallet) => wallet.config.type != WalletType.trezor && !wallet.name.toLowerCase().startsWith(trezorWalletNamePrefix), ) .toList(); + _cachedLegacyWallets = legacyWallets; return [..._cachedWallets!, ...legacyWallets]; } Future> _getLegacyWallets() async { - final newVariable = - await _legacyWalletStorage.read(allWalletsStorageKey) as List?; - final List> json = - newVariable?.cast>() ?? >[]; - - return json - .map((Map w) => - Wallet.fromJson(w)..config.isLegacyWallet = true) - .toList(); + final rawLegacyWallets = + (await _legacyWalletStorage.read(allWalletsStorageKey) as List?) + ?.cast>() ?? + []; + + return rawLegacyWallets.map((Map w) { + final wallet = Wallet.fromJson(w); + return wallet.copyWith( + config: wallet.config.copyWith( + // Wallet type for legacy wallets is iguana, to avoid confusion with + // missing/empty balances. Sign into iguana for legacy wallets by + // default, but allow for them to be signed into hdwallet if desired. + type: WalletType.iguana, + isLegacyWallet: true, + ), + ); + }).toList(); } - Future deleteWallet( - Wallet wallet, { - required String password, - }) async { + Future deleteWallet(Wallet wallet, {required String password}) async { log( 'Deleting a wallet ${wallet.id}', path: 'wallet_bloc => deleteWallet', @@ -82,48 +92,59 @@ class WalletsRepository { _cachedWallets?.removeWhere((w) => w.name == wallet.name); return; } catch (e) { - log('Failed to delete wallet: $e', - path: 'wallet_bloc => deleteWallet', isError: true) - .ignore(); + log( + 'Failed to delete wallet: $e', + path: 'wallet_bloc => deleteWallet', + isError: true, + ).ignore(); rethrow; } } String? validateWalletName(String name) { // Disallow special characters except letters, digits, space, underscore and hyphen - if (RegExp(r'[^\w\- ]').hasMatch(name)) { + if (RegExp(r'[^\p{L}\p{M}\p{N}\s\-_]', unicode: true).hasMatch(name)) { return LocaleKeys.invalidWalletNameError.tr(); } - // This shouldn't happen, but just in case. - if (_cachedWallets == null) { - getWallets().ignore(); - return null; - } - + final trimmedName = name.trim(); - - // Check if the trimmed name is empty (prevents space-only names) - if (trimmedName.isEmpty) { + + // Reject leading/trailing spaces explicitly to avoid confusion/duplicates + if (trimmedName != name) { return LocaleKeys.walletCreationNameLengthError.tr(); } - - // Check if trimmed name exceeds length limit - if (trimmedName.length > 40) { + + // Check empty and length limits on trimmed input + if (trimmedName.isEmpty || trimmedName.length > 40) { return LocaleKeys.walletCreationNameLengthError.tr(); } - // Check for duplicates using the exact input name (not trimmed) - // This preserves backward compatibility with existing wallets that might have spaces - if (_cachedWallets!.firstWhereOrNull((w) => w.name == name) != null) { - return LocaleKeys.walletCreationExistNameError.tr(); - } + return null; + } + /// Async uniqueness check: verifies that no existing wallet (SDK or legacy) + /// has the same trimmed name. Returns a localized error string if taken, + /// or null if available or if wallets can't be loaded. + Future validateWalletNameUniqueness(String name) async { + final String trimmedName = name.trim(); + try { + final List allWallets = await getWallets(); + final bool taken = + allWallets.firstWhereOrNull((w) => w.name.trim() == trimmedName) != + null; + if (taken) { + return LocaleKeys.walletCreationExistNameError.tr(); + } + } catch (_) { + // Non-blocking on failure to fetch wallets; treat as no conflict found. + } return null; } Future resetSpecificWallet(Wallet wallet) async { - final coinsToDeactivate = wallet.config.activatedCoins - .where((coin) => !enabledByDefaultCoins.contains(coin)); + final coinsToDeactivate = wallet.config.activatedCoins.where( + (coin) => !enabledByDefaultCoins.contains(coin), + ); for (final coin in coinsToDeactivate) { await _mm2Api.disableCoin(coin); } @@ -132,17 +153,23 @@ class WalletsRepository { @Deprecated('Use the KomodoDefiSdk.auth.getMnemonicEncrypted method instead.') Future downloadEncryptedWallet(Wallet wallet, String password) async { try { + Wallet workingWallet = wallet.copy(); if (wallet.config.seedPhrase.isEmpty) { final mnemonic = await _kdfSdk.auth.getMnemonicPlainText(password); - wallet.config.seedPhrase = await _encryptionTool.encryptData( + final String encryptedSeed = await _encryptionTool.encryptData( password, mnemonic.plaintextMnemonic ?? '', ); + workingWallet = workingWallet.copyWith( + config: workingWallet.config.copyWith(seedPhrase: encryptedSeed), + ); } - final String data = jsonEncode(wallet.config); - final String encryptedData = - await _encryptionTool.encryptData(password, data); - final String sanitizedFileName = _sanitizeFileName(wallet.name); + final String data = jsonEncode(workingWallet.config); + final String encryptedData = await _encryptionTool.encryptData( + password, + data, + ); + final String sanitizedFileName = _sanitizeFileName(workingWallet.name); await _fileLoader.save( fileName: sanitizedFileName, data: encryptedData, @@ -156,4 +183,41 @@ class WalletsRepository { String _sanitizeFileName(String fileName) { return fileName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'); } + + Future renameLegacyWallet({ + required String walletId, + required String newName, + }) async { + final String trimmed = newName.trim(); + // Persist to legacy storage + final List> rawLegacyWallets = + (await _legacyWalletStorage.read(allWalletsStorageKey) as List?) + ?.cast>() ?? + []; + bool updated = false; + for (int i = 0; i < rawLegacyWallets.length; i++) { + final Map data = rawLegacyWallets[i]; + if ((data['id'] as String? ?? '') == walletId) { + data['name'] = trimmed; + rawLegacyWallets[i] = data; + updated = true; + break; + } + } + if (updated) { + await _legacyWalletStorage.write(allWalletsStorageKey, rawLegacyWallets); + } + + // Update in-memory legacy cache if available + if (_cachedLegacyWallets != null) { + final index = _cachedLegacyWallets!.indexWhere( + (element) => element.id == walletId, + ); + if (index != -1) { + _cachedLegacyWallets![index] = _cachedLegacyWallets![index].copyWith( + name: trimmed, + ); + } + } + } } diff --git a/lib/dispatchers/popup_dispatcher.dart b/lib/dispatchers/popup_dispatcher.dart index 363a6e1811..90f1100263 100644 --- a/lib/dispatchers/popup_dispatcher.dart +++ b/lib/dispatchers/popup_dispatcher.dart @@ -8,6 +8,32 @@ import 'package:web_dex/common/screen.dart'; import 'package:universal_html/html.dart' as html; import 'package:web_dex/router/state/routing_state.dart'; +/// **DEPRECATED**: Use `AppDialog` from `package:web_dex/shared/widgets/app_dialog.dart` instead. +/// +/// This class is deprecated and will be removed in a future version. +/// It has been replaced by `AppDialog.show()` which provides the same functionality +/// using Flutter's built-in dialog system with better performance and maintainability. +/// +/// Migration example: +/// ```dart +/// // OLD (deprecated): +/// PopupDispatcher( +/// context: context, +/// width: 320, +/// popupContent: MyWidget(), +/// ).show(); +/// +/// // NEW (recommended): +/// AppDialog.show( +/// context: context, +/// width: 320, +/// child: MyWidget(), +/// ); +/// ``` +@Deprecated( + 'Use AppDialog from package:web_dex/shared/widgets/app_dialog.dart instead. ' + 'This class will be removed in a future version.', +) class PopupDispatcher { PopupDispatcher({ this.context, @@ -52,7 +78,8 @@ class PopupDispatcher { barrierColor: theme.custom.dialogBarrierColor, builder: (BuildContext dialogContext) { return SimpleDialog( - insetPadding: insetPadding ?? + insetPadding: + insetPadding ?? EdgeInsets.symmetric( horizontal: isMobile ? 16 : 24, vertical: isMobile ? 40 : 24, @@ -63,7 +90,8 @@ class PopupDispatcher { ? BorderSide(color: borderColor) : BorderSide.none, ), - contentPadding: contentPadding ?? + contentPadding: + contentPadding ?? EdgeInsets.symmetric( horizontal: isMobile ? 16 : 30, vertical: isMobile ? 26 : 30, @@ -73,7 +101,7 @@ class PopupDispatcher { width: width, constraints: BoxConstraints(maxWidth: maxWidth), child: popupContent, - ) + ), ], ); }, @@ -86,7 +114,11 @@ class PopupDispatcher { void close() { _resetBrowserNavigationToDefault(); if (_currentContext == null) return; - if (_isShown) Navigator.of(_currentContext!).pop(); + if (_isShown) { + final navigator = Navigator.of(_currentContext!); + // ignore: discarded_futures + navigator.maybePop(); + } } void _setupDismissibleLogic() { diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index 2b397d9015..dd0c6dfc6b 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -127,7 +127,13 @@ abstract class LocaleKeys { static const walletCreationNameLengthError = 'walletCreationNameLengthError'; static const walletCreationFormatPasswordError = 'walletCreationFormatPasswordError'; static const walletCreationConfirmPasswordError = 'walletCreationConfirmPasswordError'; + static const walletCreationNameCharactersError = 'walletCreationNameCharactersError'; + static const renameWalletDescription = 'renameWalletDescription'; + static const renameWalletConfirm = 'renameWalletConfirm'; static const incorrectPassword = 'incorrectPassword'; + static const oneClickLogin = 'oneClickLogin'; + static const quickLoginTooltip = 'quickLoginTooltip'; + static const quickLoginSubtitle = 'quickLoginSubtitle'; static const importSeedEnterSeedPhraseHint = 'importSeedEnterSeedPhraseHint'; static const passphraseCheckingTitle = 'passphraseCheckingTitle'; static const passphraseCheckingDescription = 'passphraseCheckingDescription'; @@ -203,7 +209,8 @@ abstract class LocaleKeys { static const walletCreationBip39SeedError = 'walletCreationBip39SeedError'; static const walletCreationHdBip39SeedError = 'walletCreationHdBip39SeedError'; static const walletPageNoSuchAsset = 'walletPageNoSuchAsset'; - static const swapCoin = 'swapCoin'; + static const swap = 'swap'; + static const dexAddress = 'dexAddress'; static const fiatBalance = 'fiatBalance'; static const yourBalance = 'yourBalance'; static const all = 'all'; @@ -266,7 +273,6 @@ abstract class LocaleKeys { static const buyPrice = 'buyPrice'; static const inProgress = 'inProgress'; static const orders = 'orders'; - static const swap = 'swap'; static const percentFilled = 'percentFilled'; static const orderType = 'orderType'; static const recover = 'recover'; @@ -297,6 +303,25 @@ abstract class LocaleKeys { static const feedbackFormDescription = 'feedbackFormDescription'; static const feedbackFormThanksTitle = 'feedbackFormThanksTitle'; static const feedbackFormThanksDescription = 'feedbackFormThanksDescription'; + static const feedbackFormKindQuestion = 'feedbackFormKindQuestion'; + static const feedbackFormDescribeTitle = 'feedbackFormDescribeTitle'; + static const feedbackFormContactRequired = 'feedbackFormContactRequired'; + static const feedbackFormContactOptional = 'feedbackFormContactOptional'; + static const feedbackFormMessageHint = 'feedbackFormMessageHint'; + static const feedbackFormBugReport = 'feedbackFormBugReport'; + static const feedbackFormFeatureRequest = 'feedbackFormFeatureRequest'; + static const feedbackFormSupportRequest = 'feedbackFormSupportRequest'; + static const feedbackFormOther = 'feedbackFormOther'; + static const feedbackFormDiscord = 'feedbackFormDiscord'; + static const feedbackFormMatrix = 'feedbackFormMatrix'; + static const feedbackFormTelegram = 'feedbackFormTelegram'; + static const feedbackFormSelectContactMethod = 'feedbackFormSelectContactMethod'; + static const feedbackFormDiscordHint = 'feedbackFormDiscordHint'; + static const feedbackFormMatrixHint = 'feedbackFormMatrixHint'; + static const feedbackFormTelegramHint = 'feedbackFormTelegramHint'; + static const feedbackFormEmailHint = 'feedbackFormEmailHint'; + static const feedbackFormContactHint = 'feedbackFormContactHint'; + static const feedbackFormContactOptOut = 'feedbackFormContactOptOut'; static const email = 'email'; static const emailValidatorError = 'emailValidatorError'; static const contactRequiredError = 'contactRequiredError'; @@ -332,7 +357,6 @@ abstract class LocaleKeys { static const backupSeedPhrase = 'backupSeedPhrase'; static const seedOr = 'seedOr'; static const seedDownload = 'seedDownload'; - static const seedSaveAndRemember = 'seedSaveAndRemember'; static const seedIntroWarning = 'seedIntroWarning'; static const seedSettings = 'seedSettings'; static const errorDescription = 'errorDescription'; @@ -377,6 +401,9 @@ abstract class LocaleKeys { static const dexErrorMessage = 'dexErrorMessage'; static const seedConfirmInitialText = 'seedConfirmInitialText'; static const seedConfirmIncorrectText = 'seedConfirmIncorrectText'; + static const mnemonicInvalidWordError = 'mnemonicInvalidWordError'; + static const mnemonicInvalidChecksumError = 'mnemonicInvalidChecksumError'; + static const mnemonicInvalidLengthError = 'mnemonicInvalidLengthError'; static const usedSamePassword = 'usedSamePassword'; static const passwordNotAccepted = 'passwordNotAccepted'; static const confirmNewPassword = 'confirmNewPassword'; @@ -429,6 +456,7 @@ abstract class LocaleKeys { static const withdrawAmountTooLowError = 'withdrawAmountTooLowError'; static const withdrawNoSuchCoinError = 'withdrawNoSuchCoinError'; static const withdrawPreview = 'withdrawPreview'; + static const withdrawPreviewZhtlcNote = 'withdrawPreviewZhtlcNote'; static const withdrawPreviewError = 'withdrawPreviewError'; static const txHistoryFetchError = 'txHistoryFetchError'; static const txHistoryNoTransactions = 'txHistoryNoTransactions'; @@ -459,6 +487,7 @@ abstract class LocaleKeys { static const nothingFound = 'nothingFound'; static const half = 'half'; static const max = 'max'; + static const exact = 'exact'; static const reactivating = 'reactivating'; static const weFailedCoinActivate = 'weFailedCoinActivate'; static const failedActivate = 'failedActivate'; @@ -478,10 +507,10 @@ abstract class LocaleKeys { static const userActionRequired = 'userActionRequired'; static const unknown = 'unknown'; static const unableToActiveCoin = 'unableToActiveCoin'; + static const coinIsNotActive = 'coinIsNotActive'; static const feedback = 'feedback'; static const feedbackViewTitle = 'feedbackViewTitle'; static const feedbackPageDescription = 'feedbackPageDescription'; - static const sendFeedbackButton = 'sendFeedbackButton'; static const feedbackThankyou = 'feedbackThankyou'; static const feedbackError = 'feedbackError'; static const selectAToken = 'selectAToken'; @@ -676,7 +705,6 @@ abstract class LocaleKeys { static const decimals = 'decimals'; static const onlySendToThisAddress = 'onlySendToThisAddress'; static const scanTheQrCode = 'scanTheQrCode'; - static const tradingAddress = 'tradingAddress'; static const addresses = 'addresses'; static const creating = 'creating'; static const createAddress = 'createAddress'; @@ -714,6 +742,7 @@ abstract class LocaleKeys { static const trend7d = 'trend7d'; static const tradingDisabledTooltip = 'tradingDisabledTooltip'; static const tradingDisabled = 'tradingDisabled'; + static const includeBlockedAssets = 'includeBlockedAssets'; static const unbanPubkeysResults = 'unbanPubkeysResults'; static const unbannedPubkeys = 'unbannedPubkeys'; static const stillBannedPubkeys = 'stillBannedPubkeys'; @@ -728,5 +757,29 @@ abstract class LocaleKeys { static const fetchingPrivateKeysMessage = 'fetchingPrivateKeysMessage'; static const pubkeyType = 'pubkeyType'; static const securitySettings = 'securitySettings'; + static const zhtlcConfigureTitle = 'zhtlcConfigureTitle'; + static const zhtlcZcashParamsPathLabel = 'zhtlcZcashParamsPathLabel'; + static const zhtlcPathAutomaticallyDetected = 'zhtlcPathAutomaticallyDetected'; + static const zhtlcSaplingParamsFolder = 'zhtlcSaplingParamsFolder'; + static const zhtlcBlocksPerIterationLabel = 'zhtlcBlocksPerIterationLabel'; + static const zhtlcScanIntervalLabel = 'zhtlcScanIntervalLabel'; + static const zhtlcStartSyncFromLabel = 'zhtlcStartSyncFromLabel'; + static const zhtlcEarliestSaplingOption = 'zhtlcEarliestSaplingOption'; + static const zhtlcBlockHeightOption = 'zhtlcBlockHeightOption'; + static const zhtlcShieldedAddress = 'zhtlcShieldedAddress'; + static const zhtlcDateTimeOption = 'zhtlcDateTimeOption'; + static const zhtlcSelectDateTimeLabel = 'zhtlcSelectDateTimeLabel'; + static const zhtlcZcashParamsRequired = 'zhtlcZcashParamsRequired'; + static const zhtlcInvalidBlockHeight = 'zhtlcInvalidBlockHeight'; + static const zhtlcSelectDateTimeRequired = 'zhtlcSelectDateTimeRequired'; + static const zhtlcDownloadingZcashParams = 'zhtlcDownloadingZcashParams'; + static const zhtlcPreparingDownload = 'zhtlcPreparingDownload'; + static const zhtlcErrorSettingUpZcash = 'zhtlcErrorSettingUpZcash'; + static const zhtlcDateSyncHint = 'zhtlcDateSyncHint'; + static const zhtlcActivating = 'zhtlcActivating'; + static const zhtlcActivationWarning = 'zhtlcActivationWarning'; + static const zhtlcAdvancedConfiguration = 'zhtlcAdvancedConfiguration'; + static const zhtlcAdvancedConfigurationHint = 'zhtlcAdvancedConfigurationHint'; + static const zhtlcConfigButton = 'zhtlcConfigButton'; } diff --git a/lib/main.dart b/lib/main.dart index 446f92304c..894449396c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; -import 'package:feedback/feedback.dart'; import 'package:flutter/foundation.dart' show kIsWasm, kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -23,16 +22,20 @@ 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: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'; import 'package:web_dex/blocs/wallets_repository.dart'; import 'package:web_dex/mm2/mm2.dart'; import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; import 'package:web_dex/model/stored_settings.dart'; import 'package:web_dex/performance_analytics/performance_analytics.dart'; import 'package:web_dex/sdk/widgets/window_close_handler.dart'; -import 'package:web_dex/services/feedback/custom_feedback_form.dart'; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; +import 'package:web_dex/services/feedback/app_feedback_wrapper.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'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; import 'package:web_dex/shared/utils/platform_tuner.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -44,52 +47,70 @@ PerformanceMode? get appDemoPerformanceMode => _appDemoPerformanceMode ?? _getPerformanceModeFromUrl(); Future main() async { - await runZonedGuarded( - () async { - usePathUrlStrategy(); - WidgetsFlutterBinding.ensureInitialized(); - Bloc.observer = AppBlocObserver(); - PerformanceAnalytics.init(); - - FlutterError.onError = (FlutterErrorDetails details) { - catchUnhandledExceptions(details.exception, details.stack); - }; - - // Foundational dependencies / setup - everything else builds on these 3. - // The current focus is migrating mm2Api to the new sdk, so that the sdk - // is the only/primary API/repository for KDF - final KomodoDefiSdk komodoDefiSdk = await mm2.initialize(); - final mm2Api = Mm2Api(mm2: mm2, sdk: komodoDefiSdk); - await AppBootstrapper.instance.ensureInitialized(komodoDefiSdk, mm2Api); - - final coinsRepo = CoinsRepo(kdfSdk: komodoDefiSdk, mm2: mm2); - final walletsRepository = WalletsRepository( - komodoDefiSdk, - mm2Api, - getStorage(), - ); - - runApp( - EasyLocalization( - supportedLocales: localeList, - fallbackLocale: localeList.first, - useFallbackTranslations: true, - useOnlyLangCode: true, - path: '$assetsPath/translations', - child: MultiRepositoryProvider( - providers: [ - RepositoryProvider(create: (_) => komodoDefiSdk), - RepositoryProvider(create: (_) => mm2Api), - RepositoryProvider(create: (_) => coinsRepo), - RepositoryProvider(create: (_) => walletsRepository), - ], - child: const MyApp(), - ), + await runZonedGuarded(() async { + usePathUrlStrategy(); + WidgetsFlutterBinding.ensureInitialized(); + Bloc.observer = AppBlocObserver(); + PerformanceAnalytics.init(); + + FlutterError.onError = (FlutterErrorDetails details) { + catchUnhandledExceptions(details.exception, details.stack); + }; + + // Foundational dependencies / setup - everything else builds on these 3. + // The current focus is migrating mm2Api to the new sdk, so that the sdk + // is the only/primary API/repository for KDF + final KomodoDefiSdk komodoDefiSdk = await mm2.initialize(); + final mm2Api = Mm2Api(mm2: mm2, sdk: komodoDefiSdk); + // Sparkline is dependent on Hive initialization, so we pass it on to the + // bootstrapper here + final sparklineRepository = SparklineRepository.defaultInstance(); + await AppBootstrapper.instance.ensureInitialized( + komodoDefiSdk, + mm2Api, + sparklineRepository, + ); + + final tradingStatusRepository = TradingStatusRepository(komodoDefiSdk); + final tradingStatusService = TradingStatusService(tradingStatusRepository); + await tradingStatusService.initialize(); + final arrrActivationService = ArrrActivationService(komodoDefiSdk, mm2); + + final coinsRepo = CoinsRepo( + kdfSdk: komodoDefiSdk, + mm2: mm2, + tradingStatusService: tradingStatusService, + arrrActivationService: arrrActivationService, + ); + final walletsRepository = WalletsRepository( + komodoDefiSdk, + mm2Api, + getStorage(), + ); + + runApp( + EasyLocalization( + supportedLocales: localeList, + fallbackLocale: localeList.first, + useFallbackTranslations: true, + useOnlyLangCode: true, + path: '$assetsPath/translations', + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: komodoDefiSdk), + RepositoryProvider.value(value: mm2Api), + RepositoryProvider.value(value: arrrActivationService), + RepositoryProvider.value(value: coinsRepo), + RepositoryProvider.value(value: walletsRepository), + RepositoryProvider.value(value: sparklineRepository), + RepositoryProvider.value(value: tradingStatusRepository), + RepositoryProvider.value(value: tradingStatusService), + ], + child: const MyApp(), ), - ); - }, - catchUnhandledExceptions, - ); + ), + ); + }, catchUnhandledExceptions); } void catchUnhandledExceptions(Object error, StackTrace? stack) { @@ -137,30 +158,35 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { final komodoDefiSdk = RepositoryProvider.of(context); final walletsRepository = RepositoryProvider.of(context); + final tradingStatusService = RepositoryProvider.of( + context, + ); - final theme = Theme.of(context); - + final sensitivityController = ScreenshotSensitivityController(); return MultiBlocProvider( providers: [ BlocProvider( create: (_) { final bloc = AuthBloc( - komodoDefiSdk, walletsRepository, SettingsRepository()); + komodoDefiSdk, + walletsRepository, + SettingsRepository(), + tradingStatusService, + ); bloc.add(const AuthLifecycleCheckRequested()); return bloc; }, ), ], - child: BetterFeedback( - feedbackBuilder: CustomFeedbackForm.feedbackBuilder, - themeMode: ThemeMode.light, - darkTheme: _feedbackThemeData(theme), - theme: _feedbackThemeData(theme), + child: AppFeedbackWrapper( child: AnalyticsLifecycleHandler( child: WindowCloseHandler( - child: app_bloc_root.AppBlocRoot( - storedPrefs: _storedSettings!, - komodoDefiSdk: komodoDefiSdk, + child: ScreenshotSensitivity( + controller: sensitivityController, + child: app_bloc_root.AppBlocRoot( + storedPrefs: _storedSettings!, + komodoDefiSdk: komodoDefiSdk, + ), ), ), ), @@ -168,19 +194,3 @@ class MyApp extends StatelessWidget { ); } } - -FeedbackThemeData _feedbackThemeData(ThemeData appTheme) { - return FeedbackThemeData( - bottomSheetTextInputStyle: appTheme.textTheme.bodyMedium!, - bottomSheetDescriptionStyle: appTheme.textTheme.bodyMedium!, - dragHandleColor: appTheme.colorScheme.primary, - colorScheme: appTheme.colorScheme, - sheetIsDraggable: true, - feedbackSheetHeight: 0.3, - drawColors: [ - Colors.red, - Colors.white, - Colors.green, - ], - ); -} diff --git a/lib/mm2/mm2.dart b/lib/mm2/mm2.dart index 185b77a8cb..e0cd554ce8 100644 --- a/lib/mm2/mm2.dart +++ b/lib/mm2/mm2.dart @@ -18,6 +18,7 @@ final class MM2 { preActivateHistoricalAssets: false, preActivateDefaultAssets: false, ), + onLog: _handleSdkLog, ); } @@ -65,10 +66,12 @@ final class MM2 { return response.result; } - @Deprecated('Use KomodoDefiSdk.client.rpc or KomodoDefiSdk.client.executeRpc ' - 'instead. This method is the legacy way of calling RPC methods which ' - 'injects an empty user password into the legacy models which override ' - 'the legacy base RPC request model') + @Deprecated( + 'Use KomodoDefiSdk.client.rpc or KomodoDefiSdk.client.executeRpc ' + 'instead. This method is the legacy way of calling RPC methods which ' + 'injects an empty user password into the legacy models which override ' + 'the legacy base RPC request model', + ) Future call(dynamic request) async { try { final dynamic requestWithUserpass = _assertPass(request); @@ -76,9 +79,9 @@ final class MM2 { ? JsonMap.from(requestWithUserpass) // ignore: avoid_dynamic_calls : (requestWithUserpass?.toJson != null - // ignore: avoid_dynamic_calls - ? requestWithUserpass.toJson() as JsonMap - : requestWithUserpass as JsonMap); + // ignore: avoid_dynamic_calls + ? requestWithUserpass.toJson() as JsonMap + : requestWithUserpass as JsonMap); return await _kdfSdk.client.executeRpc(jsonRequest); } catch (e) { @@ -107,6 +110,10 @@ final class MM2 { return req; } + + void _handleSdkLog(String message) { + log(message, path: 'KomodoDefiSdk').ignore(); + } } // 0 - MM2 is not running yet. diff --git a/lib/mm2/mm2_api/mm2_api.dart b/lib/mm2/mm2_api/mm2_api.dart index 8fcaa1ffa2..12f9a03dac 100644 --- a/lib/mm2/mm2_api/mm2_api.dart +++ b/lib/mm2/mm2_api/mm2_api.dart @@ -33,8 +33,6 @@ import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/my_tx_history_request.dart import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/my_tx_history_v2_request.dart'; import 'package:web_dex/mm2/mm2_api/rpc/order_status/order_status_request.dart'; import 'package:web_dex/mm2/mm2_api/rpc/order_status/order_status_response.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_request.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_response.dart'; import 'package:web_dex/mm2/mm2_api/rpc/orderbook_depth/orderbook_depth_req.dart'; import 'package:web_dex/mm2/mm2_api/rpc/orderbook_depth/orderbook_depth_response.dart'; import 'package:web_dex/mm2/mm2_api/rpc/recover_funds_of_swap/recover_funds_of_swap_request.dart'; @@ -52,16 +50,13 @@ import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_response.d import 'package:web_dex/mm2/mm2_api/rpc/validateaddress/validateaddress_request.dart'; import 'package:web_dex/mm2/mm2_api/rpc/version/version_response.dart'; import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_request.dart'; -import 'package:web_dex/model/orderbook/orderbook.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/shared/utils/utils.dart'; class Mm2Api { - Mm2Api({ - required MM2 mm2, - required KomodoDefiSdk sdk, - }) : _sdk = sdk, - _mm2 = mm2 { + Mm2Api({required MM2 mm2, required KomodoDefiSdk sdk}) + : _sdk = sdk, + _mm2 = mm2 { nft = Mm2ApiNft(_mm2.call, sdk); } @@ -113,10 +108,7 @@ class Mm2Api { denom: rational.denominator.toString(), ); - return MaxTakerVolResponse( - coin: abbr, - result: result, - ); + return MaxTakerVolResponse(coin: abbr, result: result); } Future?> getActiveSwaps( @@ -478,11 +470,7 @@ class Mm2Api { denom: rational.denominator.toString(), ); - return MaxMakerVolResponse( - coin: coinAbbr, - volume: result, - balance: result, - ); + return MaxMakerVolResponse(coin: coinAbbr, volume: result, balance: result); } Future getMinTradingVol( @@ -505,36 +493,6 @@ class Mm2Api { } } - Future getOrderbook(OrderbookRequest request) async { - try { - final JsonMap json = await _mm2.call(request); - - if (json['error'] != null) { - return OrderbookResponse( - request: request, - error: json['error'] as String?, - ); - } - - return OrderbookResponse( - request: request, - result: Orderbook.fromJson(json), - ); - } catch (e, s) { - log( - 'Error getting orderbook ${request.base}/${request.rel}: $e', - path: 'api => getOrderbook', - trace: s, - isError: true, - ).ignore(); - - return OrderbookResponse( - request: request, - error: e.toString(), - ); - } - } - Future getOrderBookDepth( List> pairs, CoinsRepo coinsRepository, @@ -557,10 +515,13 @@ class Mm2Api { } Future< - ApiResponse>> getTradePreimage( - TradePreimageRequest request, - ) async { + ApiResponse< + TradePreimageRequest, + TradePreimageResponseResult, + Map + > + > + getTradePreimage(TradePreimageRequest request) async { try { final JsonMap responseJson = await _mm2.call(request); if (responseJson['error'] != null) { @@ -577,9 +538,7 @@ class Mm2Api { trace: s, isError: true, ).ignore(); - return ApiResponse( - request: request, - ); + return ApiResponse(request: request); } } @@ -635,9 +594,7 @@ class Mm2Api { await _mm2.call(StopReq()); } - Future showPrivKey( - ShowPrivKeyRequest request, - ) async { + Future showPrivKey(ShowPrivKeyRequest request) async { try { final JsonMap json = await _mm2.call(request); if (json['error'] != null) { diff --git a/lib/mm2/mm2_api/rpc/orderbook/orderbook_request.dart b/lib/mm2/mm2_api/rpc/orderbook/orderbook_request.dart deleted file mode 100644 index d742cb063e..0000000000 --- a/lib/mm2/mm2_api/rpc/orderbook/orderbook_request.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; - -class OrderbookRequest implements BaseRequest { - OrderbookRequest({ - required this.base, - required this.rel, - }); - - final String base; - final String rel; - @override - late String userpass; - @override - final String method = 'orderbook'; - - @override - Map toJson() => { - 'userpass': userpass, - 'method': method, - 'base': base, - 'rel': rel, - }; -} diff --git a/lib/mm2/mm2_api/rpc/orderbook/orderbook_response.dart b/lib/mm2/mm2_api/rpc/orderbook/orderbook_response.dart deleted file mode 100644 index 55fa0e5118..0000000000 --- a/lib/mm2/mm2_api/rpc/orderbook/orderbook_response.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_request.dart'; -import 'package:web_dex/model/orderbook/orderbook.dart'; - -class OrderbookResponse - implements ApiResponse { - OrderbookResponse({required this.request, this.result, this.error}); - - @override - final OrderbookRequest request; - @override - final Orderbook? result; - @override - final String? error; -} diff --git a/lib/model/cex_price.dart b/lib/model/cex_price.dart index be9f247628..bbf5d0d977 100644 --- a/lib/model/cex_price.dart +++ b/lib/model/cex_price.dart @@ -1,12 +1,56 @@ +import 'package:decimal/decimal.dart'; +import 'package:equatable/equatable.dart' show Equatable; +import 'package:flutter/foundation.dart' show ValueGetter; import 'package:komodo_cex_market_data/komodo_cex_market_data.dart' as sdk_types; +import 'package:komodo_defi_types/komodo_defi_types.dart' show AssetId; typedef CexDataProvider = sdk_types.CexDataProvider; CexDataProvider cexDataProvider(String string) { return CexDataProvider.values.firstWhere( - (e) => e.toString().split('.').last == string, - orElse: () => CexDataProvider.unknown); + (e) => e.toString().split('.').last == string, + orElse: () => CexDataProvider.unknown, + ); } -typedef CexPrice = sdk_types.CexPrice; +@Deprecated( + 'Use the KomodoDefiSdk.marketData interface instead. ' + 'This class will be removed in the future, and is only being kept during ' + 'the transition to the new SDK.', +) +/// A temporary class to hold the price and change24h for a coin in a structure +/// similar to the one used in the legacy coins bloc during the transition to +/// to the SDK. +class CexPrice extends Equatable { + const CexPrice({ + required this.assetId, + required this.price, + required this.change24h, + required this.lastUpdated, + }); + + final AssetId assetId; + final Decimal? price; + final Decimal? change24h; + final DateTime lastUpdated; + + @override + List get props => [assetId, price, change24h, lastUpdated]; + + CexPrice copyWith({ + ValueGetter? price, + ValueGetter? change24h, + DateTime? lastUpdated, + }) { + return CexPrice( + assetId: assetId, + price: price?.call() ?? this.price, + change24h: change24h?.call() ?? this.change24h, + lastUpdated: lastUpdated ?? this.lastUpdated, + ); + } + + // Intentionally excluding to/from JSON methods since this class should not be + // used outside of the legacy coins bloc, especially not for serialization. +} diff --git a/lib/model/coin.dart b/lib/model/coin.dart index 3b84b99484..b9322b68ea 100644 --- a/lib/model/coin.dart +++ b/lib/model/coin.dart @@ -34,8 +34,8 @@ class Coin extends Equatable { required String? swapContractAddress, required bool walletOnly, required this.mode, - }) : _swapContractAddress = swapContractAddress, - _walletOnly = walletOnly; + }) : _swapContractAddress = swapContractAddress, + _walletOnly = walletOnly; final String abbr; final String name; @@ -54,14 +54,16 @@ class Coin extends Equatable { final int decimals; @Deprecated( - 'Use sdk.prices.fiatPrice(id) instead. This value is not updated after initial load and may be inaccurate.') + 'Use sdk.prices.fiatPrice(id) instead. This value is not updated after initial load and may be inaccurate.', + ) CexPrice? usdPrice; final bool isTestCoin; bool isCustomCoin; @Deprecated( - '$_urgentDeprecationNotice Use the SDK\'s Asset multi-address support instead. The wallet now works with multiple addresses per account.') + '$_urgentDeprecationNotice Use the SDK\'s Asset multi-address support instead. The wallet now works with multiple addresses per account.', + ) String? address; final String? _swapContractAddress; @@ -73,6 +75,11 @@ class Coin extends Equatable { final CoinMode mode; final CoinState state; + // Cache for expensive computed properties + String? _cachedTypeName; + bool? _cachedisParent; + String? _cachedDisplayName; + bool get walletOnly => _walletOnly || appWalletOnlyAssetList.contains(abbr); String? get swapContractAddress => @@ -83,12 +90,28 @@ class Coin extends Equatable { bool get isInactive => state == CoinState.inactive; @Deprecated( - '$_urgentDeprecationNotice Use the SDK\'s Asset.sendableBalance instead. This value is not updated after initial load and may be inaccurate.') + '$_urgentDeprecationNotice Use the SDK\'s Asset.sendableBalance instead. This value is not updated after initial load and may be inaccurate.', + ) double sendableBalance = 0; - String get typeName => getCoinTypeName(type); + String get typeName { + return _cachedTypeName ??= getCoinTypeName(type, abbr); + } + + bool get isParent { + return _cachedisParent ??= isParentCoin(type, abbr); + } + String get typeNameWithTestnet => typeName + (isTestCoin ? ' (TESTNET)' : ''); + /// Display-friendly name that disambiguates parent coins on different EVM networks. + /// + /// For example, for a parent coin with abbreviation 'ETH-ARB20', this returns + /// 'Ethereum (ARB20)' so that it is visually distinct from 'Ethereum' (ERC20). + String get displayName { + return _cachedDisplayName ??= id.displayName; + } + bool get isIrisToken => protocolType == 'TENDERMINTTOKEN'; bool get need0xPrefixForTxHash => isErcType; @@ -220,13 +243,10 @@ extension LegacyCoinToSdkAsset on Coin { } class ProtocolData { - ProtocolData({ - required this.platform, - required this.contractAddress, - }); + ProtocolData({required this.platform, required this.contractAddress}); factory ProtocolData.fromJson(Map json) => ProtocolData( - platform: json['platform'], + platform: json['platform'], contractAddress: json['contract_address'] ?? '', ); @@ -259,13 +279,7 @@ class CoinNode { enum CoinMode { segwit, standard, hw } -enum CoinState { - inactive, - activating, - active, - suspended, - hidden, -} +enum CoinState { inactive, activating, active, suspended, hidden } extension CoinListExtension on List { Map toMap() { diff --git a/lib/model/coin_type.dart b/lib/model/coin_type.dart index b10ac1a6bc..9ee370e8c5 100644 --- a/lib/model/coin_type.dart +++ b/lib/model/coin_type.dart @@ -19,4 +19,5 @@ enum CoinType { tendermintToken, tendermint, slp, + zhtlc, } diff --git a/lib/model/coin_utils.dart b/lib/model/coin_utils.dart index a2e649d62b..afc400cb84 100644 --- a/lib/model/coin_utils.dart +++ b/lib/model/coin_utils.dart @@ -11,7 +11,7 @@ import 'package:web_dex/shared/utils/utils.dart'; /// 2. If no balance, sort by priority (higher priority first) /// 3. If same priority, sort alphabetically List sortByPriorityAndBalance(List coins, KomodoDefiSdk sdk) { - final List list = List.from(coins); + final List list = List.of(coins); list.sort((a, b) { final double usdBalanceA = a.lastKnownUsdBalance(sdk) ?? 0.00; final double usdBalanceB = b.lastKnownUsdBalance(sdk) ?? 0.00; @@ -36,7 +36,7 @@ List sortByPriorityAndBalance(List coins, KomodoDefiSdk sdk) { } List sortFiatBalance(List coins, KomodoDefiSdk sdk) { - final List list = List.from(coins); + final List list = List.of(coins); list.sort((a, b) { final double usdBalanceA = a.lastKnownUsdBalance(sdk) ?? 0.00; final double usdBalanceB = b.lastKnownUsdBalance(sdk) ?? 0.00; @@ -57,28 +57,11 @@ List sortFiatBalance(List coins, KomodoDefiSdk sdk) { } List removeTestCoins(List coins) { - final List list = List.from(coins); - - list.removeWhere((Coin coin) => coin.isTestCoin); - - return list; + return coins.where((Coin coin) => !coin.isTestCoin).toList(); } List removeWalletOnly(List coins) { - final List list = List.from(coins); - - list.removeWhere((Coin coin) => coin.walletOnly); - - return list; -} - -List removeSuspended(List coins, bool isLoggedIn) { - if (!isLoggedIn) return coins; - final List list = List.from(coins); - - list.removeWhere((Coin coin) => coin.isSuspended); - - return list; + return coins.where((Coin coin) => !coin.walletOnly).toList(); } Map> removeSingleProtocol(Map> group) { @@ -88,7 +71,9 @@ Map> removeSingleProtocol(Map> group) { } CoinsByTicker removeTokensWithEmptyOrderbook( - CoinsByTicker tokenGroups, List depths) { + CoinsByTicker tokenGroups, + List depths, +) { final CoinsByTicker copy = CoinsByTicker.from(tokenGroups); copy.removeWhere((key, value) { @@ -109,21 +94,18 @@ CoinsByTicker removeTokensWithEmptyOrderbook( } CoinsByTicker convertToCoinsByTicker(List coinsList) { - return coinsList.fold( - {}, - (previousValue, coin) { - final String ticker = abbr2Ticker(coin.abbr); - final List? coinsWithSameTicker = previousValue[ticker]; - - if (coinsWithSameTicker == null) { - previousValue[ticker] = [coin]; - } else if (!isCoinInList(coin, coinsWithSameTicker)) { - coinsWithSameTicker.add(coin); - } - - return previousValue; - }, - ); + return coinsList.fold({}, (previousValue, coin) { + final String ticker = abbr2Ticker(coin.abbr); + final List? coinsWithSameTicker = previousValue[ticker]; + + if (coinsWithSameTicker == null) { + previousValue[ticker] = [coin]; + } else if (!isCoinInList(coin, coinsWithSameTicker)) { + coinsWithSameTicker.add(coin); + } + + return previousValue; + }); } bool isCoinInList(Coin coin, List list) { @@ -136,7 +118,7 @@ Iterable filterCoinsByPhrase(Iterable coins, String phrase) { } bool compareCoinByPhrase(Coin coin, String phrase) { - final String compareName = coin.name.toLowerCase(); + final String compareName = coin.displayName.toLowerCase(); final String compareAbbr = abbr2Ticker(coin.abbr).toLowerCase(); final lowerCasePhrase = phrase.toLowerCase(); @@ -145,7 +127,11 @@ bool compareCoinByPhrase(Coin coin, String phrase) { compareAbbr.contains(lowerCasePhrase); } -String getCoinTypeName(CoinType type) { +String getCoinTypeName(CoinType type, [String? symbol]) { + // Override for parent chain coins like ETH, AVAX etc. + if (symbol != null && isParentCoin(type, symbol)) { + return 'Native'; + } switch (type) { case CoinType.erc20: return 'ERC-20'; @@ -185,6 +171,40 @@ String getCoinTypeName(CoinType type) { return 'Tendermint Token'; case CoinType.slp: return 'SLP'; + case CoinType.zhtlc: + return 'ZHTLC'; + } +} + +bool isParentCoin(CoinType type, String symbol) { + switch (type) { + case CoinType.utxo: + case CoinType.tendermint: + return true; + case CoinType.erc20: + return symbol == 'ETH'; + case CoinType.bep20: + return symbol == 'BNB'; + case CoinType.avx20: + return symbol == 'AVAX'; + case CoinType.etc: + return symbol == 'ETC'; + case CoinType.ftm20: + return symbol == 'FTM'; + case CoinType.arb20: + return symbol == 'ETH-ARB20'; + case CoinType.hrc20: + return symbol == 'ONE'; + case CoinType.plg20: + return symbol == 'MATIC'; + case CoinType.mvr20: + return symbol == 'MOVR'; + case CoinType.krc20: + return symbol == 'KCS'; + case CoinType.qrc20: + return symbol == 'QTUM'; + default: + return false; } } diff --git a/lib/model/kdf_auth_metadata_extension.dart b/lib/model/kdf_auth_metadata_extension.dart index 92bcac6cd4..27dfa875ad 100644 --- a/lib/model/kdf_auth_metadata_extension.dart +++ b/lib/model/kdf_auth_metadata_extension.dart @@ -1,59 +1,158 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart' show Asset; +import 'package:logging/logging.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/wallet.dart'; +final Logger _walletMetadataLog = Logger('KdfAuthMetadataExtension'); + extension KdfAuthMetadataExtension on KomodoDefiSdk { + /// Checks if a wallet with the specified ID exists in the system. + /// + /// Returns `true` if a user with the given [walletId] is found among + /// all registered users, `false` otherwise. Future walletExists(String walletId) async { final users = await auth.getUsers(); return users.any((user) => user.walletId.name == walletId); } + /// Returns the wallet associated with the currently authenticated user. + /// + /// Returns `null` if no user is currently signed in. Future currentWallet() async { final user = await auth.currentUser; return user?.wallet; } + /// Returns the stored list of wallet coin/asset IDs. + /// + /// If no user is signed in, returns an empty list. Future> getWalletCoinIds() async { final user = await auth.currentUser; return user?.metadata.valueOrNull>('activated_coins') ?? []; } - Future> getWalletCoins() async { + /// Returns the stored list of wallet assets resolved from configuration IDs. + /// + /// Missing assets (for example, delisted coins) are skipped and logged for + /// visibility. + /// + /// Throws [StateError] if multiple assets are found with the same configuration ID. + Future> getWalletAssets() async { final coinIds = await getWalletCoinIds(); - return coinIds - // use single to stick to the existing behaviour around assetByTicker - // which will cause the application to crash if there are - // multiple assets with the same ticker - .map((coinId) => assets.findAssetsByConfigId(coinId).single.toCoin()) - .toList(); + if (coinIds.isEmpty) { + return []; + } + + final missingCoinIds = {}; + final walletAssets = []; + + for (final coinId in coinIds) { + final matchingAssets = assets.findAssetsByConfigId(coinId); + if (matchingAssets.isEmpty) { + missingCoinIds.add(coinId); + continue; + } + + if (matchingAssets.length > 1) { + final assetIds = matchingAssets.map((asset) => asset.id.id).join(', '); + final message = + 'Multiple assets found for activated coin ID "$coinId": $assetIds'; + _walletMetadataLog.shout(message); + throw StateError(message); + } + + walletAssets.add(matchingAssets.single); + } + + if (missingCoinIds.isNotEmpty) { + _walletMetadataLog.warning( + 'Skipping ${missingCoinIds.length} activated coin(s) that are no longer ' + 'available in the SDK (likely delisted): ' + '${missingCoinIds.join(', ')}', + ); + } + + return walletAssets; + } + + /// Returns the stored list of wallet coins converted from asset configuration IDs. + /// + /// This method retrieves the coin IDs from user metadata and converts them + /// to [Coin] objects. Uses `single` to maintain existing behavior which will + /// throw an exception if multiple assets share the same ticker. + /// + /// Missing assets (for example, delisted coins) are skipped and logged for + /// visibility. + /// + /// If no user is signed in, returns an empty list. + /// + /// Throws [StateError] if multiple assets are found with the same configuration ID. + Future> getWalletCoins() async { + final walletAssets = await getWalletAssets(); + return walletAssets.map((asset) => asset.toCoin()).toList(); } + /// 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. + /// + /// If no user is currently signed in, the operation will complete but have no effect. + /// + /// [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 existingCoins = + (await auth.currentUser)?.metadata.valueOrNull>( + 'activated_coins', + ) ?? []; final mergedCoins = {...existingCoins, ...coins}.toList(); await auth.setOrRemoveActiveUserKeyValue('activated_coins', mergedCoins); } + /// 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. + /// + /// If no user is currently signed in, the operation will complete but have no effect. + /// + /// [coins] - A list of coin/asset configuration IDs to remove. Future removeActivatedCoins(List coins) async { - final existingCoins = (await auth.currentUser) - ?.metadata - .valueOrNull>('activated_coins') ?? + final existingCoins = + (await auth.currentUser)?.metadata.valueOrNull>( + 'activated_coins', + ) ?? []; existingCoins.removeWhere((coin) => coins.contains(coin)); await auth.setOrRemoveActiveUserKeyValue('activated_coins', existingCoins); } + /// Sets the seed backup confirmation status for the current user. + /// + /// This method stores whether the user has confirmed backing up their seed phrase. + /// This is typically used to track wallet security compliance. + /// + /// If no user is currently signed in, the operation will complete but have no effect. + /// + /// [hasBackup] - Whether the seed has been backed up. Defaults to `true`. Future confirmSeedBackup({bool hasBackup = true}) async { await auth.setOrRemoveActiveUserKeyValue('has_backup', hasBackup); } + /// Sets the wallet type for the current user. + /// + /// This method stores the wallet type in user metadata, which can be used + /// to determine wallet-specific behavior and features. + /// + /// If no user is currently signed in, the operation will complete but have no effect. + /// + /// [type] - The wallet type to set for the current user. Future setWalletType(WalletType type) async { await auth.setOrRemoveActiveUserKeyValue('type', type.name); } diff --git a/lib/model/nft.dart b/lib/model/nft.dart index eb8fe8ab08..5d06236f9b 100644 --- a/lib/model/nft.dart +++ b/lib/model/nft.dart @@ -75,16 +75,7 @@ class NftToken { String? get description => metaData?.description ?? uriMeta.description; String? get imageUrl { final image = uriMeta.imageUrl ?? metaData?.image ?? uriMeta.animationUrl; - if (image == null) return null; - - // Image.network does not support ipfs protocol - String url = image.replaceFirst('ipfs://', 'https://ipfs.io/ipfs/'); - - // Also standardize gateway URLs to use ipfs.io Match both patterns: - // gateway.moralisipfs.com/ipfs/ and common.ipfs.gateway/ipfs/ - final gatewayPattern = - RegExp(r'https://[^/]+(?:\.ipfs\.|ipfs\.)[^/]+/ipfs/'); - return url.replaceAllMapped(gatewayPattern, (_) => 'https://ipfs.io/ipfs/'); + return image; // Return raw URL - bloc will handle normalization and fallbacks } String get uuid => '$chain:$tokenAddress:$tokenId'.hashCode.toString(); diff --git a/lib/model/orderbook/order.dart b/lib/model/orderbook/order.dart index f80b63c0dc..92be3fcaf7 100644 --- a/lib/model/orderbook/order.dart +++ b/lib/model/orderbook/order.dart @@ -1,3 +1,5 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' + show NumericValue, OrderInfo; import 'package:rational/rational.dart'; import 'package:uuid/uuid.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -29,15 +31,53 @@ class Order { uuid: json['uuid'], pubkey: json['pubkey'], price: fract2rat(json['price_fraction']) ?? Rational.parse(json['price']), - maxVolume: fract2rat(json['base_max_volume_fraction']) ?? + maxVolume: + fract2rat(json['base_max_volume_fraction']) ?? Rational.parse(json['base_max_volume']), - minVolume: fract2rat(json['base_min_volume_fraction']) ?? + minVolume: + fract2rat(json['base_min_volume_fraction']) ?? Rational.parse(json['base_min_volume']), - minVolumeRel: fract2rat(json['rel_min_volume_fraction']) ?? + minVolumeRel: + fract2rat(json['rel_min_volume_fraction']) ?? Rational.parse(json['rel_min_volume']), ); } + factory Order.fromOrderInfo( + OrderInfo info, { + required String base, + required String rel, + required OrderDirection direction, + }) { + final Rational? price = info.price?.toRational(); + + final Rational? maxVolume = + (info.baseMaxVolume ?? info.baseMaxVolumeAggregated)?.toRational(); + + if (price == null || maxVolume == null) { + throw ArgumentError('Invalid price or maxVolume in OrderInfo'); + } + + final Rational? minVolume = info.baseMinVolume?.toRational(); + + final Rational? minVolumeRel = + info.relMinVolume?.toRational() ?? + (minVolume != null ? minVolume * price : null); + + return Order( + base: base, + rel: rel, + direction: direction, + price: price, + maxVolume: maxVolume, + address: info.address?.addressData, + uuid: info.uuid, + pubkey: info.pubkey, + minVolume: minVolume, + minVolumeRel: minVolumeRel, + ); + } + final String base; final String rel; final OrderDirection direction; @@ -58,3 +98,22 @@ enum OrderDirection { bid, ask } // This const is used to identify and highlight newly created // order preview in maker form orderbook (instead of isTarget flag) final String orderPreviewUuid = const Uuid().v1(); + +extension NumericValueExtension on NumericValue { + Rational toRational() { + if (rational != null) { + return rational!; + } + if (fraction != null) { + final fractionRat = fract2rat(fraction!.toJson(), false); + if (fractionRat != null) { + return fractionRat; + } + } + final decimal = this.decimal.trim(); + if (decimal.isEmpty) { + throw ArgumentError('NumericValue has empty decimal string'); + } + return Rational.parse(decimal); + } +} diff --git a/lib/model/orderbook/orderbook.dart b/lib/model/orderbook/orderbook.dart index 386ba0dcfb..af1c3b57fd 100644 --- a/lib/model/orderbook/orderbook.dart +++ b/lib/model/orderbook/orderbook.dart @@ -1,3 +1,4 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' as sdk; import 'package:rational/rational.dart'; import 'package:web_dex/model/orderbook/order.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -20,31 +21,86 @@ class Orderbook { base: json['base'], rel: json['rel'], asks: json['asks'] - .map((dynamic item) => Order.fromJson( - item, - direction: OrderDirection.ask, - otherCoin: json['rel'], - )) + .map( + (dynamic item) => Order.fromJson( + item, + direction: OrderDirection.ask, + otherCoin: json['rel'], + ), + ) .toList(), bids: json['bids'] - .map((dynamic item) => Order.fromJson( - item, - direction: OrderDirection.bid, - otherCoin: json['base'], - )) + .map( + (dynamic item) => Order.fromJson( + item, + direction: OrderDirection.bid, + otherCoin: json['base'], + ), + ) .toList(), - bidsBaseVolTotal: fract2rat(json['total_bids_base_vol_fraction']) ?? + bidsBaseVolTotal: + fract2rat(json['total_bids_base_vol_fraction']) ?? Rational.parse(json['total_bids_base_vol']), - bidsRelVolTotal: fract2rat(json['total_bids_rel_vol_fraction']) ?? + bidsRelVolTotal: + fract2rat(json['total_bids_rel_vol_fraction']) ?? Rational.parse(json['total_bids_rel_vol']), - asksBaseVolTotal: fract2rat(json['total_asks_base_vol_fraction']) ?? + asksBaseVolTotal: + fract2rat(json['total_asks_base_vol_fraction']) ?? Rational.parse(json['total_asks_base_vol']), - asksRelVolTotal: fract2rat(json['total_asks_rel_vol_fraction']) ?? + asksRelVolTotal: + fract2rat(json['total_asks_rel_vol_fraction']) ?? Rational.parse(json['total_asks_rel_vol']), timestamp: json['timestamp'], ); } + factory Orderbook.fromSdkResponse(sdk.OrderbookResponse response) { + List _mapOrders( + List orders, + OrderDirection direction, + ) { + return orders + .map( + (info) => Order.fromOrderInfo( + info, + base: response.base, + rel: response.rel, + direction: direction, + ), + ) + .toList(); + } + + final asks = _mapOrders(response.asks, OrderDirection.ask); + final bids = _mapOrders(response.bids, OrderDirection.bid); + + Rational _totalBaseVolume(List orders) { + return orders.fold( + Rational.zero, + (sum, order) => sum + order.maxVolume, + ); + } + + Rational _totalRelVolume(List orders) { + return orders.fold( + Rational.zero, + (sum, order) => sum + (order.maxVolume * order.price), + ); + } + + return Orderbook( + base: response.base, + rel: response.rel, + bidsBaseVolTotal: _totalBaseVolume(bids), + bidsRelVolTotal: _totalRelVolume(bids), + asksBaseVolTotal: _totalBaseVolume(asks), + asksRelVolTotal: _totalRelVolume(asks), + bids: bids, + asks: asks, + timestamp: response.timestamp, + ); + } + final String base; final String rel; final List bids; diff --git a/lib/model/orderbook_model.dart b/lib/model/orderbook_model.dart index 2d0e0e0470..930c77302a 100644 --- a/lib/model/orderbook_model.dart +++ b/lib/model/orderbook_model.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:web_dex/blocs/orderbook_bloc.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_response.dart'; import 'package:web_dex/model/coin.dart'; class OrderbookModel { @@ -33,12 +32,12 @@ class OrderbookModel { StreamSubscription? _orderbookListener; - OrderbookResponse? _response; - final _responseCtrl = StreamController.broadcast(); - Sink get _inResponse => _responseCtrl.sink; - Stream get outResponse => _responseCtrl.stream; - OrderbookResponse? get response => _response; - set response(OrderbookResponse? value) { + OrderbookResult? _response; + final _responseCtrl = StreamController.broadcast(); + Sink get _inResponse => _responseCtrl.sink; + Stream get outResponse => _responseCtrl.stream; + OrderbookResult? get response => _response; + set response(OrderbookResult? value) { _response = value; _inResponse.add(_response); } @@ -59,8 +58,10 @@ class OrderbookModel { response = null; if (base == null || rel == null) return; - final stream = - orderBookRepository.getOrderbookStream(base!.abbr, rel!.abbr); + final stream = orderBookRepository.getOrderbookStream( + base!.abbr, + rel!.abbr, + ); _orderbookListener = stream.listen((resp) => response = resp); } diff --git a/lib/model/wallet.dart b/lib/model/wallet.dart index 2a18f8637f..b7227b0544 100644 --- a/lib/model/wallet.dart +++ b/lib/model/wallet.dart @@ -6,19 +6,15 @@ import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/shared/utils/encryption_tool.dart'; class Wallet { - Wallet({ - required this.id, - required this.name, - required this.config, - }); + Wallet({required this.id, required this.name, required this.config}); factory Wallet.fromJson(Map json) => Wallet( - id: json['id'] as String? ?? '', - name: json['name'] as String? ?? '', - config: WalletConfig.fromJson( - json['config'] as Map? ?? {}, - ), - ); + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + config: WalletConfig.fromJson( + json['config'] as Map? ?? {}, + ), + ); /// Creates a wallet from a name and the optional parameters. /// [name] - The name of the wallet. @@ -49,11 +45,7 @@ class Wallet { required String name, required WalletConfig config, }) { - return Wallet( - id: const Uuid().v1(), - name: name, - config: config, - ); + return Wallet(id: const Uuid().v1(), name: name, config: config); } String id; @@ -67,16 +59,24 @@ class Wallet { await EncryptionTool().decryptData(password, config.seedPhrase) ?? ''; Map toJson() => { - 'id': id, - 'name': name, - 'config': config.toJson(), - }; + 'id': id, + 'name': name, + 'config': config.toJson(), + }; Wallet copy() { + return Wallet(id: id, name: name, config: config.copy()); + } + + Wallet copyWith({ + String? id, + String? name, + WalletConfig? config, + }) { return Wallet( - id: id, - name: name, - config: config.copy(), + id: id ?? this.id, + name: name ?? this.name, + config: config ?? this.config.copy(), ); } } @@ -98,9 +98,9 @@ class WalletConfig { ), seedPhrase: json['seed_phrase'] as String? ?? '', pubKey: json['pub_key'] as String?, - activatedCoins: - List.from(json['activated_coins'] as List? ?? []) - .toList(), + activatedCoins: List.from( + json['activated_coins'] as List? ?? [], + ).toList(), hasBackup: json['has_backup'] as bool? ?? false, ); } @@ -131,6 +131,24 @@ class WalletConfig { pubKey: pubKey, ); } + + WalletConfig copyWith({ + String? seedPhrase, + String? pubKey, + List? activatedCoins, + bool? hasBackup, + WalletType? type, + bool? isLegacyWallet, + }) { + return WalletConfig( + seedPhrase: seedPhrase ?? this.seedPhrase, + pubKey: pubKey ?? this.pubKey, + activatedCoins: activatedCoins ?? [...this.activatedCoins], + hasBackup: hasBackup ?? this.hasBackup, + type: type ?? this.type, + isLegacyWallet: isLegacyWallet ?? this.isLegacyWallet, + ); + } } enum WalletType { @@ -158,8 +176,9 @@ enum WalletType { extension KdfUserWalletExtension on KdfUser { Wallet get wallet { - final walletType = - WalletType.fromJson(metadata['type'] as String? ?? 'iguana'); + final walletType = WalletType.fromJson( + metadata['type'] as String? ?? 'iguana', + ); return Wallet( id: walletId.name, name: walletId.name, diff --git a/lib/router/navigators/main_layout/main_layout_router.dart b/lib/router/navigators/main_layout/main_layout_router.dart index d1fe561e2d..5d2c21fa37 100644 --- a/lib/router/navigators/main_layout/main_layout_router.dart +++ b/lib/router/navigators/main_layout/main_layout_router.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:web_dex/router/navigators/main_layout/main_layout_router_delegate.dart'; class MainLayoutRouter extends StatefulWidget { + const MainLayoutRouter({super.key}); + @override State createState() => _MainLayoutRouterState(); } @@ -11,8 +13,6 @@ class _MainLayoutRouterState extends State { @override Widget build(BuildContext context) { - return Router( - routerDelegate: _routerDelegate, - ); + return Router(routerDelegate: _routerDelegate); } } diff --git a/lib/services/arrr_activation/arrr_activation_service.dart b/lib/services/arrr_activation/arrr_activation_service.dart new file mode 100644 index 0000000000..80be34869e --- /dev/null +++ b/lib/services/arrr_activation/arrr_activation_service.dart @@ -0,0 +1,556 @@ +import 'dart:async'; + +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart' + show ExponentialBackoff, retry; +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/mm2/mm2_api/rpc/disable_coin/disable_coin_req.dart'; + +import 'arrr_config.dart'; + +/// Service layer - business logic coordination for ARRR activation +class ArrrActivationService { + ArrrActivationService(this._sdk, this._mm2) + : _configService = _sdk.activationConfigService { + _startListeningToAuthChanges(); + } + + final ActivationConfigService _configService; + final KomodoDefiSdk _sdk; + final MM2 _mm2; + final Logger _log = Logger('ArrrActivationService'); + + /// Stream controller for configuration requests + final StreamController _configRequestController = + StreamController.broadcast(); + + /// Completer to wait for configuration when needed + final Map> _configCompleters = {}; + + /// Track ongoing activation flows per asset to prevent duplicate runs + final Map> _ongoingActivations = {}; + + /// Subscription to auth state changes + StreamSubscription? _authSubscription; + + /// Flag to track if the service is being disposed + bool _isDisposing = false; + + /// Stream of configuration requests that UI can listen to + Stream get configurationRequests => + _configRequestController.stream; + + /// Future-based activation (for CoinsRepo consumers) + /// This method will wait for user configuration if needed + Future activateArrr( + Asset asset, { + ZhtlcUserConfig? initialConfig, + }) { + if (_isDisposing || _configRequestController.isClosed) { + throw StateError('ArrrActivationService has been disposed'); + } + + final existingActivation = _ongoingActivations[asset.id]; + if (existingActivation != null) { + _log.info( + 'Activation already in progress for ${asset.id.id} - reusing existing future', + ); + return existingActivation; + } + + late Future activationFuture; + activationFuture = + _activateArrrInternal(asset, initialConfig: initialConfig).whenComplete( + () { + _ongoingActivations.remove(asset.id); + }, + ); + _ongoingActivations[asset.id] = activationFuture; + return activationFuture; + } + + Future _activateArrrInternal( + Asset asset, { + ZhtlcUserConfig? initialConfig, + }) async { + var config = initialConfig ?? await _getOrRequestConfiguration(asset.id); + + if (config == null) { + final requiredSettings = await _getRequiredSettings(asset.id); + + final configRequest = ZhtlcConfigurationRequest( + asset: asset, + requiredSettings: requiredSettings, + ); + + final completer = Completer(); + _configCompleters[asset.id] = completer; + + _log.info('Requesting configuration for ${asset.id.id}'); + + // Check if stream controller is closed or service is disposing + if (_isDisposing || _configRequestController.isClosed) { + _log.severe( + 'Configuration request controller is closed or service is disposing for ${asset.id.id}', + ); + _configCompleters.remove(asset.id); + return ArrrActivationResultError( + 'Configuration system is not available', + ); + } + + // Wait for UI listeners to be ready before emitting request + await _waitForUIListeners(asset.id); + + try { + _configRequestController.add(configRequest); + _log.info('Configuration request emitted for ${asset.id.id}'); + } catch (e, stackTrace) { + _log.severe( + 'Failed to emit configuration request for ${asset.id.id}', + e, + stackTrace, + ); + return ArrrActivationResultError('Failed to request configuration: $e'); + } + + try { + config = await completer.future.timeout( + const Duration(minutes: 15), + onTimeout: () { + _log.warning('Configuration request timed out for ${asset.id.id}'); + return null; + }, + ); + } finally { + _configCompleters.remove(asset.id); + } + + if (config == null) { + _log.info('Configuration cancelled/timed out for ${asset.id.id}'); + return ArrrActivationResultError( + 'Configuration cancelled by user or timed out', + ); + } + + _log.info('Configuration received for ${asset.id.id}'); + } + + _log.info('Starting activation with configuration for ${asset.id.id}'); + return _performActivation(asset, config); + } + + /// Perform the actual activation with configuration + Future _performActivation( + Asset asset, + ZhtlcUserConfig config, + ) async { + const maxAttempts = 5; + var attempt = 0; + + try { + final result = await retry( + () async { + attempt += 1; + _log.info( + 'Starting ARRR activation attempt $attempt for ${asset.id.id}', + ); + + await _cacheActivationStart(asset.id); + + ActivationProgress? lastActivationProgress; + await for (final activationProgress in _sdk.assets.activateAsset( + asset, + )) { + await _cacheActivationProgress(asset.id, activationProgress); + lastActivationProgress = activationProgress; + } + + if (lastActivationProgress?.isSuccess ?? false) { + await _cacheActivationComplete(asset.id); + return ArrrActivationResultSuccess( + Stream.value( + ActivationProgress( + status: 'Activation completed successfully', + progressPercentage: 100, + isComplete: true, + progressDetails: ActivationProgressDetails( + currentStep: ActivationStep.complete, + stepCount: 1, + ), + ), + ), + ); + } + + final errorMessage = + lastActivationProgress?.errorMessage ?? + 'Unknown activation error'; + throw _RetryableZhtlcActivationException(errorMessage); + }, + maxAttempts: maxAttempts, + backoffStrategy: ExponentialBackoff( + initialDelay: const Duration(seconds: 5), + maxDelay: const Duration(seconds: 30), + ), + onRetry: (currentAttempt, error, delay) { + _log.warning( + 'ARRR activation attempt $currentAttempt for ${asset.id.id} failed. ' + 'Retrying in ${delay.inMilliseconds}ms. Error: $error', + ); + }, + ); + + return result; + } catch (e, stackTrace) { + _log.severe( + 'ARRR activation failed after $maxAttempts attempts for ${asset.id.id}', + e, + stackTrace, + ); + await _cacheActivationError(asset.id, e.toString()); + return ArrrActivationResultError(e.toString()); + } + } + + Future _getOrRequestConfiguration(AssetId assetId) async { + final existing = await _configService.getSavedZhtlc(assetId); + if (existing != null) return existing; + + return null; + } + + Future> _getRequiredSettings( + AssetId assetId, + ) async { + return assetId.activationSettings(); + } + + /// Activation status caching for UI display + final Map _activationCache = {}; + final ReadWriteMutex _activationCacheMutex = ReadWriteMutex(); + + Future _cacheActivationStart(AssetId assetId) async { + await _activationCacheMutex.protectWrite(() async { + _activationCache[assetId] = ArrrActivationStatusInProgress( + assetId: assetId, + startTime: DateTime.now(), + ); + }); + } + + Future _cacheActivationProgress( + AssetId assetId, + ActivationProgress progress, + ) async { + await _activationCacheMutex.protectWrite(() async { + final current = _activationCache[assetId]; + if (current is ArrrActivationStatusInProgress) { + _activationCache[assetId] = current.copyWith( + progressPercentage: progress.progressPercentage?.toInt(), + currentStep: progress.progressDetails?.currentStep, + statusMessage: progress.status, + ); + } + }); + } + + Future _cacheActivationComplete(AssetId assetId) async { + await _activationCacheMutex.protectWrite(() async { + _activationCache[assetId] = ArrrActivationStatusCompleted( + assetId: assetId, + completionTime: DateTime.now(), + ); + }); + } + + Future _cacheActivationError( + AssetId assetId, + String errorMessage, + ) async { + await _activationCacheMutex.protectWrite(() async { + _activationCache[assetId] = ArrrActivationStatusError( + assetId: assetId, + errorMessage: errorMessage, + errorTime: DateTime.now(), + ); + }); + } + + // Public method for UI to check activation status + Future getActivationStatus(AssetId assetId) async { + return _activationCacheMutex.protectRead( + () async => _activationCache[assetId], + ); + } + + // Public method for UI to get all cached activation statuses + Future> get activationStatuses async { + return _activationCacheMutex.protectRead( + () async => + Map.unmodifiable(_activationCache), + ); + } + + // Clear cached status when no longer needed + Future clearActivationStatus(AssetId assetId) async { + await _activationCacheMutex.protectWrite( + () async => _activationCache.remove(assetId), + ); + } + + /// Submit configuration for a pending request + /// Called by UI when user provides configuration + Future submitConfiguration( + AssetId assetId, + ZhtlcUserConfig config, + ) async { + if (_isDisposing) { + _log.warning('Ignoring configuration submission - service is disposing'); + return; + } + _log.info('Submitting configuration for ${assetId.id}'); + + // Save configuration to SDK + final completer = _configCompleters[assetId]; + try { + await _configService.saveZhtlcConfig(assetId, config); + _log.info('Configuration saved to SDK for ${assetId.id}'); + } catch (e) { + final error = ArrrActivationResultError( + 'Failed to save configuration: $e', + ); + _log.severe( + 'Failed to save configuration to SDK for ${assetId.id}', + error, + ); + completer?.completeError(error); + return; + } + + if (completer != null && !completer.isCompleted) { + completer.complete(config); + } else { + _log.warning('No pending completer found for ${assetId.id}'); + } + } + + /// Cancel configuration for a pending request + /// Called by UI when user cancels configuration + void cancelConfiguration(AssetId assetId) { + _log.info('Cancelling configuration for ${assetId.id}'); + final completer = _configCompleters[assetId]; + if (completer != null && !completer.isCompleted) { + completer.complete(null); + } else { + _log.warning('No pending completer found for ${assetId.id}'); + } + } + + /// Get diagnostic information about the configuration request system + Map getConfigurationSystemDiagnostics() { + return { + 'hasListeners': _configRequestController.hasListener, + 'isClosed': _configRequestController.isClosed, + 'pendingCompleters': _configCompleters.keys.map((id) => id.id).toList(), + 'handledConfigurations': _configCompleters.length, + }; + } + + /// Test method to verify configuration request system is working + /// This will log diagnostic information + void diagnoseConfigurationSystem() { + final diagnostics = getConfigurationSystemDiagnostics(); + _log.info('Configuration system diagnostics: $diagnostics'); + + if (!_configRequestController.hasListener) { + _log.warning( + 'No listeners detected for configuration requests. ' + 'Make sure ZhtlcConfigurationHandler is in the widget tree.', + ); + } + + if (_configRequestController.isClosed) { + _log.severe('Configuration request controller is closed!'); + } + } + + /// Wait for UI listeners to be ready before emitting configuration requests + /// This ensures the ZhtlcConfigurationHandler is properly initialized + Future _waitForUIListeners(AssetId assetId) async { + const maxWaitTime = Duration(seconds: 10); + const checkInterval = Duration(milliseconds: 100); + final stopwatch = Stopwatch()..start(); + + while (!_configRequestController.hasListener && + stopwatch.elapsed < maxWaitTime) { + _log.info('Waiting for UI listeners to be ready for ${assetId.id}...'); + await Future.delayed(checkInterval); + } + + if (!_configRequestController.hasListener) { + _log.warning( + 'No UI listeners detected after ${maxWaitTime.inSeconds} seconds for ${assetId.id}. ' + 'Make sure ZhtlcConfigurationHandler is in the widget tree.', + ); + } else { + _log.info( + 'UI listeners ready for ${assetId.id} after ${stopwatch.elapsed.inMilliseconds}ms', + ); + } + + stopwatch.stop(); + } + + /// Start listening to authentication state changes + void _startListeningToAuthChanges() { + _authSubscription?.cancel(); + _authSubscription = _sdk.auth.watchCurrentUser().listen( + (user) => unawaited(_handleAuthStateChange(user)), + ); + } + + /// Handle authentication state changes + Future _handleAuthStateChange(KdfUser? user) async { + if (user == null) { + // User signed out - cleanup all active operations + await _cleanupOnSignOut(); + } + } + + /// Clean up all user-specific state when user signs out + Future _cleanupOnSignOut() async { + _log.info('User signed out - cleaning up active ZHTLC activations'); + + // Cancel all pending configuration requests + final pendingAssets = _configCompleters.keys.toList(); + for (final assetId in pendingAssets) { + final completer = _configCompleters[assetId]; + if (completer != null && !completer.isCompleted) { + _log.info('Cancelling pending configuration request for ${assetId.id}'); + completer.complete(null); + } + } + _configCompleters.clear(); + + // Clear activation cache as it's user-specific + var activeAssets = []; + await _activationCacheMutex.protectWrite(() async { + activeAssets = _activationCache.keys.toList(); + for (final assetId in activeAssets) { + _log.info('Clearing activation status for ${assetId.id}'); + } + _activationCache.clear(); + }); + + _log.info( + 'Cleanup completed - cancelled ${pendingAssets.length} pending configs and cleared ${activeAssets.length} activation statuses', + ); + } + + /// Updates the configuration for an already activated ZHTLC coin + /// This will: + /// 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 { + if (_isDisposing || _configRequestController.isClosed) { + throw StateError('ArrrActivationService has been disposed'); + } + + _log.info('Updating ZHTLC configuration for ${asset.id.id}'); + + try { + // Cancel any pending configuration requests + final completer = _configCompleters[asset.id]; + if (completer != null && !completer.isCompleted) { + _log.info( + 'Cancelling pending configuration request for ${asset.id.id}', + ); + completer.complete(null); + _configCompleters.remove(asset.id); + } + + // 2. Disable the coin if it's currently active + await _disableCoin(asset.id.id); + + // 3. Store the new configuration + _log.info('Saving new configuration for ${asset.id.id}'); + await _configService.saveZhtlcConfig(asset.id, newConfig); + } catch (e, stackTrace) { + _log.severe( + 'Failed to update ZHTLC configuration for ${asset.id.id}', + e, + stackTrace, + ); + await _cacheActivationError(asset.id, e.toString()); + } + } + + /// Disable a coin by calling the MM2 disable_coin RPC + /// Copied from CoinsRepo._disableCoin for consistency + Future _disableCoin(String coinId) async { + try { + final activatedAssets = await _sdk.assets.getEnabledCoins(); + final isCurrentlyActive = activatedAssets.any( + (configId) => configId == coinId, + ); + if (isCurrentlyActive) { + _log.info('Disabling currently active ZHTLC coin $coinId'); + await _mm2.call(DisableCoinReq(coin: coinId)); + _log.info('Successfully disabled coin $coinId'); + } + } catch (e, s) { + _log.shout('Error disabling $coinId', e, s); + // Don't rethrow - we want to continue with the configuration update + } + } + + /// Dispose resources + void dispose() { + // Mark as disposing to prevent new operations + _isDisposing = true; + + // Cancel auth subscription first + _authSubscription?.cancel(); + + // Complete any pending configuration requests with a specific error + for (final completer in _configCompleters.values) { + if (!completer.isCompleted) { + completer.completeError(StateError('Service is being disposed')); + } + } + _configCompleters.clear(); + + // Close controller after ensuring all operations are complete + if (!_configRequestController.isClosed) { + _configRequestController.close(); + } + } +} + +class _RetryableZhtlcActivationException implements Exception { + const _RetryableZhtlcActivationException(this.message); + + final String message; + + @override + String toString() => 'RetryableZhtlcActivationException: $message'; +} + +/// Configuration request model for UI handling +class ZhtlcConfigurationRequest { + const ZhtlcConfigurationRequest({ + required this.asset, + required this.requiredSettings, + }); + + final Asset asset; + final List requiredSettings; +} diff --git a/lib/services/arrr_activation/arrr_config.dart b/lib/services/arrr_activation/arrr_config.dart new file mode 100644 index 0000000000..2c70f9dd0b --- /dev/null +++ b/lib/services/arrr_activation/arrr_config.dart @@ -0,0 +1,191 @@ +import 'package:equatable/equatable.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// ARRR activation result for Future-based API +abstract class ArrrActivationResult { + const ArrrActivationResult(); + + T when({ + required T Function(Stream progress) success, + required T Function( + String coinId, + List requiredSettings, + ) + needsConfiguration, + required T Function(String message) error, + }) { + if (this is ArrrActivationResultSuccess) { + final self = this as ArrrActivationResultSuccess; + return success(self.progress); + } else if (this is ArrrActivationResultNeedsConfig) { + final self = this as ArrrActivationResultNeedsConfig; + return needsConfiguration(self.coinId, self.requiredSettings); + } else if (this is ArrrActivationResultError) { + final self = this as ArrrActivationResultError; + return error(self.message); + } + throw StateError('Unknown ArrrActivationResult type: $runtimeType'); + } +} + +class ArrrActivationResultSuccess extends ArrrActivationResult { + const ArrrActivationResultSuccess(this.progress); + + final Stream progress; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ArrrActivationResultSuccess && other.progress == progress; + } + + @override + int get hashCode => progress.hashCode; +} + +class ArrrActivationResultNeedsConfig extends ArrrActivationResult { + const ArrrActivationResultNeedsConfig({ + required this.coinId, + required this.requiredSettings, + }); + + final String coinId; + final List requiredSettings; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ArrrActivationResultNeedsConfig && + other.coinId == coinId && + other.requiredSettings == requiredSettings; + } + + @override + int get hashCode => Object.hash(coinId, requiredSettings); +} + +class ArrrActivationResultError extends ArrrActivationResult { + const ArrrActivationResultError(this.message); + + final String message; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ArrrActivationResultError && other.message == message; + } + + @override + int get hashCode => message.hashCode; +} + +/// ARRR activation status for UI caching +abstract class ArrrActivationStatus extends Equatable { + const ArrrActivationStatus(); + + T when({ + required T Function( + AssetId assetId, + DateTime startTime, + int? progressPercentage, + ActivationStep? currentStep, + String? statusMessage, + ) + inProgress, + required T Function(AssetId assetId, DateTime completionTime) completed, + required T Function( + AssetId assetId, + String errorMessage, + DateTime errorTime, + ) + error, + }) { + if (this is ArrrActivationStatusInProgress) { + final self = this as ArrrActivationStatusInProgress; + return inProgress( + self.assetId, + self.startTime, + self.progressPercentage, + self.currentStep, + self.statusMessage, + ); + } else if (this is ArrrActivationStatusCompleted) { + final self = this as ArrrActivationStatusCompleted; + return completed(self.assetId, self.completionTime); + } else if (this is ArrrActivationStatusError) { + final self = this as ArrrActivationStatusError; + return error(self.assetId, self.errorMessage, self.errorTime); + } + throw StateError('Unknown ArrrActivationStatus type: $runtimeType'); + } +} + +class ArrrActivationStatusInProgress extends ArrrActivationStatus { + const ArrrActivationStatusInProgress({ + required this.assetId, + required this.startTime, + this.progressPercentage, + this.currentStep, + this.statusMessage, + }); + + final AssetId assetId; + final DateTime startTime; + final int? progressPercentage; + final ActivationStep? currentStep; + final String? statusMessage; + + ArrrActivationStatusInProgress copyWith({ + AssetId? assetId, + DateTime? startTime, + int? progressPercentage, + ActivationStep? currentStep, + String? statusMessage, + }) { + return ArrrActivationStatusInProgress( + assetId: assetId ?? this.assetId, + startTime: startTime ?? this.startTime, + progressPercentage: progressPercentage ?? this.progressPercentage, + currentStep: currentStep ?? this.currentStep, + statusMessage: statusMessage ?? this.statusMessage, + ); + } + + @override + List get props => [ + assetId, + startTime, + progressPercentage, + currentStep, + statusMessage, + ]; +} + +class ArrrActivationStatusCompleted extends ArrrActivationStatus { + const ArrrActivationStatusCompleted({ + required this.assetId, + required this.completionTime, + }); + + final AssetId assetId; + final DateTime completionTime; + + @override + List get props => [assetId, completionTime]; +} + +class ArrrActivationStatusError extends ArrrActivationStatus { + const ArrrActivationStatusError({ + required this.assetId, + required this.errorMessage, + required this.errorTime, + }); + + final AssetId assetId; + final String errorMessage; + final DateTime errorTime; + + @override + List get props => [assetId, errorMessage, errorTime]; +} diff --git a/lib/services/feedback/app_feedback_wrapper.dart b/lib/services/feedback/app_feedback_wrapper.dart new file mode 100644 index 0000000000..0c61aa7cb6 --- /dev/null +++ b/lib/services/feedback/app_feedback_wrapper.dart @@ -0,0 +1,40 @@ +import 'package:feedback/feedback.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/services/feedback/custom_feedback_form.dart'; + +/// Wraps the app with BetterFeedback and provides consistent theming. +class AppFeedbackWrapper extends StatelessWidget { + const AppFeedbackWrapper({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final brightness = Theme.of(context).brightness; + return BetterFeedback( + feedbackBuilder: CustomFeedbackForm.feedbackBuilder, + themeMode: ThemeMode.system, + theme: _buildFeedbackTheme(brightness), + darkTheme: _buildFeedbackTheme(Brightness.dark), + child: child, + ); + } + + FeedbackThemeData _buildFeedbackTheme(Brightness brightness) { + final base = brightness == Brightness.light + ? FeedbackThemeData.light() + : FeedbackThemeData.dark(); + return FeedbackThemeData( + background: base.background, + feedbackSheetColor: base.feedbackSheetColor, + activeFeedbackModeColor: base.activeFeedbackModeColor, + drawColors: base.drawColors, + bottomSheetDescriptionStyle: base.bottomSheetDescriptionStyle, + bottomSheetTextInputStyle: base.bottomSheetTextInputStyle, + dragHandleColor: base.dragHandleColor, + colorScheme: base.colorScheme, + sheetIsDraggable: true, + feedbackSheetHeight: 0.35, + ); + } +} diff --git a/lib/services/feedback/custom_feedback_form.dart b/lib/services/feedback/custom_feedback_form.dart index bba3cb87ce..537260d9a9 100644 --- a/lib/services/feedback/custom_feedback_form.dart +++ b/lib/services/feedback/custom_feedback_form.dart @@ -1,8 +1,8 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:feedback/feedback.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/feedback_form/feedback_form_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; @@ -12,26 +12,25 @@ import 'package:web_dex/views/support/missing_coins_dialog.dart'; /// A form that prompts the user for feedback using BLoC for state management. class CustomFeedbackForm extends StatelessWidget { - const CustomFeedbackForm({ - super.key, - required this.scrollController, - }); + const CustomFeedbackForm({super.key, required this.scrollController}); final ScrollController? scrollController; static FeedbackBuilder get feedbackBuilder => (context, onSubmit, scrollController) => BlocProvider( - create: (_) => FeedbackFormBloc(onSubmit), - child: CustomFeedbackForm(scrollController: scrollController), - ); + create: (_) => FeedbackFormBloc(onSubmit), + child: CustomFeedbackForm(scrollController: scrollController), + ); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final theme = Theme.of(context); + // final theme = Theme.of(context); // Unused here; section widgets read theme directly final isLoading = state.status == FeedbackFormStatus.submitting; final formValid = state.isValid && !isLoading; + final submitLabel = LocaleKeys.send.tr(); + return Form( autovalidateMode: AutovalidateMode.onUserInteraction, child: Column( @@ -41,129 +40,56 @@ class CustomFeedbackForm extends StatelessWidget { children: [ if (scrollController != null) const FeedbackSheetDragHandle(), - ListView( - controller: scrollController, - padding: EdgeInsets.fromLTRB( - 16, - scrollController != null ? 20 : 16, - 16, - 0, - ), + _ScrollableFormContent( + scrollController: scrollController, + topPadding: scrollController != null ? 20.0 : 0.0, children: [ - Text( - 'What kind of feedback do you want to give?', - style: theme.textTheme.titleMedium, + _SectionTitle( + title: LocaleKeys.feedbackFormKindQuestion.tr(), ), - const SizedBox(height: 8), - DropdownButtonFormField( - isExpanded: true, - value: state.feedbackType, - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - validator: (value) { - if (value == null) { - return 'Please select a feedback type'; - } - return null; - }, - items: FeedbackType.values - .map( - (type) => DropdownMenuItem( - value: type, - child: Text(type.description), - ), - ) - .toList(), - onChanged: isLoading - ? null - : (feedbackType) { - if (feedbackType == - FeedbackType.missingCoins) { - showMissingCoinsDialog(context); - } - context.read().add( - FeedbackFormTypeChanged(feedbackType)); - }, - ), - const SizedBox(height: 16), - Text( - 'Please describe your feedback:', - style: theme.textTheme.titleMedium, + const SizedBox(height: 4), + _FeedbackTypeDropdown( + isLoading: isLoading, + selected: state.feedbackType, ), + const SizedBox(height: 8), - UiTextFormField( - maxLength: feedbackMaxLength, - maxLengthEnforcement: MaxLengthEnforcement.enforced, - enabled: !isLoading, - autofocus: true, - hintText: 'Enter your feedback here...', + _MessageField( + isLoading: isLoading, errorText: state.feedbackTextError, - validationMode: InputValidationMode.eager, - onChanged: (value) => context - .read() - .add(FeedbackFormMessageChanged(value ?? '')), - ), - const SizedBox(height: 16), - Text( - state.feedbackType == FeedbackType.support || - state.feedbackType == - FeedbackType.missingCoins - ? 'How can we contact you?' - : 'How can we contact you? (Optional)', - style: theme.textTheme.titleMedium, ), + const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 130, - child: DropdownButtonFormField( - isExpanded: true, - value: state.contactMethod, - hint: const Text('Select'), - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - items: ContactMethod.values - .map( - (method) => - DropdownMenuItem( - value: method, - child: Text(method.label), - ), - ) - .toList(), - onChanged: isLoading - ? null - : (method) => context - .read() - .add(FeedbackFormContactMethodChanged( - method)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: UiTextFormField( - enabled: !isLoading, - maxLength: contactDetailsMaxLength, - maxLengthEnforcement: - MaxLengthEnforcement.enforced, - hintText: _getContactHint(state.contactMethod), - errorText: state.contactDetailsError, - validationMode: InputValidationMode.eager, - onChanged: (value) => context - .read() - .add(FeedbackFormContactDetailsChanged( - value ?? '')), + _SectionTitle( + title: state.isContactRequired + ? LocaleKeys.feedbackFormContactRequired.tr() + : LocaleKeys.feedbackFormContactOptional.tr(), + ), + const SizedBox(height: 4), + if (state.isContactOptOutVisible) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: state.contactOptOut, + onChanged: isLoading + ? null + : (checked) => + context.read().add( + FeedbackFormContactOptOutChanged( + checked ?? false, + ), + ), + title: Text( + LocaleKeys.feedbackFormContactOptOut.tr(), ), ), - ], + ), + _ContactRow( + isLoading: state.isContactRowDisabled, + selectedMethod: state.contactMethod, + contactError: state.contactDetailsError, ), ], ), @@ -171,28 +97,14 @@ class CustomFeedbackForm extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (isLoading) - const Padding( - padding: EdgeInsets.only(right: 16.0), - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2.0), - ), - ), - TextButton( - onPressed: formValid - ? () => context - .read() - .add(const FeedbackFormSubmitted()) - : null, - child: const Text('SUBMIT'), - ), - ], + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: _ActionsRow( + isLoading: isLoading, + isFormValid: formValid, + submitLabel: submitLabel, ), ), ], @@ -203,17 +115,231 @@ class CustomFeedbackForm extends StatelessWidget { } } +class _ScrollableFormContent extends StatelessWidget { + const _ScrollableFormContent({ + required this.scrollController, + required this.topPadding, + required this.children, + }); + + final ScrollController? scrollController; + final double topPadding; + final List children; + + @override + Widget build(BuildContext context) { + return ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: Scrollbar( + controller: scrollController, + thumbVisibility: true, + child: ListView( + controller: scrollController, + padding: EdgeInsets.fromLTRB(16, topPadding, 16, 0), + children: children, + ), + ), + ); + } +} + +class _SectionTitle extends StatelessWidget { + const _SectionTitle({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Text(title, style: theme.textTheme.titleMedium); + } +} + +class _FeedbackTypeDropdown extends StatelessWidget { + const _FeedbackTypeDropdown({ + required this.isLoading, + required this.selected, + }); + + final bool isLoading; + final FeedbackType? selected; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + autofocus: true, + isExpanded: true, + initialValue: selected, + decoration: InputDecoration( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + validator: (value) => + value == null ? 'Please select a feedback type' : null, + items: FeedbackType.values + .map( + (type) => DropdownMenuItem( + value: type, + child: Text(type.description), + ), + ) + .toList(), + onChanged: isLoading + ? null + : (feedbackType) { + if (feedbackType == FeedbackType.missingCoins) { + showMissingCoinsDialog(context); + } + context.read().add( + FeedbackFormTypeChanged(feedbackType), + ); + }, + ); + } +} + +class _MessageField extends StatelessWidget { + const _MessageField({required this.isLoading, required this.errorText}); + + final bool isLoading; + final String? errorText; + + @override + Widget build(BuildContext context) { + return UiTextFormField( + maxLines: null, + maxLength: feedbackMaxLength, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + enabled: !isLoading, + labelText: LocaleKeys.feedbackFormDescribeTitle.tr(), + hintText: LocaleKeys.feedbackFormMessageHint.tr(), + errorText: errorText, + validationMode: InputValidationMode.eager, + onChanged: (value) => context.read().add( + FeedbackFormMessageChanged(value ?? ''), + ), + ); + } +} + +class _ContactRow extends StatelessWidget { + const _ContactRow({ + required this.isLoading, + required this.selectedMethod, + required this.contactError, + }); + + final bool isLoading; + final ContactMethod? selectedMethod; + final String? contactError; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 130, + child: DropdownButtonFormField( + isExpanded: true, + initialValue: selectedMethod, + hint: Text(LocaleKeys.feedbackFormSelectContactMethod.tr()), + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + items: ContactMethod.values + .map( + (method) => DropdownMenuItem( + value: method, + child: Text(method.label), + ), + ) + .toList(), + onChanged: isLoading + ? null + : (method) => context.read().add( + FeedbackFormContactMethodChanged(method), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: UiTextFormField( + enabled: !isLoading, + maxLength: contactDetailsMaxLength, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + hintText: _getContactHint(selectedMethod).tr(), + errorText: contactError, + validationMode: InputValidationMode.eager, + onChanged: (value) => context.read().add( + FeedbackFormContactDetailsChanged(value ?? ''), + ), + ), + ), + ], + ); + } +} + +class _ActionsRow extends StatelessWidget { + const _ActionsRow({ + required this.isLoading, + required this.isFormValid, + required this.submitLabel, + }); + + final bool isLoading; + final bool isFormValid; + final String submitLabel; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (isLoading) + const Padding( + padding: EdgeInsets.only(right: 16.0), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2.0), + ), + ), + TextButton( + onPressed: isLoading ? null : () => BetterFeedback.of(context).hide(), + child: Text(LocaleKeys.cancel.tr()), + ), + const SizedBox(width: 16), + FilledButton.icon( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + ), + onPressed: isFormValid + ? () => context.read().add( + const FeedbackFormSubmitted(), + ) + : null, + label: Text(submitLabel), + icon: const Icon(Icons.send), + ), + ], + ); + } +} + String _getContactHint(ContactMethod? method) { switch (method) { case ContactMethod.discord: - return 'Discord username (e.g., username123)'; + return LocaleKeys.feedbackFormDiscordHint; case ContactMethod.matrix: - return 'Matrix ID (e.g., @user:matrix.org)'; + return LocaleKeys.feedbackFormMatrixHint; case ContactMethod.telegram: - return 'Telegram username (e.g., @username)'; + return LocaleKeys.feedbackFormTelegramHint; case ContactMethod.email: - return 'Your email address'; + return LocaleKeys.feedbackFormEmailHint; default: - return 'Enter your contact details'; + return LocaleKeys.feedbackFormContactHint; } } diff --git a/lib/services/feedback/feedback_formatter.dart b/lib/services/feedback/feedback_formatter.dart new file mode 100644 index 0000000000..eeaaded385 --- /dev/null +++ b/lib/services/feedback/feedback_formatter.dart @@ -0,0 +1,112 @@ +import 'package:web_dex/shared/utils/extensions/string_extensions.dart'; + +/// Utility class for formatting feedback descriptions in an agent-friendly way +class FeedbackFormatter { + /// Creates a properly formatted description for agent review + static String createAgentFriendlyDescription( + String description, + String type, + Map metadata, + ) { + final buffer = StringBuffer(); + + // Add the pre-formatted description from the form + buffer.writeln(description); + buffer.writeln(); + + // Technical information section + buffer.writeln('🔧 TECHNICAL INFORMATION:'); + buffer.writeln('─' * 40); + + // Group related metadata for better readability + final appInfo = {}; + final deviceInfo = {}; + final buildInfo = {}; + final walletInfo = {}; + + for (final entry in metadata.entries) { + switch (entry.key) { + case 'contactMethod': + case 'contactDetails': + // These are already handled in the form-level formatting + break; + case 'appName': + case 'packageName': + case 'version': + case 'buildNumber': + appInfo[entry.key] = entry.value; + break; + case 'platform': + case 'targetPlatform': + case 'baseUrl': + deviceInfo[entry.key] = entry.value; + break; + case 'mode': + case 'commitHash': + case 'timestamp': + buildInfo[entry.key] = entry.value; + break; + case 'coinsCurrentCommit': + case 'coinsLatestCommit': + buildInfo[entry.key] = entry.value; + break; + case 'wallet': + walletInfo[entry.key] = entry.value; + break; + default: + deviceInfo[entry.key] = entry.value; + } + } + + if (appInfo.isNotEmpty) { + buffer.writeln(' 📱 App Information:'); + appInfo.forEach( + (key, value) => buffer.writeln(' • ${_formatKey(key)}: $value'), + ); + buffer.writeln(); + } + + if (deviceInfo.isNotEmpty) { + buffer.writeln(' 💻 Device Information:'); + deviceInfo.forEach( + (key, value) => buffer.writeln(' • ${_formatKey(key)}: $value'), + ); + buffer.writeln(); + } + + if (buildInfo.isNotEmpty) { + buffer.writeln(' 🔨 Build Information:'); + buildInfo.forEach( + (key, value) => buffer.writeln(' • ${_formatKey(key)}: $value'), + ); + buffer.writeln(); + } + + if (walletInfo.isNotEmpty) { + buffer.writeln(' 👛 Wallet Information:'); + walletInfo.forEach( + (key, value) => buffer.writeln(' • ${_formatKey(key)}: $value'), + ); + buffer.writeln(); + } + + buffer.writeln('═══════════════════════════════════════'); + + return buffer.toString(); + } + + // Convert camel case to separate words + static String _formatKey(String key) { + // Special-case certain keys for clearer labeling in reports + if (key == 'commitHash') { + return 'KDF commit hash'; + } + return key + .replaceAllMapped( + RegExp(r'([a-z])([A-Z])'), + (Match m) => '${m[1]} ${m[2]}', + ) + .replaceAll('_', ' ') + .toCapitalize(); + } +} diff --git a/lib/services/feedback/feedback_models.dart b/lib/services/feedback/feedback_models.dart index 6d8079a1a6..431e4cfbdc 100644 --- a/lib/services/feedback/feedback_models.dart +++ b/lib/services/feedback/feedback_models.dart @@ -31,8 +31,9 @@ class CustomFeedback { String toFormattedDescription() { final buffer = StringBuffer(); buffer.writeln('═══════════════════════════════════════'); - buffer - .writeln('📋 ${feedbackType?.description ?? 'Unknown'}'.toUpperCase()); + buffer.writeln( + '📋 ${feedbackType?.description ?? 'Unknown'}'.toUpperCase(), + ); buffer.writeln('═══════════════════════════════════════'); buffer.writeln(); buffer.writeln('💬 USER FEEDBACK:'); @@ -63,7 +64,8 @@ class CustomFeedback { break; case ContactMethod.telegram: buffer.writeln( - ' 📱 Telegram: ${contact.startsWith('@') ? contact : '@$contact'}'); + ' 📱 Telegram: ${contact.startsWith('@') ? contact : '@$contact'}', + ); break; case ContactMethod.matrix: buffer.writeln(' 🔗 Matrix: $contact'); @@ -72,61 +74,52 @@ class CustomFeedback { if (feedbackType == FeedbackType.support || feedbackType == FeedbackType.missingCoins) { buffer.writeln( - ' ⚠️ PRIORITY: Contact details provided for support request'); + ' ⚠️ PRIORITY: Contact details provided for support request', + ); } } else { buffer.writeln(' ❌ No contact information provided'); if (feedbackType == FeedbackType.support || feedbackType == FeedbackType.missingCoins) { buffer.writeln( - ' ⚠️ WARNING: Support request without contact details!'); + ' ⚠️ WARNING: Support request without contact details!', + ); } } return buffer.toString(); } } -enum FeedbackType { - missingCoins, - bugReport, - featureRequest, - support, - other; -} +enum FeedbackType { missingCoins, bugReport, featureRequest, support, other } extension FeedbackTypeDescription on FeedbackType { String get description { switch (this) { case FeedbackType.bugReport: - return 'Bug Report'; + return LocaleKeys.feedbackFormBugReport.tr(); case FeedbackType.featureRequest: - return 'Feature Request'; + return LocaleKeys.feedbackFormFeatureRequest.tr(); case FeedbackType.support: - return 'Support Request'; + return LocaleKeys.feedbackFormSupportRequest.tr(); case FeedbackType.missingCoins: return LocaleKeys.myCoinsMissing.tr(); case FeedbackType.other: - return 'Other'; + return LocaleKeys.feedbackFormOther.tr(); } } } -enum ContactMethod { - discord, - matrix, - telegram, - email; -} +enum ContactMethod { discord, matrix, telegram, email } extension ContactMethodLabel on ContactMethod { String get label { switch (this) { case ContactMethod.discord: - return 'Discord'; + return LocaleKeys.feedbackFormDiscord.tr(); case ContactMethod.matrix: - return 'Matrix'; + return LocaleKeys.feedbackFormMatrix.tr(); case ContactMethod.telegram: - return 'Telegram'; + return LocaleKeys.feedbackFormTelegram.tr(); case ContactMethod.email: return LocaleKeys.email.tr(); } diff --git a/lib/services/feedback/feedback_provider.dart b/lib/services/feedback/feedback_provider.dart new file mode 100644 index 0000000000..c0d542ed09 --- /dev/null +++ b/lib/services/feedback/feedback_provider.dart @@ -0,0 +1,15 @@ +import 'dart:typed_data'; + +/// Abstract interface for feedback providers +abstract class FeedbackProvider { + /// Submits feedback to the provider + Future submitFeedback({ + required String description, + required Uint8List screenshot, + required String type, + required Map metadata, + }); + + /// Returns true if this provider is configured and available for use + bool get isAvailable; +} diff --git a/lib/services/feedback/feedback_service.dart b/lib/services/feedback/feedback_service.dart index f5b7c2ba1b..8d2a7f1461 100644 --- a/lib/services/feedback/feedback_service.dart +++ b/lib/services/feedback/feedback_service.dart @@ -1,20 +1,16 @@ -import 'dart:convert'; -import 'package:easy_localization/easy_localization.dart'; import 'package:feedback/feedback.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; -import 'package:http/http.dart' as http; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; -import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:http_parser/http_parser.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:web_dex/app_config/app_config.dart'; -import 'package:web_dex/generated/codegen_loader.g.dart'; -import 'package:web_dex/shared/utils/extensions/string_extensions.dart'; +import 'package:web_dex/services/feedback/feedback_provider.dart'; +import 'package:web_dex/services/feedback/providers/cloudflare_feedback_provider.dart'; +import 'package:web_dex/services/feedback/providers/debug_console_feedback_provider.dart'; +import 'package:web_dex/services/feedback/providers/trello_feedback_provider.dart'; + +export 'feedback_ui_extension.dart'; /// Service that handles user feedback submission class FeedbackService { @@ -42,13 +38,13 @@ class FeedbackService { /// /// Returns true if feedback was submitted successfully, false otherwise Future handleFeedback(UserFeedback feedback) async { - PackageInfo packageInfo = await PackageInfo.fromPlatform(); + final PackageInfo packageInfo = await PackageInfo.fromPlatform(); final buildMode = kReleaseMode ? 'release' : kDebugMode - ? 'debug' - : (kProfileMode ? 'profile' : 'unknown'); + ? 'debug' + : (kProfileMode ? 'profile' : 'unknown'); // Extract contact information from the extras if provided String? contactMethod; @@ -57,15 +53,16 @@ class FeedbackService { if (feedback.extra != null && feedback.extra is JsonMap) { final extras = feedback.extra!; - contactMethod = extras['contact_method'] as String?; - contactDetails = extras['contact_details'] as String?; - feedbackType = extras['feedback_type'] as String?; + contactMethod = extras.valueOrNull('contact_method'); + contactDetails = extras.valueOrNull('contact_details'); + feedbackType = extras.valueOrNull('feedback_type'); } + final sdk = GetIt.I(); + final Map metadata = { if (contactMethod != null) 'contactMethod': contactMethod, if (contactDetails != null) 'contactDetails': contactDetails, - 'platform': kIsWeb ? 'web' : 'native', 'commitHash': const String.fromEnvironment( 'COMMIT_HASH', @@ -78,9 +75,11 @@ class FeedbackService { ...packageInfo.data, 'mode': buildMode, 'timestamp': DateTime.now().toIso8601String(), - 'wallet': - (await GetIt.I().auth.currentUser)?.toJson() ?? 'None' + (await GetIt.I().auth.currentUser)?.toJson() ?? 'None', + + 'coinsCurrentCommit': await sdk.assets.currentCoinsCommit, + 'coinsLatestCommit': await sdk.assets.latestCoinsCommit, }; try { @@ -95,8 +94,8 @@ class FeedbackService { final altAvailable = provider is TrelloFeedbackProvider ? CloudflareFeedbackProvider.fromEnvironment().isAvailable : provider is CloudflareFeedbackProvider - ? TrelloFeedbackProvider.hasEnvironmentVariables() - : true; + ? TrelloFeedbackProvider.hasEnvironmentVariables() + : true; if (kDebugMode && !altAvailable) { debugPrint('Failed to submit feedback: $e'); } @@ -104,592 +103,3 @@ class FeedbackService { } } } - -/// Abstract interface for feedback providers -abstract class FeedbackProvider { - /// Submits feedback to the provider - Future submitFeedback({ - required String description, - required Uint8List screenshot, - required String type, - required Map metadata, - }); - - /// Returns true if this provider is configured and available for use - bool get isAvailable; -} - -/// Utility class for formatting feedback descriptions in an agent-friendly way -class FeedbackFormatter { - /// Creates a properly formatted description for agent review - static String createAgentFriendlyDescription( - String description, - String type, - Map metadata, - ) { - final buffer = StringBuffer(); - - // Add the pre-formatted description from the form - buffer.writeln(description); - buffer.writeln(); - - // Technical information section - buffer.writeln('🔧 TECHNICAL INFORMATION:'); - buffer.writeln('─' * 40); - - // Group related metadata for better readability - final appInfo = {}; - final deviceInfo = {}; - final buildInfo = {}; - final walletInfo = {}; - - for (final entry in metadata.entries) { - switch (entry.key) { - case 'contactMethod': - case 'contactDetails': - // These are already handled in the form-level formatting - break; - case 'appName': - case 'packageName': - case 'version': - case 'buildNumber': - appInfo[entry.key] = entry.value; - break; - case 'platform': - case 'targetPlatform': - case 'baseUrl': - deviceInfo[entry.key] = entry.value; - break; - case 'mode': - case 'commitHash': - case 'timestamp': - buildInfo[entry.key] = entry.value; - break; - case 'wallet': - walletInfo[entry.key] = entry.value; - break; - default: - deviceInfo[entry.key] = entry.value; - } - } - - if (appInfo.isNotEmpty) { - buffer.writeln(' 📱 App Information:'); - appInfo.forEach( - (key, value) => buffer.writeln(' • ${_formatKey(key)}: $value')); - buffer.writeln(); - } - - if (deviceInfo.isNotEmpty) { - buffer.writeln(' 💻 Device Information:'); - deviceInfo.forEach( - (key, value) => buffer.writeln(' • ${_formatKey(key)}: $value')); - buffer.writeln(); - } - - if (buildInfo.isNotEmpty) { - buffer.writeln(' 🔨 Build Information:'); - buildInfo.forEach( - (key, value) => buffer.writeln(' • ${_formatKey(key)}: $value')); - buffer.writeln(); - } - - if (walletInfo.isNotEmpty) { - buffer.writeln(' 👛 Wallet Information:'); - walletInfo.forEach( - (key, value) => buffer.writeln(' • ${_formatKey(key)}: $value')); - buffer.writeln(); - } - - buffer.writeln('═══════════════════════════════════════'); - - return buffer.toString(); - } - - // Convert camel case to separate words - static String _formatKey(String key) { - return key - .replaceAllMapped( - RegExp(r'([a-z])([A-Z])'), - (Match m) => '${m[1]} ${m[2]}', - ) - .replaceAll('_', ' ') - .toCapitalize(); - } - - // ...existing code... -} - -/// Implementation of FeedbackProvider that submits feedback to Trello -/// -/// The following environment variables must be set using dart-define: -/// TRELLO_API_KEY: Your Trello API key -/// TRELLO_TOKEN: Your Trello API token -/// TRELLO_BOARD_ID: The ID of the Trello board where feedback will be sent -/// TRELLO_LIST_ID: The ID of the Trello list where feedback will be sent - -/// The Trello API key can be obtained by going to the Power-Ups console: -/// https://trello.com/power-ups/admin - -/// For Komodo Wallet, the Trello API token can be re-generated by going to: -/// https://trello.com/1/authorize?expiration=never&name=Komodo%20Wallet%20Feedback&scope=read,write&response_type=token&key=YOUR_API_KEY -/// -/// If you have trouble generating that or if you are setting it up for a fork, -/// there is an option in the power-up console to generate the link. -/// -/// The Trello board ID and list ID can be obtained by going to the Trello board -/// and adding `.json` to the end of the URL, doing a search for the board/list -/// name and then copying the `id`. -/// E.g. https://trello.com/c/AbcdXYZ/63-feedback-user-feedback -> -/// https://trello.com/c/AbcdXYZ/63-feedback-user-feedback.json -/// -/// The environment variables can be set for the build using the following -/// command for example: -/// flutter build web --dart-define TRELLO_API_KEY=YOUR_KEY_HERE --dart-define TRELLO_TOKEN=YOUR_TOKEN_HERE --dart-define TRELLO_BOARD_ID=YOUR_BOARD_ID_HERE --dart-define TRELLO_LIST_ID=YOUR_LIST_ID_HERE -class TrelloFeedbackProvider implements FeedbackProvider { - final String apiKey; - final String token; - final String boardId; - final String listId; - - const TrelloFeedbackProvider({ - required this.apiKey, - required this.token, - required this.boardId, - required this.listId, - }); - - static bool hasEnvironmentVariables() { - final requiredVars = { - 'TRELLO_API_KEY': const String.fromEnvironment('TRELLO_API_KEY'), - 'TRELLO_TOKEN': const String.fromEnvironment('TRELLO_TOKEN'), - 'TRELLO_BOARD_ID': const String.fromEnvironment('TRELLO_BOARD_ID'), - 'TRELLO_LIST_ID': const String.fromEnvironment('TRELLO_LIST_ID'), - }; - - final missingVars = - requiredVars.entries.where((e) => e.value.isEmpty).toList(); - - if (missingVars.isNotEmpty) { - final altAvailable = - CloudflareFeedbackProvider.fromEnvironment().isAvailable; - if (kDebugMode && !altAvailable) { - debugPrint( - 'Missing required environment variables for Trello feedback provider: ' + - missingVars.join(', '), - ); - } - return false; - } - - return true; - } - - /// Creates a TrelloFeedbackProvider instance if all required environment variables are set. - /// Returns null if any environment variable is missing or empty. - static TrelloFeedbackProvider? fromEnvironment() { - if (!hasEnvironmentVariables()) { - return null; - } - - return TrelloFeedbackProvider( - apiKey: const String.fromEnvironment('TRELLO_API_KEY'), - token: const String.fromEnvironment('TRELLO_TOKEN'), - boardId: const String.fromEnvironment('TRELLO_BOARD_ID'), - listId: const String.fromEnvironment('TRELLO_LIST_ID'), - ); - } - - @override - bool get isAvailable => hasEnvironmentVariables(); - - @override - Future submitFeedback({ - required String description, - required Uint8List screenshot, - required String type, - required Map metadata, - }) async { - try { - // Create comprehensive formatted description for agents - final formattedDesc = FeedbackFormatter.createAgentFriendlyDescription( - description, - type, - metadata, - ); - - // 1. Create the card - final cardResponse = await http.post( - Uri.parse('https://api.trello.com/1/cards'), - headers: {'Content-Type': 'application/json; charset=utf-8'}, - body: jsonEncode({ - 'idList': listId, - 'key': apiKey, - 'token': token, - 'name': 'Feedback: $type', - 'desc': formattedDesc, - }), - ); - - if (cardResponse.statusCode != 200) { - throw Exception( - 'Failed to create Trello card (${cardResponse.statusCode}): ${cardResponse.body}', - ); - } - - final cardId = jsonDecode(cardResponse.body)['id']; - - // 2. Attach the screenshot to the card - final attachmentRequest = http.MultipartRequest( - 'POST', - Uri.parse('https://api.trello.com/1/cards/$cardId/attachments'), - ); - - attachmentRequest.fields.addAll({'key': apiKey, 'token': token}); - - attachmentRequest.files.add( - http.MultipartFile.fromBytes( - 'file', - screenshot, - filename: 'screenshot.png', - contentType: MediaType('image', 'png'), - ), - ); - - final attachmentResponse = await attachmentRequest.send(); - final streamedResponse = await http.Response.fromStream( - attachmentResponse, - ); - - if (streamedResponse.statusCode != 200) { - throw Exception( - 'Failed to attach screenshot (${streamedResponse.statusCode}): ${streamedResponse.body}', - ); - } - } catch (e) { - final altAvailable = - CloudflareFeedbackProvider.fromEnvironment().isAvailable; - if (kDebugMode && !altAvailable) { - debugPrint('Error in Trello submitFeedback: $e'); - } - rethrow; - } - } -} - -/// Implementation of FeedbackProvider that submits feedback to Komodo's -/// internal API. -/// -/// The following environment variables must be set using dart-define: -/// FEEDBACK_API_KEY: The API key for the feedback service -/// FEEDBACK_PRODUCTION_URL: The production URL for the feedback API, OR: -/// FEEDBACK_TEST_URL: The test URL for the feedback API to test in debug mode -/// TRELLO_LIST_ID: The ID of the Trello list where feedback will be sent (shared with TrelloFeedbackProvider) -/// TRELLO_BOARD_ID: The ID of the Trello board (shared with TrelloFeedbackProvider) -/// -/// This provider is used for submitting feedback to the Cloudflare Worker. -/// You can set up your own feedback backend by using the repository available at: -/// https://github.com/KomodoPlatform/komodo-wallet-feedback-cf-worker -/// -/// Example build command: -/// ``` -/// flutter build web --dart-define=FEEDBACK_PRODUCTION_URL=https://your-api-url.com --dart-define=FEEDBACK_API_KEY=your_api_key --dart-define=TRELLO_LIST_ID=your_list_id --dart-define=TRELLO_BOARD_ID=your_board_id -/// ``` -/// -/// Example run command (debugging): -/// ``` -/// flutter run --dart-define=FEEDBACK_TEST_URL=https://your-test-api-url.com --dart-define=FEEDBACK_API_KEY=your_api_key --dart-define=TRELLO_LIST_ID=your_list_id --dart-define=TRELLO_BOARD_ID=your_board_id -/// ``` -/// -/// The test URL is hardcoded in the code. -/// -class CloudflareFeedbackProvider implements FeedbackProvider { - final String apiKey; - final String testEndpoint; - final String prodEndpoint; - final String listId; - final String boardId; - - const CloudflareFeedbackProvider({ - required this.apiKey, - required this.prodEndpoint, - this.testEndpoint = '', - required this.listId, - required this.boardId, - }); - - /// Creates a CloudflareFeedbackProvider instance from environment variables. - /// - /// Uses the following environment variables: - /// - FEEDBACK_API_KEY: The API key for the feedback service - /// - FEEDBACK_PRODUCTION_URL: The production URL for the feedback API (Only required in release mode) - /// - FEEDBACK_TEST_URL: The test URL for the feedback API (Only required in debug mode) - /// - TRELLO_LIST_ID: The ID of the Trello list where feedback will be sent (shared with TrelloFeedbackProvider) - /// - TRELLO_BOARD_ID: The ID of the Trello board where feedback will be sent (shared with TrelloFeedbackProvider) - static CloudflareFeedbackProvider fromEnvironment() { - return CloudflareFeedbackProvider( - apiKey: const String.fromEnvironment('FEEDBACK_API_KEY'), - prodEndpoint: const String.fromEnvironment('FEEDBACK_PRODUCTION_URL'), - testEndpoint: const String.fromEnvironment('FEEDBACK_TEST_URL'), - listId: const String.fromEnvironment('TRELLO_LIST_ID'), - boardId: const String.fromEnvironment('TRELLO_BOARD_ID'), - ); - } - - bool get useTestEndpoint => kDebugMode && testEndpoint.isNotEmpty; - - String get _endpoint => useTestEndpoint ? testEndpoint : prodEndpoint; - - @override - bool get isAvailable => - apiKey.isNotEmpty && - (prodEndpoint.isNotEmpty || (kDebugMode && testEndpoint.isNotEmpty)) && - listId.isNotEmpty && - boardId.isNotEmpty; - - @override - Future submitFeedback({ - required String description, - required Uint8List screenshot, - required String type, - required Map metadata, - }) async { - try { - // Create comprehensive formatted description for agents - final formattedDesc = FeedbackFormatter.createAgentFriendlyDescription( - description, - type, - metadata, - ); - - final request = http.MultipartRequest('POST', Uri.parse(_endpoint)); - - // Set headers including charset - request.headers.addAll({ - 'X-KW-KEY': apiKey, - 'Accept-Charset': 'utf-8', - }); - - // Properly encode all string fields to ensure UTF-8 encoding - request.fields.addAll({ - 'idBoard': boardId, - 'idList': listId, - 'name': 'Feedback: $type', - 'desc': formattedDesc, - }); - - request.files.add( - http.MultipartFile.fromBytes( - 'img', - screenshot, - filename: 'screenshot.png', - contentType: MediaType('image', 'png'), - ), - ); - - // Encode metadata as JSON with proper UTF-8 handling - final metadataJson = metadata.toJsonString(); - request.fields['metadata'] = metadataJson; - - final streamedResponse = await request.send(); - final response = await http.Response.fromStream(streamedResponse); - - if (response.statusCode != 200) { - throw Exception( - 'Failed to submit feedback (${response.statusCode}): ${response.body}', - ); - } - } catch (e) { - final altAvailable = TrelloFeedbackProvider.hasEnvironmentVariables(); - if (kDebugMode && !altAvailable) { - debugPrint('Error in Cloudflare submitFeedback: $e'); - } - rethrow; - } - } -} - -/// Debug implementation of FeedbackProvider that prints feedback to console -class DebugConsoleFeedbackProvider implements FeedbackProvider { - @override - bool get isAvailable => true; - - @override - Future submitFeedback({ - required String description, - required Uint8List screenshot, - required String type, - required Map metadata, - }) async { - debugPrint('---------------- DEBUG FEEDBACK ----------------'); - debugPrint('Type: $type'); - debugPrint('Description:'); - debugPrint(description); - debugPrint('\nMetadata:'); - metadata.forEach((key, value) => debugPrint('$key: $value')); - debugPrint('Screenshot size: ${screenshot.length} bytes'); - debugPrint('---------------------------------------------'); - } -} - -extension BuildContextShowFeedback on BuildContext { - /// Shows the feedback dialog if the feedback service is available. - /// Does nothing if the feedback service is not configured. - void showFeedback() { - final feedbackService = FeedbackService.create(); - if (feedbackService == null) { - debugPrint( - 'Feedback dialog not shown: feedback service is not configured', - ); - return; - } - - BetterFeedback.of(this).show((feedback) async { - // Workaround for known BetterFeedback issue: - // https://github.com/ueman/feedback/issues/322#issuecomment-2384060812 - await Future.delayed(Duration(milliseconds: 500)); - try { - final success = await feedbackService.handleFeedback(feedback); - - if (success) { - // Close the feedback dialog - BetterFeedback.of(this).hide(); - - // Check if Discord was selected as contact method - String? contactMethod; - if (feedback.extra != null && feedback.extra is JsonMap) { - contactMethod = feedback.extra!['contact_method'] as String?; - } - - // Show Discord info dialog if Discord was selected - if (contactMethod == 'discord') { - // Use a short delay to ensure the feedback form is fully closed - await Future.delayed(Duration(milliseconds: 300)); - await _showDiscordInfoDialog(this); - } - - // Show success message - ScaffoldMessenger.of(this).showSnackBar( - SnackBar( - content: Text( - 'Thank you! ${LocaleKeys.feedbackFormDescription.tr()}', - ), - action: SnackBarAction( - label: LocaleKeys.addMoreFeedback.tr(), - onPressed: () => showFeedback(), - ), - duration: const Duration(seconds: 5), - ), - ); - } else { - // Keep the feedback dialog open but show error message - final theme = Theme.of(this); - ScaffoldMessenger.of(this).showSnackBar( - SnackBar( - content: Text( - LocaleKeys.feedbackError.tr(), - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onErrorContainer, - ), - ), - backgroundColor: theme.colorScheme.errorContainer, - ), - ); - } - } catch (e) { - debugPrint('Error submitting feedback: $e'); - - // Show error message but keep dialog open - final theme = Theme.of(this); - ScaffoldMessenger.of(this).showSnackBar( - SnackBar( - content: Text( - LocaleKeys.feedbackError.tr(), - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onErrorContainer, - ), - ), - backgroundColor: theme.colorScheme.errorContainer, - ), - ); - } - }); - } - - /// Returns true if feedback functionality is available - bool get isFeedbackAvailable => - FeedbackService.create()?.isAvailable ?? false; -} - -/// Shows a dialog with information about Discord contact -Future _showDiscordInfoDialog(BuildContext context) { - final theme = Theme.of(context); - - return showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text('Let\'s Connect on Discord!'), - contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 24), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'To ensure we can reach you:', - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '• Make sure you\'re a member of the Komodo Discord server', - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 8), - Text( - '• Watch for our team in the support channel', - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 8), - Text( - '• Feel free to reach out to us anytime in the server', - style: theme.textTheme.bodyMedium, - ), - ], - ), - ), - ], - ), - actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text('Close'), - ), - SizedBox( - width: 230, - child: UiPrimaryButton( - onPressed: () async { - Navigator.of(context).pop(); - await _openDiscordSupport(); - }, - child: Text('Join Komodo Discord'), - ), - ), - ], - ), - ); -} - -Future _openDiscordSupport() async { - try { - await launchUrl(discordInviteUrl, mode: LaunchMode.externalApplication); - } catch (e) { - debugPrint('Error opening Discord link: $e'); - } -} diff --git a/lib/services/feedback/feedback_ui_extension.dart b/lib/services/feedback/feedback_ui_extension.dart new file mode 100644 index 0000000000..c7875a7db5 --- /dev/null +++ b/lib/services/feedback/feedback_ui_extension.dart @@ -0,0 +1,176 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:feedback/feedback.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/services/feedback/feedback_service.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; +import 'dart:typed_data'; + +extension BuildContextShowFeedback on BuildContext { + /// Shows the feedback dialog if the feedback service is available. + /// Does nothing if the feedback service is not configured. + void showFeedback() { + final feedbackService = FeedbackService.create(); + if (feedbackService == null) { + debugPrint( + 'Feedback dialog not shown: feedback service is not configured', + ); + return; + } + + BetterFeedback.of(this).show((feedback) async { + await Future.delayed(Duration(milliseconds: 500)); + try { + // If current UI is marked screenshot-sensitive, replace screenshot with a + // minimal transparent PNG to avoid leaking secrets. + final bool isSensitive = isScreenshotSensitive; + final UserFeedback sanitized = isSensitive + ? UserFeedback( + text: feedback.text, + extra: feedback.extra, + // 1x1 transparent PNG + screenshot: Uint8List.fromList(const [ + 137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,10,73,68,65,84,120,156,99,96,0,0,0,2,0,1,226,33,185,120,0,0,0,0,73,69,78,68,174,66,96,130 + ]), + ) + : feedback; + + final success = await feedbackService.handleFeedback(sanitized); + + if (success) { + BetterFeedback.of(this).hide(); + + String? contactMethod; + if (feedback.extra != null && feedback.extra is JsonMap) { + final extras = feedback.extra!; + contactMethod = extras.valueOrNull('contact_method'); + } + + if (contactMethod == 'discord') { + await Future.delayed(Duration(milliseconds: 300)); + await _showDiscordInfoDialog(this); + } + + ScaffoldMessenger.of(this).showSnackBar( + SnackBar( + content: Text( + 'Thank you! ${LocaleKeys.feedbackFormDescription.tr()}', + ), + action: SnackBarAction( + label: LocaleKeys.addMoreFeedback.tr(), + onPressed: () => showFeedback(), + ), + duration: const Duration(seconds: 5), + ), + ); + } else { + final theme = Theme.of(this); + ScaffoldMessenger.of(this).showSnackBar( + SnackBar( + content: Text( + LocaleKeys.feedbackError.tr(), + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } + } catch (e) { + debugPrint('Error submitting feedback: $e'); + final theme = Theme.of(this); + ScaffoldMessenger.of(this).showSnackBar( + SnackBar( + content: Text( + LocaleKeys.feedbackError.tr(), + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } + }); + } + + bool get isFeedbackAvailable => + FeedbackService.create()?.isAvailable ?? false; +} + +Future _showDiscordInfoDialog(BuildContext context) { + final theme = Theme.of(context); + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Let\'s Connect on Discord!'), + contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 24), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'To ensure we can reach you:', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '• Make sure you\'re a member of the Komodo Discord server', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + '• Watch for our team in the support channel', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + '• Feel free to reach out to us anytime in the server', + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + ], + ), + actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text('Close'), + ), + SizedBox( + width: 230, + child: UiPrimaryButton( + onPressed: () async { + Navigator.of(context).pop(); + await _openDiscordSupport(); + }, + child: Text('Join Komodo Discord'), + ), + ), + ], + ), + ); +} + +Future _openDiscordSupport() async { + try { + await launchUrl(discordInviteUrl, mode: LaunchMode.externalApplication); + } catch (e) { + debugPrint('Error opening Discord link: $e'); + } +} diff --git a/lib/services/feedback/providers/cloudflare_feedback_provider.dart b/lib/services/feedback/providers/cloudflare_feedback_provider.dart new file mode 100644 index 0000000000..6a6b709812 --- /dev/null +++ b/lib/services/feedback/providers/cloudflare_feedback_provider.dart @@ -0,0 +1,104 @@ +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; +import 'package:web_dex/services/feedback/feedback_formatter.dart'; +import 'package:web_dex/services/feedback/feedback_provider.dart'; +import 'package:web_dex/services/logger/get_logger.dart' as app_logger; + +class CloudflareFeedbackProvider implements FeedbackProvider { + final String apiKey; + final String prodEndpoint; + final String listId; + final String boardId; + + const CloudflareFeedbackProvider({ + required this.apiKey, + required this.prodEndpoint, + required this.listId, + required this.boardId, + }); + + static CloudflareFeedbackProvider fromEnvironment() { + return CloudflareFeedbackProvider( + apiKey: const String.fromEnvironment('FEEDBACK_API_KEY'), + prodEndpoint: const String.fromEnvironment('FEEDBACK_PRODUCTION_URL'), + listId: const String.fromEnvironment('TRELLO_LIST_ID'), + boardId: const String.fromEnvironment('TRELLO_BOARD_ID'), + ); + } + + String get _endpoint => prodEndpoint; + + @override + bool get isAvailable => + apiKey.isNotEmpty && + prodEndpoint.isNotEmpty && + listId.isNotEmpty && + boardId.isNotEmpty; + + @override + Future submitFeedback({ + required String description, + required Uint8List screenshot, + required String type, + required Map metadata, + }) async { + try { + final formattedDesc = FeedbackFormatter.createAgentFriendlyDescription( + description, + type, + metadata, + ); + + final request = http.MultipartRequest('POST', Uri.parse(_endpoint)); + request.headers.addAll({'X-KW-KEY': apiKey, 'Accept-Charset': 'utf-8'}); + request.fields.addAll({ + 'idBoard': boardId, + 'idList': listId, + 'name': 'Feedback: $type', + 'desc': formattedDesc, + }); + + request.files.add( + http.MultipartFile.fromBytes( + 'img', + screenshot, + filename: 'screenshot.png', + contentType: MediaType('image', 'png'), + ), + ); + + try { + final Uint8List logsBytes = await app_logger.logger + .exportRecentLogsBytes(maxBytes: 9 * 1024 * 1024); + if (logsBytes.isNotEmpty) { + request.files.add( + http.MultipartFile.fromBytes( + 'logs', + logsBytes, + filename: 'logs.txt', + contentType: MediaType('text', 'plain'), + ), + ); + } + } catch (e) { + if (kDebugMode) { + debugPrint('Skipping logs attachment: $e'); + } + } + + final streamedResponse = await request.send(); + final response = await http.Response.fromStream(streamedResponse); + + if (response.statusCode != 200) { + throw Exception( + 'Failed to submit feedback (${response.statusCode}): ${response.body}', + ); + } + } catch (e) { + rethrow; + } + } +} diff --git a/lib/services/feedback/providers/debug_console_feedback_provider.dart b/lib/services/feedback/providers/debug_console_feedback_provider.dart new file mode 100644 index 0000000000..f04af8e95e --- /dev/null +++ b/lib/services/feedback/providers/debug_console_feedback_provider.dart @@ -0,0 +1,26 @@ +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:web_dex/services/feedback/feedback_provider.dart'; + +class DebugConsoleFeedbackProvider implements FeedbackProvider { + @override + bool get isAvailable => true; + + @override + Future submitFeedback({ + required String description, + required Uint8List screenshot, + required String type, + required Map metadata, + }) async { + debugPrint('---------------- DEBUG FEEDBACK ----------------'); + debugPrint('Type: $type'); + debugPrint('Description:'); + debugPrint(description); + debugPrint('\nMetadata:'); + metadata.forEach((key, value) => debugPrint('$key: $value')); + debugPrint('Screenshot size: ${screenshot.length} bytes'); + debugPrint('---------------------------------------------'); + } +} diff --git a/lib/services/feedback/providers/trello_feedback_provider.dart b/lib/services/feedback/providers/trello_feedback_provider.dart new file mode 100644 index 0000000000..1a0c1f768d --- /dev/null +++ b/lib/services/feedback/providers/trello_feedback_provider.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; +import 'package:web_dex/services/feedback/feedback_formatter.dart'; +import 'package:web_dex/services/feedback/feedback_provider.dart'; +import 'package:web_dex/services/logger/get_logger.dart' as app_logger; + +class TrelloFeedbackProvider implements FeedbackProvider { + final String apiKey; + final String token; + final String boardId; + final String listId; + + const TrelloFeedbackProvider({ + required this.apiKey, + required this.token, + required this.boardId, + required this.listId, + }); + + static bool hasEnvironmentVariables() { + final requiredVars = { + 'TRELLO_API_KEY': const String.fromEnvironment('TRELLO_API_KEY'), + 'TRELLO_TOKEN': const String.fromEnvironment('TRELLO_TOKEN'), + 'TRELLO_BOARD_ID': const String.fromEnvironment('TRELLO_BOARD_ID'), + 'TRELLO_LIST_ID': const String.fromEnvironment('TRELLO_LIST_ID'), + }; + + final missing = requiredVars.entries.where((e) => e.value.isEmpty).toList(); + return missing.isEmpty; + } + + static TrelloFeedbackProvider? fromEnvironment() { + if (!hasEnvironmentVariables()) return null; + return TrelloFeedbackProvider( + apiKey: const String.fromEnvironment('TRELLO_API_KEY'), + token: const String.fromEnvironment('TRELLO_TOKEN'), + boardId: const String.fromEnvironment('TRELLO_BOARD_ID'), + listId: const String.fromEnvironment('TRELLO_LIST_ID'), + ); + } + + @override + bool get isAvailable => hasEnvironmentVariables(); + + @override + Future submitFeedback({ + required String description, + required Uint8List screenshot, + required String type, + required Map metadata, + }) async { + // 1) Create card with formatted description + final formattedDesc = FeedbackFormatter.createAgentFriendlyDescription( + description, + type, + metadata, + ); + + final cardResponse = await http.post( + Uri.parse('https://api.trello.com/1/cards'), + headers: {'Content-Type': 'application/json; charset=utf-8'}, + body: jsonEncode({ + 'idList': listId, + 'key': apiKey, + 'token': token, + 'name': 'Feedback: $type', + 'desc': formattedDesc, + }), + ); + + if (cardResponse.statusCode != 200) { + throw Exception( + 'Failed to create Trello card (${cardResponse.statusCode}): ${cardResponse.body}', + ); + } + + final cardId = jsonDecode(cardResponse.body)['id']; + + // 2) Attach screenshot + final imgReq = http.MultipartRequest( + 'POST', + Uri.parse('https://api.trello.com/1/cards/$cardId/attachments'), + ); + imgReq.fields.addAll({'key': apiKey, 'token': token}); + imgReq.files.add( + http.MultipartFile.fromBytes( + 'file', + screenshot, + filename: 'screenshot.png', + contentType: MediaType('image', 'png'), + ), + ); + final imgResp = await http.Response.fromStream(await imgReq.send()); + if (imgResp.statusCode != 200) { + throw Exception( + 'Failed to attach screenshot (${imgResp.statusCode}): ${imgResp.body}', + ); + } + + // 3) Attach logs (<= 9MB) - optional + try { + final bytes = await app_logger.logger.exportRecentLogsBytes( + maxBytes: 9 * 1024 * 1024, + ); + if (bytes.isEmpty) return; + + final logsReq = http.MultipartRequest( + 'POST', + Uri.parse('https://api.trello.com/1/cards/$cardId/attachments'), + ); + logsReq.fields.addAll({'key': apiKey, 'token': token}); + logsReq.files.add( + http.MultipartFile.fromBytes( + 'file', + bytes, + filename: 'logs.txt', + contentType: MediaType('text', 'plain'), + ), + ); + final logsResp = await http.Response.fromStream(await logsReq.send()); + if (logsResp.statusCode != 200) { + throw Exception( + 'Failed to attach logs (${logsResp.statusCode}): ${logsResp.body}', + ); + } + } catch (e) { + if (kDebugMode) { + debugPrint('Skipping logs attachment (Trello): $e'); + } + } + } +} diff --git a/lib/services/initializer/app_bootstrapper.dart b/lib/services/initializer/app_bootstrapper.dart index 1d79aa6c27..11e594f5f4 100644 --- a/lib/services/initializer/app_bootstrapper.dart +++ b/lib/services/initializer/app_bootstrapper.dart @@ -9,7 +9,11 @@ final class AppBootstrapper { bool _isInitialized = false; - Future ensureInitialized(KomodoDefiSdk kdfSdk, Mm2Api mm2Api) async { + Future ensureInitialized( + KomodoDefiSdk kdfSdk, + Mm2Api mm2Api, + SparklineRepository sparklineRepository, + ) async { if (_isInitialized) return; // Register core services with GetIt @@ -22,8 +26,10 @@ final class AppBootstrapper { log('AppBootstrapper: Log initialized in ${timer.elapsedMilliseconds}ms'); timer.reset(); - await _warmUpInitializers().awaitAll(); - log('AppBootstrapper: Warm-up initializers completed in ${timer.elapsedMilliseconds}ms'); + await _warmUpInitializers(sparklineRepository).awaitAll(); + log( + 'AppBootstrapper: Warm-up initializers completed in ${timer.elapsedMilliseconds}ms', + ); timer.stop(); _isInitialized = true; @@ -38,7 +44,9 @@ final class AppBootstrapper { /// A list of futures that should be completed before the app starts /// ([runApp]) which do not depend on each other. - List> _warmUpInitializers() { + List> _warmUpInitializers( + SparklineRepository sparklineRepository, + ) { return [ app_bloc_root.loadLibrary(), packageInformation.init(), @@ -46,9 +54,10 @@ final class AppBootstrapper { CexMarketData.ensureInitialized(), PlatformTuner.setWindowTitleAndSize(), _initializeSettings(), - _initHive(isWeb: kIsWeb || kIsWasm, appFolder: appFolder).then( - (_) => sparklineRepository.init(), - ), + _initHive( + isWeb: kIsWeb || kIsWasm, + appFolder: appFolder, + ).then((_) => sparklineRepository.init()), ]; } @@ -57,11 +66,14 @@ final class AppBootstrapper { final stored = await SettingsRepository.loadStoredSettings(); _storedSettings = stored; - // Register the analytics repository with GetIt + // Register the unified analytics repository with GetIt // This will make sure we have a singleton instance across the app - FirebaseAnalyticsRepo.register(stored.analytics); + // that handles both Firebase and Matomo analytics simultaneously + AnalyticsRepository.register(stored.analytics); - log('AppBootstrapper: Analytics repository registered with GetIt'); + log( + 'AppBootstrapper: Unified Analytics repository (Firebase + Matomo) registered with GetIt', + ); return; } } diff --git a/lib/services/logger/logger.dart b/lib/services/logger/logger.dart index 8f143ee590..3ddc91922c 100644 --- a/lib/services/logger/logger.dart +++ b/lib/services/logger/logger.dart @@ -1,5 +1,8 @@ +import 'dart:typed_data'; + abstract class LoggerInterface { Future init(); Future write(String logMessage, [String? path]); Future getLogFile(); + Future exportRecentLogsBytes({int maxBytes}); } diff --git a/lib/services/logger/mock_logger.dart b/lib/services/logger/mock_logger.dart index 3e04a62877..62f4deb7ea 100644 --- a/lib/services/logger/mock_logger.dart +++ b/lib/services/logger/mock_logger.dart @@ -1,5 +1,6 @@ // ignore_for_file: avoid_print +import 'dart:typed_data'; import 'package:web_dex/services/logger/logger.dart'; class MockLogger implements LoggerInterface { @@ -19,4 +20,12 @@ class MockLogger implements LoggerInterface { Future init() async { print('initialized'); } + + @override + Future exportRecentLogsBytes({ + int maxBytes = 9 * 1024 * 1024, + }) async { + final String mock = 'Mock logs: logger not available in this environment.'; + return Uint8List.fromList(mock.codeUnits); + } } diff --git a/lib/services/logger/universal_logger.dart b/lib/services/logger/universal_logger.dart index 7144d20797..df16a29603 100644 --- a/lib/services/logger/universal_logger.dart +++ b/lib/services/logger/universal_logger.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; import 'package:dragon_logs/dragon_logs.dart'; import 'package:intl/intl.dart'; @@ -27,8 +29,9 @@ class UniversalLogger with LoggerMetadataMixin implements LoggerInterface { try { await DragonLogs.init(); - initialised_logger - .log('Logger initialized in ${timer.elapsedMilliseconds}ms'); + initialised_logger.log( + 'Logger initialized in ${timer.elapsedMilliseconds}ms', + ); _isInitialized = true; } catch (e) { @@ -45,6 +48,13 @@ class UniversalLogger with LoggerMetadataMixin implements LoggerInterface { @override Future write(String message, [String? path]) async { + // If logger is not initialized, fall back to simple print + if (!_isInitialized) { + // ignore: avoid_print + print('[$path] $message'); + return; + } + final date = DateTime.now(); final LogMessage logMessage = LogMessage( @@ -73,8 +83,15 @@ class UniversalLogger with LoggerMetadataMixin implements LoggerInterface { @override Future getLogFile() async { - final String date = - DateFormat('dd.MM.yyyy_HH-mm-ss').format(DateTime.now()); + if (!_isInitialized) { + // ignore: avoid_print + print('Logger not initialized, cannot export log file'); + return; + } + + final String date = DateFormat( + 'dd.MM.yyyy_HH-mm-ss', + ).format(DateTime.now()); final String filename = 'komodo_wallet_log_$date'; await FileLoader.fromPlatform().save( @@ -83,4 +100,29 @@ class UniversalLogger with LoggerMetadataMixin implements LoggerInterface { type: LoadFileType.compressed, ); } + + @override + Future exportRecentLogsBytes({ + int maxBytes = 9 * 1024 * 1024, + }) async { + final List recentChunks = []; + int totalBytes = 0; + + await for (final String chunk in DragonLogs.exportLogsStream()) { + final Uint8List bytes = Uint8List.fromList(utf8.encode(chunk)); + recentChunks.add(bytes); + totalBytes += bytes.length; + + while (totalBytes > maxBytes && recentChunks.isNotEmpty) { + totalBytes -= recentChunks.first.length; + recentChunks.removeAt(0); + } + } + + final BytesBuilder builder = BytesBuilder(copy: false); + for (final Uint8List part in recentChunks) { + builder.add(part); + } + return builder.toBytes(); + } } diff --git a/lib/shared/constants.dart b/lib/shared/constants.dart index 80dd23d4da..1755353875 100644 --- a/lib/shared/constants.dart +++ b/lib/shared/constants.dart @@ -8,6 +8,7 @@ const int decimalRange = 8; const String storedSettingsKey = '_atomicDexStoredSettings'; const String storedAnalyticsSettingsKey = 'analytics_settings'; const String storedMarketMakerSettingsKey = 'market_maker_settings'; +const String lastLoggedInWalletKey = 'last_logged_in_wallet'; // anchor: protocols support const String ercTxHistoryUrl = 'https://etherscan-proxy.komodo.earth/api'; @@ -36,10 +37,14 @@ const String updateCheckerEndpoint = 'https://komodo.earth/adexwebversion'; final Uri feedbackUrl = Uri.parse('https://komodo.earth:8181/webform/'); const int feedbackMaxLength = 1000; 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; 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(r'^@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'); +final RegExp matrixIdRegex = RegExp( + r'^@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', +); final Uri pricesUrlV3 = Uri.parse( 'https://defi-stats.komodo.earth/api/v3/prices/tickers_v2?expire_at=60', ); @@ -50,10 +55,35 @@ const bool isTestMode = bool.fromEnvironment( 'testing_mode', defaultValue: false, ); + +// Analytics & CI environment configuration +// These values are provided via --dart-define at build/run time in CI and app builds +const bool isCiEnvironment = bool.fromEnvironment('CI', defaultValue: false); + +/// When true, providers should not send analytics (used in CI/tests or privacy-first builds) +const bool analyticsDisabled = bool.fromEnvironment( + 'ANALYTICS_DISABLED', + defaultValue: false, +); + +/// Matomo configuration (only used when both are non-empty) +const String matomoUrl = String.fromEnvironment('MATOMO_URL', defaultValue: ''); + +const String matomoSiteId = String.fromEnvironment( + 'MATOMO_SITE_ID', + defaultValue: '', +); + +/// Optional: Custom dimension id in Matomo used to store platform name +/// Provide via --dart-define=MATOMO_PLATFORM_DIMENSION_ID= +const int? matomoPlatformDimensionId = + int.fromEnvironment('MATOMO_PLATFORM_DIMENSION_ID', defaultValue: -1) == -1 + ? null + : int.fromEnvironment('MATOMO_PLATFORM_DIMENSION_ID'); const String moralisProxyUrl = 'https://moralis-proxy.komodo.earth'; const String nftAntiSpamUrl = 'https://nft.antispam.dragonhound.info'; const String geoBlockerApiUrl = - 'https://komodo-wallet-bouncer.komodoplatform.com'; + 'https://komodo-wallet-bouncer.komodoplatform.com/v1/'; const String tradingBlacklistUrl = 'https://defi-stats.komodo.earth/api/v3/utils/blacklist'; diff --git a/lib/shared/constants/ipfs_constants.dart b/lib/shared/constants/ipfs_constants.dart new file mode 100644 index 0000000000..f890f428c3 --- /dev/null +++ b/lib/shared/constants/ipfs_constants.dart @@ -0,0 +1,28 @@ +/// IPFS gateway configuration constants +class IpfsConstants { + IpfsConstants._(); + + /// Primary gateways ordered by reliability and performance for web platforms + /// These gateways are optimized for CORS support and reduced Cloudflare issues + static const List defaultWebOptimizedGateways = [ + 'https://dweb.link/ipfs/', // IPFS Foundation - good CORS, subdomain resolution + 'https://gateway.pinata.cloud/ipfs/', // Pinata - reliable, NFT-focused + 'https://cloudflare-ipfs.com/ipfs/', // Cloudflare - fast CDN + 'https://nftstorage.link/ipfs/', // NFT Storage - specialized for NFTs + 'https://ipfs.io/ipfs/', // Standard IPFS Foundation gateway - fallback + ]; + + /// Standard gateways for non-web platforms (mobile, desktop) + /// These gateways provide good reliability across different platforms + static const List defaultStandardGateways = [ + 'https://ipfs.io/ipfs/', + 'https://dweb.link/ipfs/', + 'https://gateway.pinata.cloud/ipfs/', + ]; + + /// Circuit breaker cooldown duration for failed gateways + static const Duration failureCooldown = Duration(minutes: 5); + + /// IPFS protocol identifier + static const String ipfsProtocol = 'ipfs://'; +} diff --git a/lib/shared/screenshot/screenshot_sensitivity.dart b/lib/shared/screenshot/screenshot_sensitivity.dart new file mode 100644 index 0000000000..a823bbb27e --- /dev/null +++ b/lib/shared/screenshot/screenshot_sensitivity.dart @@ -0,0 +1,105 @@ +import 'package:flutter/widgets.dart'; + +/// Controller that tracks whether the current UI subtree is considered +/// screenshot-sensitive. +class ScreenshotSensitivityController extends ChangeNotifier { + int _depth = 0; + + bool get isSensitive => _depth > 0; + + void enter() { + _depth += 1; + _safeNotifyListeners(); + } + + void exit() { + if (_depth > 0) { + _depth -= 1; + _safeNotifyListeners(); + } + } + + /// Safely notify listeners, avoiding calls during widget tree locked phases + /// and calling build during a build or dismount. + void _safeNotifyListeners() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (hasListeners) { + notifyListeners(); + } + }); + } +} + +/// Inherited notifier providing access to the ScreenshotSensitivityController. +class ScreenshotSensitivity + extends InheritedNotifier { + const ScreenshotSensitivity({ + super.key, + required ScreenshotSensitivityController controller, + required super.child, + }) : super(notifier: controller); + + static ScreenshotSensitivityController? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.notifier; + } + + static ScreenshotSensitivityController of(BuildContext context) { + final controller = maybeOf(context); + assert( + controller != null, + 'ScreenshotSensitivity not found in widget tree', + ); + return controller!; + } +} + +/// Widget that marks its subtree as screenshot-sensitive while mounted. +class ScreenshotSensitive extends StatefulWidget { + const ScreenshotSensitive({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _ScreenshotSensitiveState(); +} + +class _ScreenshotSensitiveState extends State { + ScreenshotSensitivityController? _controller; + bool _hasCalledEnter = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final controller = ScreenshotSensitivity.maybeOf(context); + if (!identical(controller, _controller)) { + // Exit the old controller if we were using it + if (_hasCalledEnter) { + _controller?.exit(); + } + _controller = controller; + _hasCalledEnter = false; + // Enter the new controller - this is safe now due to deferred notification + _controller?.enter(); + _hasCalledEnter = true; + } + } + + @override + void dispose() { + // Exit the controller - this is safe now due to deferred notification + if (_hasCalledEnter) { + _controller?.exit(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} + +extension ScreenshotSensitivityContextExt on BuildContext { + bool get isScreenshotSensitive => + ScreenshotSensitivity.maybeOf(this)?.isSensitive ?? false; +} diff --git a/lib/shared/ui/clock_warning_banner.dart b/lib/shared/ui/clock_warning_banner.dart index 16f0f4fcd4..1cbc567cbd 100644 --- a/lib/shared/ui/clock_warning_banner.dart +++ b/lib/shared/ui/clock_warning_banner.dart @@ -12,39 +12,37 @@ class ClockWarningBanner extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, systemHealthState) { - final tradingEnabled = - context.watch().state is TradingEnabled; + final tradingEnabled = context + .watch() + .state + .isEnabled; if (systemHealthState is SystemHealthLoadSuccess && !systemHealthState.isValid && tradingEnabled) { - return _buildWarningBanner(); + return Container( + padding: const EdgeInsets.all(10), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.warning, color: Colors.white), + const SizedBox(width: 8), + Expanded( + child: Text( + LocaleKeys.systemTimeWarning.tr(), + style: const TextStyle(color: Colors.white), + ), + ), + ], + ), + ); } return const SizedBox.shrink(); }, ); } - - Widget _buildWarningBanner() { - return Container( - padding: const EdgeInsets.all(10), - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.warning, color: Colors.white), - const SizedBox(width: 8), - Expanded( - child: Text( - LocaleKeys.systemTimeWarning.tr(), - style: const TextStyle(color: Colors.white), - ), - ), - ], - ), - ); - } } diff --git a/lib/shared/utils/balances_formatter.dart b/lib/shared/utils/balances_formatter.dart index 479187c4c6..9b088d011e 100644 --- a/lib/shared/utils/balances_formatter.dart +++ b/lib/shared/utils/balances_formatter.dart @@ -1,3 +1,4 @@ +import 'package:decimal/decimal.dart'; import 'package:rational/rational.dart'; import 'package:web_dex/model/coin.dart'; @@ -28,7 +29,6 @@ import 'package:web_dex/model/coin.dart'; /// ``` /// unit tests: [get_fiat_amount_tests] double getFiatAmount(Coin coin, Rational amount) { - final double usdPrice = coin.usdPrice?.price ?? 0.00; - final Rational usdPriceRational = Rational.parse(usdPrice.toString()); - return (amount * usdPriceRational).toDouble(); + final Decimal usdPrice = coin.usdPrice?.price ?? Decimal.zero; + return (amount * usdPrice.toRational()).toDouble(); } diff --git a/lib/shared/utils/extensions/kdf_user_extensions.dart b/lib/shared/utils/extensions/kdf_user_extensions.dart new file mode 100644 index 0000000000..4475293a7c --- /dev/null +++ b/lib/shared/utils/extensions/kdf_user_extensions.dart @@ -0,0 +1,7 @@ +import 'package:komodo_defi_types/komodo_defi_types.dart' show KdfUser; +import 'package:web_dex/model/wallet.dart'; + +extension KdfUserAnalyticsExtension on KdfUser { + /// Returns a normalized wallet type string for analytics/logging. + String get type => wallet.config.type.name; +} diff --git a/lib/shared/utils/extensions/sdk_extensions.dart b/lib/shared/utils/extensions/sdk_extensions.dart index bedfb05dd6..72b871d8bb 100644 --- a/lib/shared/utils/extensions/sdk_extensions.dart +++ b/lib/shared/utils/extensions/sdk_extensions.dart @@ -20,8 +20,10 @@ extension SdkBalances on Asset { return sdk.balances.getBalance(id); } - Stream watchBalance(KomodoDefiSdk sdk, - {bool activateIfNeeded = true}) { + Stream watchBalance( + KomodoDefiSdk sdk, { + bool activateIfNeeded = true, + }) { return sdk.balances.watchBalance(id, activateIfNeeded: activateIfNeeded); } } @@ -31,14 +33,13 @@ extension SdkPrices on Asset { Future getFiatPrice( KomodoDefiSdk sdk, { DateTime? priceDate, - String fiatCurrency = 'usdt', + QuoteCurrency quoteCurrency = Stablecoin.usdt, }) async { return (await sdk.marketData.maybeFiatPrice( id, priceDate: priceDate, - fiatCurrency: fiatCurrency, - )) - ?.toDouble(); + quoteCurrency: quoteCurrency, + ))?.toDouble(); } // /// Gets historical fiat prices for specified dates @@ -57,10 +58,10 @@ extension SdkPrices on Asset { /// Watches for price updates and maintains the cache Stream watchFiatPrice( KomodoDefiSdk sdk, { - String fiatCurrency = 'usdt', + QuoteCurrency quoteCurrency = Stablecoin.usdt, }) async* { while (true) { - final price = await getFiatPrice(sdk, fiatCurrency: fiatCurrency); + final price = await getFiatPrice(sdk, quoteCurrency: quoteCurrency); yield price; await Future.delayed(const Duration(minutes: 1)); } diff --git a/lib/shared/utils/ipfs_gateway_manager.dart b/lib/shared/utils/ipfs_gateway_manager.dart new file mode 100644 index 0000000000..3785b0f1fb --- /dev/null +++ b/lib/shared/utils/ipfs_gateway_manager.dart @@ -0,0 +1,250 @@ +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:mutex/mutex.dart'; +import 'package:web_dex/shared/constants/ipfs_constants.dart'; + +/// Manages IPFS gateway selection and fallback mechanisms for reliable content loading +class IpfsGatewayManager { + /// Creates an IPFS gateway manager with optional custom gateway configurations + /// + /// [webOptimizedGateways] - List of gateways optimized for web platforms + /// [standardGateways] - List of gateways for non-web platforms + /// [failureCooldown] - Duration to wait before retrying a failed gateway + /// [httpClient] - HTTP client for testing URL accessibility (optional, defaults to http.Client()) + /// [urlTestTimeout] - Timeout duration for URL accessibility tests + IpfsGatewayManager({ + List? webOptimizedGateways, + List? standardGateways, + Duration? failureCooldown, + http.Client? httpClient, + Duration? urlTestTimeout, + }) : _webOptimizedGateways = + webOptimizedGateways ?? IpfsConstants.defaultWebOptimizedGateways, + _standardGateways = + standardGateways ?? IpfsConstants.defaultStandardGateways, + _failureCooldown = failureCooldown ?? IpfsConstants.failureCooldown, + _httpClient = httpClient ?? http.Client(), + _urlTestTimeout = urlTestTimeout ?? const Duration(seconds: 5); + + // Configuration + final List _webOptimizedGateways; + final List _standardGateways; + final Duration _failureCooldown; + final http.Client _httpClient; + final Duration _urlTestTimeout; + + // Failed URL tracking for circuit breaker pattern - protected by mutex for thread safety + final Set _failedUrls = {}; + final Map _failureTimestamps = {}; + final ReadWriteMutex _collectionsMutex = ReadWriteMutex(); + + // Gateway patterns to normalize to our preferred gateways + static final RegExp _gatewayPattern = RegExp( + r'https://([^/]+(?:\.ipfs\.|ipfs\.)[^/]+)/ipfs/', + caseSensitive: false, + ); + + // Subdomain IPFS pattern (e.g., https://QmXYZ.ipfs.dweb.link) + static final RegExp _subdomainPattern = RegExp( + r'https://([a-zA-Z0-9]+)\.ipfs\.([^/]+)', + caseSensitive: false, + ); + + /// Returns the appropriate list of gateways based on the current platform + List get gateways { + if (kIsWeb) { + return _webOptimizedGateways; + } + return _standardGateways; + } + + /// Converts an IPFS URL to HTTP gateway URLs with multiple fallback options + List getGatewayUrls(String? url) { + if (url == null || url.isEmpty) return []; + + final cid = _extractContentId(url); + if (cid == null) return [url]; // Not an IPFS URL, return as-is + + // Generate URLs for all available gateways + return gateways.map((gateway) => '$gateway$cid').toList(); + } + + /// Gets the primary (preferred) gateway URL for an IPFS link + String? getPrimaryGatewayUrl(String? url) { + final urls = getGatewayUrls(url); + return urls.isNotEmpty ? urls.first : null; + } + + /// Extracts the IPFS content ID from various URL formats + static String? _extractContentId(String url) { + // Handle ipfs:// protocol (case-insensitive) + if (url.toLowerCase().startsWith( + IpfsConstants.ipfsProtocol.toLowerCase(), + )) { + return url.substring(IpfsConstants.ipfsProtocol.length); + } + + // Handle gateway format (e.g., https://gateway.com/ipfs/QmXYZ) + // handle gateway first, since subdomain format will also match + // this pattern + final gatewayMatch = _gatewayPattern.firstMatch(url); + if (gatewayMatch != null) { + return url.substring(gatewayMatch.end); + } + + // Handle subdomain format (e.g., https://QmXYZ.ipfs.dweb.link/path) + final subdomainMatch = _subdomainPattern.firstMatch(url); + if (subdomainMatch != null) { + final cid = subdomainMatch.group(1)!; + final remainingPath = url.substring(subdomainMatch.end); + return remainingPath.isEmpty ? cid : '$cid$remainingPath'; + } + + // Check if URL contains /ipfs/ somewhere (case-insensitive) + final ipfsIndex = url.toLowerCase().indexOf('/ipfs/'); + if (ipfsIndex != -1) { + return url.substring(ipfsIndex + 6); // +6 for '/ipfs/'.length + } + + return null; // Not a recognized IPFS URL + } + + /// Normalizes an IPFS URL to use the preferred gateway + String? normalizeIpfsUrl(String? url) { + return getPrimaryGatewayUrl(url); + } + + /// Checks if a URL is an IPFS URL (any format) + static bool isIpfsUrl(String? url) { + if (url == null || url.isEmpty) return false; + + return url.toLowerCase().startsWith( + IpfsConstants.ipfsProtocol.toLowerCase(), + ) || + _subdomainPattern.hasMatch(url) || + _gatewayPattern.hasMatch(url) || + url.toLowerCase().contains('/ipfs/'); + } + + /// Logs gateway performance for debugging + Future logGatewayAttempt( + String gatewayUrl, + bool success, { + String? errorMessage, + Duration? loadTime, + }) async { + await _collectionsMutex.protectWrite(() async { + if (success) { + // Remove from failed set on success + _failedUrls.remove(gatewayUrl); + _failureTimestamps.remove(gatewayUrl); + } else { + // Mark as failed + _failedUrls.add(gatewayUrl); + _failureTimestamps[gatewayUrl] = DateTime.now(); + } + }); + + if (kDebugMode) { + final status = success ? 'SUCCESS' : 'FAILED'; + final timing = loadTime != null ? ' (${loadTime.inMilliseconds}ms)' : ''; + final error = errorMessage != null ? ' - $errorMessage' : ''; + + debugPrint('IPFS Gateway $status: $gatewayUrl$timing$error'); + } + } + + /// Checks if a URL should be skipped due to recent failures + Future shouldSkipUrl(String url) async { + return await _collectionsMutex.protectWrite(() async { + if (!_failedUrls.contains(url)) return false; + + final failureTime = _failureTimestamps[url]; + if (failureTime == null) return false; + + final now = DateTime.now(); + if (now.difference(failureTime) > _failureCooldown) { + // Cooldown expired, remove from failed set + _failedUrls.remove(url); + _failureTimestamps.remove(url); + return false; + } + + return true; + }); + } + + /// Gets gateway URLs excluding recently failed ones + Future> getReliableGatewayUrls(String? url) async { + final allUrls = getGatewayUrls(url); + final reliableUrls = []; + + for (final urlToCheck in allUrls) { + final shouldSkip = await shouldSkipUrl(urlToCheck); + if (!shouldSkip) { + reliableUrls.add(urlToCheck); + } + } + + return reliableUrls; + } + + /// Test if a URL is accessible by making a HEAD request + Future testUrlAccessibility(String url) async { + try { + final response = await _httpClient + .head(Uri.parse(url)) + .timeout(_urlTestTimeout); + return response.statusCode == 200; + } catch (e) { + return false; + } + } + + /// Find the first working URL from a list of URLs + /// + /// [urls] - List of URLs to test + /// [startIndex] - Index to start testing from (defaults to 0) + /// [onUrlTested] - Optional callback called for each URL test result + Future findWorkingUrl( + List urls, { + int startIndex = 0, + void Function(String url, bool success, String? errorMessage)? onUrlTested, + }) async { + for (int i = startIndex; i < urls.length; i++) { + final url = urls[i]; + + // Skip URLs that are recently failed according to circuit breaker + final shouldSkip = await shouldSkipUrl(url); + if (shouldSkip) { + continue; + } + + final isWorking = await testUrlAccessibility(url); + + // Call the callback if provided + onUrlTested?.call( + url, + isWorking, + isWorking ? null : 'URL accessibility test failed', + ); + + if (isWorking) { + return url; + } else { + // Log the failed attempt + await logGatewayAttempt( + url, + false, + errorMessage: 'URL accessibility test failed', + ); + } + } + return null; + } + + /// Dispose of resources + void dispose() { + _httpClient.close(); + } +} diff --git a/lib/shared/utils/utils.dart b/lib/shared/utils/utils.dart index 6f321637ff..6fff9f4bb8 100644 --- a/lib/shared/utils/utils.dart +++ b/lib/shared/utils/utils.dart @@ -139,9 +139,11 @@ Rational? fract2rat(Map? fract, [bool willLog = true]) { if (fract == null) return null; try { + final String numerStr = fract['numer'].toString(); + final String denomStr = fract['denom'].toString(); final rat = Rational( - BigInt.from(double.parse(fract['numer'])), - BigInt.from(double.parse(fract['denom'])), + BigInt.parse(numerStr), + BigInt.parse(denomStr), ); return rat; } catch (e) { @@ -390,6 +392,7 @@ final Map _abbr2TickerCache = {}; Color getProtocolColor(CoinType type) { switch (type) { + case CoinType.zhtlc: case CoinType.utxo: return const Color.fromRGBO(233, 152, 60, 1); case CoinType.erc20: @@ -453,6 +456,7 @@ bool hasTxHistorySupport(Coin coin) { case CoinType.hco20: case CoinType.plg20: case CoinType.slp: + case CoinType.zhtlc: return true; } } @@ -469,6 +473,7 @@ String getNativeExplorerUrlByCoin(Coin coin, String? address) { case CoinType.tendermintToken: return '${coin.explorerUrl}account/$coinAddress'; + case CoinType.zhtlc: case CoinType.utxo: case CoinType.smartChain: case CoinType.erc20: diff --git a/lib/shared/widgets/app_dialog.dart b/lib/shared/widgets/app_dialog.dart new file mode 100644 index 0000000000..5ff8f02ddb --- /dev/null +++ b/lib/shared/widgets/app_dialog.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; +import 'package:app_theme/app_theme.dart'; +import 'package:web_dex/common/screen.dart'; + +// Constants for dialog styling +const double _defaultBorderRadius = 18; +const double _defaultMaxWidth = 640; +const BorderRadius _defaultDialogBorderRadius = BorderRadius.all( + Radius.circular(_defaultBorderRadius), +); + +/// A replacement for the deprecated PopupDispatcher that uses Flutter's built-in dialog system. +/// +/// This widget provides the same styling and behavior as PopupDispatcher but with +/// better performance and maintainability. +/// +/// ## Migration from PopupDispatcher +/// +/// **Simple dialog:** +/// ```dart +/// // OLD +/// PopupDispatcher( +/// context: context, +/// width: 320, +/// popupContent: MyWidget(), +/// ).show(); +/// +/// // NEW +/// AppDialog.show( +/// context: context, +/// width: 320, +/// child: MyWidget(), +/// ); +/// ``` +/// +/// **Dialog with success callback:** +/// ```dart +/// // OLD +/// _popupDispatcher = PopupDispatcher( +/// context: context, +/// popupContent: MyWidget(onSuccess: () => _popupDispatcher?.close()), +/// ); +/// _popupDispatcher?.show(); +/// +/// // NEW +/// AppDialog.showWithCallback( +/// context: context, +/// childBuilder: (closeDialog) => MyWidget(onSuccess: closeDialog), +/// // useRootNavigator defaults to true to prevent navigation corruption +/// ); +/// ``` +/// +/// **Dialog with custom styling:** +/// ```dart +/// // OLD +/// PopupDispatcher( +/// context: context, +/// borderColor: customColor, +/// contentPadding: EdgeInsets.all(20), +/// popupContent: MyWidget(), +/// ).show(); +/// +/// // NEW +/// AppDialog.show( +/// context: context, +/// borderColor: customColor, +/// contentPadding: EdgeInsets.all(20), +/// child: MyWidget(), +/// ); +/// ``` +class AppDialog { + /// Shows a dialog with PopupDispatcher-compatible styling. + /// + /// Parameters: + /// - [context]: The build context to show the dialog in + /// - [child]: The widget to display inside the dialog + /// - [width]: The preferred width of the dialog content + /// - [maxWidth]: The maximum width constraint (defaults to 640) + /// - [barrierDismissible]: Whether the dialog can be dismissed by tapping outside (defaults to true) + /// - [borderColor]: The color of the dialog border (defaults to theme.custom.specificButtonBorderColor) + /// - [insetPadding]: Custom inset padding (uses responsive defaults if null) + /// - [contentPadding]: Custom content padding (uses responsive defaults if null) + /// - [useRootNavigator]: Whether to use the root navigator (defaults to false) + /// - [onDismiss]: Callback called when the dialog is dismissed + static Future show({ + required BuildContext context, + required Widget child, + double? width, + double maxWidth = _defaultMaxWidth, + bool barrierDismissible = true, + Color? borderColor, + EdgeInsets? insetPadding, + EdgeInsets? contentPadding, + bool useRootNavigator = false, + VoidCallback? onDismiss, + }) async { + // Ensure context is still mounted before showing dialog + if (!context.mounted) return null; + + // Validate parameters + assert(maxWidth > 0, 'maxWidth must be positive'); + assert(width == null || width > 0, 'width must be positive if provided'); + + final result = await showDialog( + context: context, + useRootNavigator: useRootNavigator, + barrierDismissible: barrierDismissible, + barrierColor: theme.custom.dialogBarrierColor, + builder: (BuildContext dialogContext) { + return PopScope( + onPopInvokedWithResult: (didPop, result) { + // Only call onDismiss when dialog is actually dismissed (not programmatically closed) + if (didPop && onDismiss != null) { + onDismiss(); + } + }, + child: SimpleDialog( + insetPadding: + insetPadding ?? + EdgeInsets.symmetric( + horizontal: isMobile ? 16 : 24, + vertical: isMobile ? 40 : 24, + ), + shape: RoundedRectangleBorder( + borderRadius: _defaultDialogBorderRadius, + side: BorderSide( + color: borderColor ?? theme.custom.specificButtonBorderColor, + ), + ), + contentPadding: + contentPadding ?? + EdgeInsets.symmetric( + horizontal: isMobile ? 16 : 30, + vertical: isMobile ? 26 : 30, + ), + children: [ + Container( + width: width, + constraints: BoxConstraints(maxWidth: maxWidth), + child: child, + ), + ], + ), + ); + }, + ); + + return result; + } + + /// Shows a dialog with a specific content widget that can close itself. + /// + /// This is a convenience method for dialogs that need to close automatically + /// when a success action occurs. The child widget receives a `closeDialog` + /// callback that it can call to close the dialog safely. + /// + /// It defaults to using the root navigator to prevent navigation stack + /// corruption after login or similar flows. + static Future showWithCallback({ + required BuildContext context, + required Widget Function(VoidCallback closeDialog) childBuilder, + double? width, + double maxWidth = _defaultMaxWidth, + bool barrierDismissible = true, + Color? borderColor, + EdgeInsets? insetPadding, + EdgeInsets? contentPadding, + bool useRootNavigator = true, + VoidCallback? onDismiss, + }) async { + // Ensure context is still mounted before showing dialog + if (!context.mounted) return null; + + // Validate parameters + assert(maxWidth > 0, 'maxWidth must be positive'); + assert(width == null || width > 0, 'width must be positive if provided'); + + return show( + context: context, + width: width, + maxWidth: maxWidth, + barrierDismissible: barrierDismissible, + borderColor: borderColor, + insetPadding: insetPadding, + contentPadding: contentPadding, + useRootNavigator: useRootNavigator, + onDismiss: onDismiss, + child: Builder( + builder: (context) { + try { + // Guard against multiple close attempts which may happen during + // rapid navigation state changes (e.g. login success + route change) + bool didRequestClose = false; + return childBuilder(() { + if (didRequestClose) return; + didRequestClose = true; + + // Pop the dialog using the same navigator that was used to show it. + // Use maybePop to avoid "Bad state: No element" when there is + // nothing left to pop on the target navigator. + final navigator = Navigator.of( + context, + rootNavigator: useRootNavigator, + ); + // ignore: discarded_futures + navigator.maybePop(); + }); + } catch (e) { + // If childBuilder throws an error, show a fallback widget + return Center( + child: Text( + 'Error building dialog content: $e', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ); + } + }, + ), + ); + } + + /// Safely closes a dialog using the correct navigator context. + /// + /// This method ensures the dialog is closed using the appropriate navigator, + /// preventing navigation stack corruption. Use this when you need to close + /// a dialog programmatically from outside the dialog widget. + /// + /// [useRootNavigator] should match the value used when showing the dialog. + /// - Use `false` for dialogs shown with `AppDialog.show()` (default) + /// - Use `true` for dialogs shown with `AppDialog.showWithCallback()` (default) + static void close(BuildContext context, {bool useRootNavigator = false}) { + if (context.mounted) { + final navigator = Navigator.of(context, rootNavigator: useRootNavigator); + // ignore: discarded_futures + navigator.maybePop(); + } + } +} diff --git a/lib/shared/widgets/coin_balance.dart b/lib/shared/widgets/coin_balance.dart index 3ffad1a5d4..baa0952374 100644 --- a/lib/shared/widgets/coin_balance.dart +++ b/lib/shared/widgets/coin_balance.dart @@ -7,11 +7,7 @@ import 'package:web_dex/shared/widgets/coin_fiat_balance.dart'; // TODO! Integrate this widget directly to the SDK and make it subscribe to // the balance changes of the coin. class CoinBalance extends StatelessWidget { - const CoinBalance({ - super.key, - required this.coin, - this.isVertical = false, - }); + const CoinBalance({super.key, required this.coin, this.isVertical = false}); final Coin coin; final bool isVertical; @@ -19,9 +15,7 @@ class CoinBalance extends StatelessWidget { @override Widget build(BuildContext context) { final baseFont = Theme.of(context).textTheme.bodySmall; - final balanceStyle = baseFont?.copyWith( - fontWeight: FontWeight.w500, - ); + final balanceStyle = baseFont?.copyWith(fontWeight: FontWeight.w500); final balance = context.sdk.balances.lastKnown(coin.id)?.spendable.toDouble() ?? 0.0; @@ -38,26 +32,12 @@ class CoinBalance extends StatelessWidget { textAlign: TextAlign.right, ), ), - Text( - ' ${Coin.normalizeAbbr(coin.abbr)}', - style: balanceStyle, - ), + Text(' ${Coin.normalizeAbbr(coin.abbr)}', style: balanceStyle), ], ), ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 100, - ), - child: Row( - children: [ - Text(' (', style: balanceStyle), - CoinFiatBalance( - coin, - isAutoScrollEnabled: true, - ), - Text(')', style: balanceStyle), - ], - ), + constraints: const BoxConstraints(maxWidth: 100), + child: CoinFiatBalance(coin, isAutoScrollEnabled: true), ), ]; diff --git a/lib/shared/widgets/coin_fiat_balance.dart b/lib/shared/widgets/coin_fiat_balance.dart index f50e5c35e8..6a966fdedb 100644 --- a/lib/shared/widgets/coin_fiat_balance.dart +++ b/lib/shared/widgets/coin_fiat_balance.dart @@ -23,31 +23,38 @@ class CoinFiatBalance extends StatelessWidget { Widget build(BuildContext context) { final balanceStream = context.sdk.balances.watchBalance(coin.id); - final TextStyle mergedStyle = - const TextStyle(fontSize: 12, fontWeight: FontWeight.w500).merge(style); + final TextStyle mergedStyle = const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ).merge(style); return StreamBuilder( - stream: balanceStream, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox(); - } - - final balanceStr = formatUsdValue( - coin.lastKnownUsdBalance(context.sdk), + 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, ); + } - if (isAutoScrollEnabled) { - return AutoScrollText( - text: balanceStr, - style: mergedStyle, - isSelectable: isSelectable, - ); - } - - return isSelectable - ? SelectableText(balanceStr, style: mergedStyle) - : Text(balanceStr, style: mergedStyle); - }); + return isSelectable + ? SelectableText(balanceStr, style: mergedStyle) + : Text(balanceStr, style: mergedStyle); + }, + ); } } diff --git a/lib/shared/widgets/coin_item/coin_item_title.dart b/lib/shared/widgets/coin_item/coin_item_title.dart index 4115c79658..fe2fd88f15 100644 --- a/lib/shared/widgets/coin_item/coin_item_title.dart +++ b/lib/shared/widgets/coin_item/coin_item_title.dart @@ -34,15 +34,12 @@ class CoinItemTitle extends StatelessWidget { Flexible( child: amount == null ? CoinName( - text: coin?.name, + text: coin?.displayName, style: TextStyle(fontSize: size.titleFontSize, height: 1), ) : coin?.mode == CoinMode.segwit - ? SegwitIcon(height: size.segwitIconSize) - : CoinProtocolName( - text: coin?.typeNameWithTestnet, - size: size, - ), + ? SegwitIcon(height: size.segwitIconSize) + : CoinProtocolName(text: coin?.typeNameWithTestnet, size: size), ), ], ); diff --git a/lib/shared/widgets/coin_select_item_widget.dart b/lib/shared/widgets/coin_select_item_widget.dart index 47deaf7bff..efc547fd7d 100644 --- a/lib/shared/widgets/coin_select_item_widget.dart +++ b/lib/shared/widgets/coin_select_item_widget.dart @@ -57,8 +57,8 @@ class CoinSelectItemWidget extends StatelessWidget { builder: (context) { final themeCustom = Theme.of(context).brightness == Brightness.dark - ? Theme.of(context).extension()! - : Theme.of(context).extension()!; + ? Theme.of(context).extension()! + : Theme.of(context).extension()!; return TrendPercentageText( percentage: trendPercentage, upColor: themeCustom.increaseColor, @@ -80,10 +80,7 @@ class CoinSelectItemWidget extends StatelessWidget { child: Row( children: [ if (leading != null) - Padding( - padding: const EdgeInsets.only(right: 12), - child: leading!, - ) + Padding(padding: const EdgeInsets.only(right: 12), child: leading!) else Padding( padding: const EdgeInsets.only(right: 12), @@ -91,16 +88,14 @@ class CoinSelectItemWidget extends StatelessWidget { ), Expanded( child: DefaultTextStyle( - style: theme.inputDecorationTheme.labelStyle ?? - theme.textTheme.bodyMedium!, + style: theme.textTheme.bodyMedium!.copyWith( + color: theme.colorScheme.onSurface, + ), child: title ?? Text(name), ), ), if (trailing != null) - Padding( - padding: const EdgeInsets.only(left: 8), - child: trailing!, - ), + Padding(padding: const EdgeInsets.only(left: 8), child: trailing!), ], ), ); diff --git a/lib/shared/widgets/coin_type_tag.dart b/lib/shared/widgets/coin_type_tag.dart index 44f917a0e4..c400248c24 100644 --- a/lib/shared/widgets/coin_type_tag.dart +++ b/lib/shared/widgets/coin_type_tag.dart @@ -27,55 +27,24 @@ class CoinTypeTag extends StatelessWidget { ), ), child: Center( - child: Text(_protocolName, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w400, - color: Colors.white, - ))), + child: Text( + _resolvedProtocolName, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w400, + color: Colors.white, + ), + ), + ), ); } - String get _protocolName { - switch (coin.type) { - case CoinType.smartChain: - return 'SMART CHAIN'; - case CoinType.erc20: - return 'ERC20'; - case CoinType.utxo: - return 'UTXO'; - case CoinType.bep20: - return 'BEP20'; - case CoinType.qrc20: - return 'QRC20'; - case CoinType.ftm20: - return 'FTM20'; - case CoinType.arb20: - return 'ARB20'; - case CoinType.etc: - return 'ETC'; - case CoinType.avx20: - return 'AVX20'; - case CoinType.hrc20: - return 'HRC20'; - case CoinType.mvr20: - return 'MVR20'; - case CoinType.hco20: - return 'HCO20'; - case CoinType.plg20: - return 'PLG20'; - case CoinType.sbch: - return 'SmartBCH'; - case CoinType.ubiq: - return 'UBIQ'; - case CoinType.krc20: - return 'KRC20'; - case CoinType.tendermintToken: - return 'TENDERMINTTOKEN'; - case CoinType.tendermint: - return 'TENDERMINT'; - case CoinType.slp: - return 'SLP'; - } + String get _resolvedProtocolName { + // Use the same naming that the business logic layer uses everywhere else + // and ensure parents show as 'Native'. + final upper = coin.typeName.toUpperCase(); + if (upper == 'SMART CHAIN') return 'SMART CHAIN'; + // Keep short forms without hyphen for small badge + return upper.replaceAll('-', ''); } } diff --git a/lib/shared/widgets/hidden_without_wallet.dart b/lib/shared/widgets/hidden_without_wallet.dart index acdcd9b1b0..2bc7df798c 100644 --- a/lib/shared/widgets/hidden_without_wallet.dart +++ b/lib/shared/widgets/hidden_without_wallet.dart @@ -5,20 +5,23 @@ import 'package:web_dex/model/wallet.dart'; class HiddenWithoutWallet extends StatelessWidget { const HiddenWithoutWallet( - {Key? key, required this.child, this.isHiddenForHw = false}) + {Key? key, required this.child, this.isHiddenForHw = false, this.isHiddenElse = true}) : super(key: key); final Widget child; final bool isHiddenForHw; + final bool isHiddenElse; @override Widget build(BuildContext context) { return BlocBuilder(builder: (context, state) { final Wallet? currentWallet = state.currentUser?.wallet; if (currentWallet == null) { - return const SizedBox.shrink(); + if (isHiddenElse) { + return const SizedBox.shrink(); + } } - if (isHiddenForHw && currentWallet.isHW) { + if (isHiddenForHw && currentWallet?.isHW == true) { return const SizedBox.shrink(); } diff --git a/lib/shared/widgets/logout_popup.dart b/lib/shared/widgets/logout_popup.dart index 32227ba5ff..fff7e2bdf0 100644 --- a/lib/shared/widgets/logout_popup.dart +++ b/lib/shared/widgets/logout_popup.dart @@ -39,7 +39,7 @@ class LogOutPopup extends StatelessWidget { if (currentWallet?.config.type == WalletType.iguana || currentWallet?.config.type == WalletType.hdwallet) SelectableText( - context.watch().state is! TradingEnabled + !context.watch().state.isEnabled ? LocaleKeys.logoutPopupDescriptionWalletOnly.tr() : LocaleKeys.logoutPopupDescription.tr(), style: const TextStyle( diff --git a/lib/shared/widgets/quick_login_switch.dart b/lib/shared/widgets/quick_login_switch.dart new file mode 100644 index 0000000000..3673269ec3 --- /dev/null +++ b/lib/shared/widgets/quick_login_switch.dart @@ -0,0 +1,60 @@ +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/shared/widgets/remember_wallet_service.dart'; + +class QuickLoginSwitch extends StatelessWidget { + final bool value; + final ValueChanged onChanged; + + const QuickLoginSwitch({ + super.key, + required this.value, + required this.onChanged, + }); + + /// Show remembered wallet dialog if conditions are met + static Future maybeShowRememberedWallet(BuildContext context) async { + return RememberWalletService.maybeShowRememberedWallet(context); + } + + /// Track when user has been logged in + static void trackUserLoggedIn() { + RememberWalletService.trackUserLoggedIn(); + } + + /// Reset remember me dialog state when user logs out + static void resetOnLogout() { + RememberWalletService.resetOnLogout(); + } + + /// Check if remember me dialog has been shown this session + static bool get hasShownRememberMeDialogThisSession => + RememberWalletService.hasShownRememberMeDialogThisSession; + + /// Check if user has been logged in this session + static bool get hasBeenLoggedInThisSession => + RememberWalletService.hasBeenLoggedInThisSession; + + @override + Widget build(BuildContext context) { + return SwitchListTile( + title: Row( + children: [ + Text(LocaleKeys.oneClickLogin.tr()), + const SizedBox(width: 8), + Tooltip( + message: LocaleKeys.quickLoginTooltip.tr(), + child: const Icon(Icons.info, size: 16), + ), + ], + ), + subtitle: Text( + LocaleKeys.quickLoginSubtitle.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + value: value, + onChanged: onChanged, + ); + } +} diff --git a/lib/shared/widgets/remember_wallet_service.dart b/lib/shared/widgets/remember_wallet_service.dart new file mode 100644 index 0000000000..64a0864a60 --- /dev/null +++ b/lib/shared/widgets/remember_wallet_service.dart @@ -0,0 +1,133 @@ +import 'dart:convert'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:logging/logging.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/blocs/wallets_repository.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/services/storage/get_storage.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/widgets/app_dialog.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_wrapper.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; + +/// Service to handle remember wallet functionality +class RememberWalletService { + static final _log = Logger('RememberWalletService'); + static bool _hasShownRememberMeDialogThisSession = false; + static bool _hasBeenLoggedInThisSession = false; + + /// Check and possibly show the remembered wallet dialog + static Future maybeShowRememberedWallet(BuildContext context) async { + final authState = context.read().state; + if (authState.mode != AuthorizeMode.noLogin || + _hasShownRememberMeDialogThisSession) { + return; + } + + final storage = getStorage(); + final walletsRepo = context.read(); + final storedWalletData = await storage.read(lastLoggedInWalletKey); + if (storedWalletData == null) return; + + WalletId walletId; + try { + // Parse stored wallet data - handle both JSON string and Map formats + if (storedWalletData is String) { + // Try to parse as JSON string first (new format) + try { + final parsedData = + jsonDecode(storedWalletData) as Map; + walletId = WalletId.fromJson(parsedData); + } catch (_) { + // If JSON parsing fails, treat as legacy wallet name + walletId = WalletId.fromName( + storedWalletData, + AuthOptions(derivationMethod: DerivationMethod.iguana), + ); + } + } else if (storedWalletData is Map) { + walletId = WalletId.fromJson(storedWalletData); + } else { + // Unrecognized format, clear invalid data + await storage.delete(lastLoggedInWalletKey); + return; + } + } catch (e) { + // Only clear data for actual parsing errors + await storage.delete(lastLoggedInWalletKey); + return; + } + + try { + final wallets = walletsRepo.wallets ?? await walletsRepo.getWallets(); + + if (!context.mounted) return; + + // Match by wallet name and optionally by pubkey hash for more precise matching + final wallet = wallets.where((w) { + if (w.name != walletId.name) return false; + // If we have a pubkey hash in the stored WalletId, ensure it matches + if (walletId.hasFullIdentity && w.config.pubKey != null) { + // Verify if wallet.config.pubKey corresponds to walletId.pubkeyHash + final pubKeyHash = md5 + .convert(utf8.encode(w.config.pubKey!)) + .toString(); + if (pubKeyHash != walletId.pubkeyHash) return false; + } + return true; + }).firstOrNull; + + if (wallet == null) return; + + if (!context.mounted) return; + + // Use AppDialog - a replacement for deprecated PopupDispatcher + // Allow AppDialog to use its default root navigator behavior to avoid navigation stack corruption + // Mark that we've shown the dialog to prevent multiple prompts in a single session + _hasShownRememberMeDialogThisSession = true; + + await AppDialog.showWithCallback( + context: context, + width: 320, + // Keep default useRootNavigator (true) to avoid navigation stack corruption + childBuilder: (closeDialog) => WalletsManagerWrapper( + eventType: WalletsManagerEventType.header, + selectedWallet: wallet, + rememberMe: true, + onSuccess: (wallet) => closeDialog(), + ), + ); + } catch (e, stackTrace) { + // Log the error for debugging and monitoring + _log.severe('Failed to show remembered wallet dialog', e, stackTrace); + + // Reset the flag so future attempts can be made if this one failed + _hasShownRememberMeDialogThisSession = false; + + // Re-throw the error to prevent silent failures that could leave the app + // in an inconsistent state. The caller should handle this appropriately. + rethrow; + } + } + + /// Track when user has been logged in + static void trackUserLoggedIn() { + _hasBeenLoggedInThisSession = true; + } + + /// Reset remember me dialog state when user logs out + static void resetOnLogout() { + _hasShownRememberMeDialogThisSession = false; + _hasBeenLoggedInThisSession = false; + } + + /// Check if remember me dialog has been shown this session + static bool get hasShownRememberMeDialogThisSession => + _hasShownRememberMeDialogThisSession; + + /// Check if user has been logged in this session + static bool get hasBeenLoggedInThisSession => _hasBeenLoggedInThisSession; +} diff --git a/lib/views/bitrefill/bitrefill_inappwebview_button.dart b/lib/views/bitrefill/bitrefill_inappwebview_button.dart index d24596bab7..fd42db695f 100644 --- a/lib/views/bitrefill/bitrefill_inappwebview_button.dart +++ b/lib/views/bitrefill/bitrefill_inappwebview_button.dart @@ -8,6 +8,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:web_dex/bloc/bitrefill/bloc/bitrefill_bloc.dart'; import 'package:web_dex/views/bitrefill/bitrefill_button_view.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; /// A button that opens the provided url in an embedded InAppWebview widget. /// This widget uses the flutter_inappwebview package to open the url using @@ -110,12 +111,14 @@ class BitrefillInAppWebviewButtonState content: SizedBox( width: width, height: height, - child: InAppWebView( - key: const Key('bitrefill-inappwebview'), - initialUrlRequest: _createUrlRequest(), - initialSettings: settings, - onWebViewCreated: _onCreated, - onConsoleMessage: _onConsoleMessage, + child: ScreenshotSensitive( + child: InAppWebView( + key: const Key('bitrefill-inappwebview'), + initialUrlRequest: _createUrlRequest(), + initialSettings: settings, + onWebViewCreated: _onCreated, + onConsoleMessage: _onConsoleMessage, + ), ), ), actions: [ @@ -143,12 +146,14 @@ class BitrefillInAppWebviewButtonState elevation: 0, ), body: SafeArea( - child: InAppWebView( - key: const Key('bitrefill-inappwebview'), - initialUrlRequest: _createUrlRequest(), - initialSettings: settings, - onWebViewCreated: _onCreated, - onConsoleMessage: _onConsoleMessage, + child: ScreenshotSensitive( + child: InAppWebView( + key: const Key('bitrefill-inappwebview'), + initialUrlRequest: _createUrlRequest(), + initialSettings: settings, + onWebViewCreated: _onCreated, + onConsoleMessage: _onConsoleMessage, + ), ), ), ); diff --git a/lib/views/bridge/bridge_confirmation.dart b/lib/views/bridge/bridge_confirmation.dart index e34d4a9e01..f0df070c8f 100644 --- a/lib/views/bridge/bridge_confirmation.dart +++ b/lib/views/bridge/bridge_confirmation.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:rational/rational.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/bloc/bridge_form/bridge_bloc.dart'; import 'package:web_dex/bloc/bridge_form/bridge_event.dart'; import 'package:web_dex/bloc/bridge_form/bridge_state.dart'; @@ -18,7 +19,6 @@ import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/dex_form_error.dart'; import 'package:web_dex/model/trade_preimage.dart'; -import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/router/state/routing_state.dart'; import 'package:web_dex/shared/ui/ui_light_button.dart'; import 'package:web_dex/shared/utils/balances_formatter.dart'; @@ -47,8 +47,9 @@ class _BridgeOrderConfirmationState extends State { context.read().add(const BridgeClear()); routingState.bridgeState.setDetailsAction(swapUuid); - final tradingEntitiesBloc = - RepositoryProvider.of(context); + final tradingEntitiesBloc = RepositoryProvider.of( + context, + ); await tradingEntitiesBloc.fetch(); }, builder: (BuildContext context, BridgeState state) { @@ -98,7 +99,7 @@ class _BridgeOrderConfirmationState extends State { const BridgeTotalFees(), const SizedBox(height: 24), const _ErrorGroup(), - _ButtonsRow(onCancel, startSwap), + _ButtonsRow(onCancel, startSwap, confirmDto), ], ), ), @@ -112,25 +113,28 @@ class _BridgeOrderConfirmationState extends State { final bloc = context.read(); final state = bloc.state; final sellCoin = state.sellCoin; - final buyCoin = RepositoryProvider.of(context) - .getCoin(state.bestOrder?.coin ?? ''); + final buyCoin = RepositoryProvider.of( + context, + ).getCoin(state.bestOrder?.coin ?? ''); if (sellCoin != null && buyCoin != null) { context.read().logEvent( - BridgeInitiatedEventData( - fromChain: sellCoin.protocolType, - toChain: buyCoin.protocolType, - asset: sellCoin.abbr, - walletType: context - .read() - .state - .currentUser - ?.wallet - .config - .type - .name ?? - 'unknown', - ), - ); + BridgeInitiatedEventData( + asset: sellCoin.abbr, + secondaryAsset: buyCoin.abbr, + network: sellCoin.protocolType, + secondaryNetwork: buyCoin.protocolType, + hdType: + context + .read() + .state + .currentUser + ?.wallet + .config + .type + .name ?? + 'unknown', + ), + ); } bloc.add(const BridgeStartSwap()); @@ -142,11 +146,12 @@ class _BridgeOrderConfirmationState extends State { } class _ConfirmDTO { - _ConfirmDTO( - {required this.sellCoin, - required this.buyCoin, - this.sellAmount, - this.buyAmount}); + _ConfirmDTO({ + required this.sellCoin, + required this.buyCoin, + this.sellAmount, + this.buyAmount, + }); final Coin sellCoin; final Coin buyCoin; @@ -182,24 +187,23 @@ class _ReceiveGroup extends StatelessWidget { children: [ SelectableText( LocaleKeys.swapConfirmationYouReceive.tr(), - style: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith(color: theme.custom.dexSubTitleColor), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: theme.custom.dexSubTitleColor), ), SelectableText.rich( TextSpan( - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: theme.custom.balanceColor), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: theme.custom.balanceColor), children: [ TextSpan( - text: '${formatDexAmt(dto.buyAmount)} ', - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - )), + text: '${formatDexAmt(dto.buyAmount)} ', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + ), + ), TextSpan(text: dto.buyCoin.abbr), ], ), @@ -251,10 +255,10 @@ class _Percentage extends StatelessWidget { } else { final text = ' (${percentage > 0 ? '+' : ''}${formatAmt(percentage)}%)'; final style = Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: 11, - color: Theme.of(context).textTheme.bodyMedium?.color, - fontWeight: FontWeight.w200, - ); + fontSize: 11, + color: Theme.of(context).textTheme.bodyMedium?.color, + fontWeight: FontWeight.w200, + ); return Text(text, style: style); } } @@ -267,13 +271,13 @@ class _SendGroup extends StatelessWidget { @override Widget build(BuildContext context) { - final style1 = Theme.of(context).textTheme.bodyLarge?.copyWith( - color: theme.custom.dexSubTitleColor, - ); + final style1 = Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: theme.custom.dexSubTitleColor); final style3 = Theme.of(context).textTheme.bodyMedium?.copyWith( - fontSize: 14.0, - fontWeight: FontWeight.w500, - ); + fontSize: 14.0, + fontWeight: FontWeight.w500, + ); final coinsBloc = RepositoryProvider.of(context); final Coin? coin = coinsBloc.getCoin(dto.sellCoin.abbr); if (coin == null) return const SizedBox.shrink(); @@ -288,8 +292,10 @@ class _SendGroup extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - SelectableText(LocaleKeys.swapConfirmationYouSending.tr(), - style: style1), + SelectableText( + LocaleKeys.swapConfirmationYouSending.tr(), + style: style1, + ), const SizedBox(height: 10), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -307,10 +313,7 @@ class _SendGroup extends StatelessWidget { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - SelectableText( - formatDexAmt(dto.sellAmount), - style: style3, - ), + SelectableText(formatDexAmt(dto.sellAmount), style: style3), _FiatSend(dto), ], ), @@ -344,10 +347,9 @@ class _ErrorGroup extends StatelessWidget { builder: (context, error) { if (error == null) return const SizedBox(); - final style = Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(color: Theme.of(context).colorScheme.error); + final style = Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ); return Container( padding: const EdgeInsets.fromLTRB(8, 0, 8, 20), child: Text(error.error, style: style), @@ -358,10 +360,11 @@ class _ErrorGroup extends StatelessWidget { } class _ButtonsRow extends StatelessWidget { - const _ButtonsRow(this.onCancel, this.startSwap); + const _ButtonsRow(this.onCancel, this.startSwap, this.dto); final void Function()? onCancel; final void Function()? startSwap; + final _ConfirmDTO dto; @override Widget build(BuildContext context) { @@ -370,7 +373,7 @@ class _ButtonsRow extends StatelessWidget { children: [ _BackButton(onCancel), const SizedBox(width: 23), - _ConfirmButton(startSwap), + _ConfirmButton(startSwap, dto), ], ), ); @@ -386,49 +389,56 @@ class _BackButton extends StatelessWidget { Widget build(BuildContext context) { return Flexible( child: BlocSelector( - selector: (state) => state.inProgress, - builder: (context, inProgress) { - return Opacity( - opacity: inProgress ? 0.8 : 1, - child: UiLightButton( - key: const Key('bridge-order-cancel-button'), - height: 40, - onPressed: inProgress ? null : onPressed, - text: LocaleKeys.back.tr(), - ), - ); - }), + selector: (state) => state.inProgress, + builder: (context, inProgress) { + return Opacity( + opacity: inProgress ? 0.8 : 1, + child: UiLightButton( + key: const Key('bridge-order-cancel-button'), + height: 40, + onPressed: inProgress ? null : onPressed, + text: LocaleKeys.back.tr(), + ), + ); + }, + ), ); } } class _ConfirmButton extends StatelessWidget { - const _ConfirmButton(this.onPressed); + const _ConfirmButton(this.onPressed, this.dto); final void Function()? onPressed; + final _ConfirmDTO dto; @override Widget build(BuildContext context) { final tradingStatusState = context.watch().state; - final tradingEnabled = tradingStatusState.isEnabled; + final tradingEnabled = tradingStatusState.canTradeAssets([ + dto.sellCoin.id, + dto.buyCoin.id, + ]); return Flexible( - child: BlocSelector( - selector: (state) => state.inProgress, - builder: (context, inProgress) { - return Opacity( - opacity: inProgress ? 0.8 : 1, - child: UiPrimaryButton( - key: const Key('bridge-order-confirm-button'), - height: 40, - prefix: inProgress ? const _ProgressIndicator() : null, - text: tradingEnabled - ? LocaleKeys.confirm.tr() - : LocaleKeys.tradingDisabled.tr(), - onPressed: inProgress || !tradingEnabled ? null : onPressed, - ), - ); - })); + child: BlocSelector( + selector: (state) => state.inProgress, + builder: (context, inProgress) { + return Opacity( + opacity: inProgress ? 0.8 : 1, + child: UiPrimaryButton( + key: const Key('bridge-order-confirm-button'), + height: 40, + prefix: inProgress ? const _ProgressIndicator() : null, + text: tradingEnabled + ? LocaleKeys.confirm.tr() + : LocaleKeys.tradingDisabled.tr(), + onPressed: inProgress || !tradingEnabled ? null : onPressed, + ), + ); + }, + ), + ); } } diff --git a/lib/views/bridge/bridge_exchange_form.dart b/lib/views/bridge/bridge_exchange_form.dart index 91505e6dda..2334c43ee1 100644 --- a/lib/views/bridge/bridge_exchange_form.dart +++ b/lib/views/bridge/bridge_exchange_form.dart @@ -8,6 +8,7 @@ import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; import 'package:web_dex/bloc/bridge_form/bridge_bloc.dart'; import 'package:web_dex/bloc/bridge_form/bridge_event.dart'; import 'package:web_dex/bloc/bridge_form/bridge_state.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; @@ -55,15 +56,9 @@ class _BridgeExchangeFormState extends State { children: [ BridgeTickerSelector(), SizedBox(height: 30), - BridgeGroup( - header: SourceProtocolHeader(), - child: SourceProtocol(), - ), + BridgeGroup(header: SourceProtocolHeader(), child: SourceProtocol()), SizedBox(height: 19), - BridgeGroup( - header: TargetProtocolHeader(), - child: TargetProtocol(), - ), + BridgeGroup(header: TargetProtocolHeader(), child: TargetProtocol()), SizedBox(height: 12), BridgeFormErrorList(), SizedBox(height: 12), @@ -117,18 +112,27 @@ class _ExchangeButton extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder( - builder: (context, systemHealthState) { - // Determine if system clock is valid - final isSystemClockValid = systemHealthState is SystemHealthLoadSuccess && - systemHealthState.isValid; + builder: (context, systemHealthState) { + final isSystemClockValid = + systemHealthState is SystemHealthLoadSuccess && + systemHealthState.isValid; - final tradingStatusState = context.watch().state; - final tradingEnabled = tradingStatusState.isEnabled; + final coinsRepo = RepositoryProvider.of(context); - return BlocSelector( - selector: (state) => state.inProgress, - builder: (context, inProgress) { + return BlocBuilder( + builder: (context, bridgeState) { + final tradingStatusState = context.watch().state; + final targetCoin = bridgeState.bestOrder == null + ? null + : coinsRepo.getCoin(bridgeState.bestOrder!.coin); + final tradingEnabled = tradingStatusState.canTradeAssets([ + bridgeState.sellCoin?.id, + targetCoin?.id, + ]); + + final inProgress = bridgeState.inProgress; final isDisabled = inProgress || !isSystemClockValid; + return SizedBox( width: theme.custom.dexFormWidth, child: ConnectWalletWrapper( @@ -151,8 +155,10 @@ class _ExchangeButton extends StatelessWidget { ), ), ); - }); - }); + }, + ); + }, + ); } void _onPressed(BuildContext context) { diff --git a/lib/views/bridge/bridge_page.dart b/lib/views/bridge/bridge_page.dart index 6f8b54fe8c..32456a02d8 100644 --- a/lib/views/bridge/bridge_page.dart +++ b/lib/views/bridge/bridge_page.dart @@ -16,6 +16,7 @@ import 'package:web_dex/views/common/pages/page_layout.dart'; import 'package:web_dex/views/dex/entities_list/history/history_list.dart'; import 'package:web_dex/views/dex/entities_list/in_progress/in_progress_list.dart'; import 'package:web_dex/views/dex/entity_details/trading_details.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart'; class BridgePage extends StatefulWidget { const BridgePage() : super(key: const Key('bridge-page')); @@ -50,17 +51,21 @@ class _BridgePageState extends State with TickerProviderStateMixin { }); } }, - child: Builder(builder: (context) { - final page = _showSwap ? _buildTradingDetails() : _buildBridgePage(); - return page; - }), + child: ZhtlcConfigurationHandler( + child: Builder( + builder: (context) { + final page = _showSwap + ? _buildTradingDetails() + : _buildBridgePage(); + return page; + }, + ), + ), ); } Widget _buildTradingDetails() { - return TradingDetails( - uuid: routingState.bridgeState.uuid, - ); + return TradingDetails(uuid: routingState.bridgeState.uuid); } Widget _buildBridgePage() { @@ -78,8 +83,9 @@ class _BridgePageState extends State with TickerProviderStateMixin { crossAxisAlignment: CrossAxisAlignment.center, children: [ ConstrainedBox( - constraints: - BoxConstraints(maxWidth: theme.custom.dexFormWidth), + constraints: BoxConstraints( + maxWidth: theme.custom.dexFormWidth, + ), child: HiddenWithoutWallet( child: BridgeTabBar( currentTabIndex: _activeTabIndex, @@ -91,11 +97,7 @@ class _BridgePageState extends State with TickerProviderStateMixin { padding: EdgeInsets.only(top: 12.0), child: ClockWarningBanner(), ), - Flexible( - child: _TabContent( - activeTabIndex: _activeTabIndex, - ), - ), + Flexible(child: _TabContent(activeTabIndex: _activeTabIndex)), ], ), ), @@ -128,7 +130,7 @@ class _BridgePageState extends State with TickerProviderStateMixin { class _TabContent extends StatelessWidget { final int _activeTabIndex; const _TabContent({required int activeTabIndex}) - : _activeTabIndex = activeTabIndex; + : _activeTabIndex = activeTabIndex; @override Widget build(BuildContext context) { @@ -137,7 +139,9 @@ class _TabContent extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 20), child: InProgressList( - filter: _bridgeSwapsFilter, onItemClick: _onSwapItemClick), + filter: _bridgeSwapsFilter, + onItemClick: _onSwapItemClick, + ), ), Padding( padding: const EdgeInsets.only(top: 20), diff --git a/lib/views/bridge/bridge_protocol_label.dart b/lib/views/bridge/bridge_protocol_label.dart index 8790b1d1e9..575a56c29d 100644 --- a/lib/views/bridge/bridge_protocol_label.dart +++ b/lib/views/bridge/bridge_protocol_label.dart @@ -1,10 +1,8 @@ import 'package:app_theme/app_theme.dart'; import 'package:flutter/material.dart'; import 'package:komodo_ui/komodo_ui.dart' show AssetIcon; -import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_type.dart'; -import 'package:web_dex/model/coin_utils.dart'; import 'package:web_dex/shared/utils/utils.dart'; class BridgeProtocolLabel extends StatelessWidget { @@ -49,7 +47,7 @@ class BridgeProtocolLabel extends StatelessWidget { return Text( coin.type == CoinType.utxo ? coin.abbr - : getCoinTypeName(coin.type).toUpperCase(), + : coin.typeName.toUpperCase(), style: TextStyle( color: ThemeData.estimateBrightnessForColor(protocolColor) == Brightness.dark diff --git a/lib/views/bridge/bridge_tickers_list_item.dart b/lib/views/bridge/bridge_tickers_list_item.dart index 060a745070..19a6229a33 100644 --- a/lib/views/bridge/bridge_tickers_list_item.dart +++ b/lib/views/bridge/bridge_tickers_list_item.dart @@ -33,23 +33,21 @@ class BridgeTickersListItem extends StatelessWidget { width: 30, alignment: const Alignment(0, 0), decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(15)), - child: AssetIcon.ofTicker( - coin.abbr, - size: 26, + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(15), ), + child: AssetIcon.ofTicker(coin.abbr, size: 26), ), - const SizedBox( - width: 4, - ), + const SizedBox(width: 4), Expanded( child: AutoScrollText( - text: coin.name, + text: coin.displayName, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, fontSize: 14), + fontWeight: FontWeight.w500, + fontSize: 14, + ), ), - ) + ), ], ), ), diff --git a/lib/views/bridge/view/table/bridge_source_protocols_table.dart b/lib/views/bridge/view/table/bridge_source_protocols_table.dart index eebb8d8176..3712b88188 100644 --- a/lib/views/bridge/view/table/bridge_source_protocols_table.dart +++ b/lib/views/bridge/view/table/bridge_source_protocols_table.dart @@ -11,6 +11,7 @@ import 'package:web_dex/views/bridge/bridge_group.dart'; import 'package:web_dex/views/bridge/view/table/bridge_nothing_found.dart'; import 'package:web_dex/views/bridge/view/table/bridge_protocol_table_item.dart'; import 'package:web_dex/views/bridge/view/table/bridge_table_column_heads.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; class BridgeSourceProtocolsTable extends StatefulWidget { const BridgeSourceProtocolsTable({ @@ -82,7 +83,11 @@ class _SourceProtocolItems extends StatelessWidget { @override Widget build(BuildContext context) { - if (coins.isEmpty) return BridgeNothingFound(); + final tradingState = context.watch().state; + final filteredCoins = coins + .where((coin) => tradingState.canTradeAssets([coin.id])) + .toList(); + if (filteredCoins.isEmpty) return BridgeNothingFound(); final scrollController = ScrollController(); return Column( @@ -99,9 +104,9 @@ class _SourceProtocolItems extends StatelessWidget { controller: scrollController, padding: EdgeInsets.zero, shrinkWrap: true, - itemCount: coins.length, + itemCount: filteredCoins.length, itemBuilder: (BuildContext context, int index) { - final Coin coin = coins[index]; + final Coin coin = filteredCoins[index]; return BridgeProtocolTableItem( index: index, diff --git a/lib/views/bridge/view/table/bridge_target_protocols_table.dart b/lib/views/bridge/view/table/bridge_target_protocols_table.dart index 0505c5f8da..ec0d461b68 100644 --- a/lib/views/bridge/view/table/bridge_target_protocols_table.dart +++ b/lib/views/bridge/view/table/bridge_target_protocols_table.dart @@ -16,6 +16,7 @@ import 'package:web_dex/views/bridge/bridge_group.dart'; import 'package:web_dex/views/bridge/view/table/bridge_nothing_found.dart'; import 'package:web_dex/views/bridge/view/table/bridge_protocol_table_order_item.dart'; import 'package:web_dex/views/bridge/view/table/bridge_table_column_heads.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; class BridgeTargetProtocolsTable extends StatefulWidget { const BridgeTargetProtocolsTable({ @@ -114,6 +115,15 @@ class _TargetProtocolItems extends StatelessWidget { final scrollController = ScrollController(); final coinsRepository = RepositoryProvider.of(context); + final tradingState = context.watch().state; + final filteredTargets = targetsList.where((order) { + final Coin? coin = coinsRepository.getCoin(order.coin); + if (coin == null) return false; + return tradingState.canTradeAssets([sellCoin.id, coin.id]); + }).toList(); + + if (filteredTargets.isEmpty) return BridgeNothingFound(); + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -128,7 +138,7 @@ class _TargetProtocolItems extends StatelessWidget { controller: scrollController, shrinkWrap: true, itemBuilder: (BuildContext context, int index) { - final BestOrder order = targetsList[index]; + final BestOrder order = filteredTargets[index]; final Coin coin = coinsRepository.getCoin(order.coin)!; return BridgeProtocolTableOrderItem( @@ -138,7 +148,7 @@ class _TargetProtocolItems extends StatelessWidget { onSelect: () => onSelect(order), ); }, - itemCount: targetsList.length, + itemCount: filteredTargets.length, ), ), ), @@ -173,11 +183,12 @@ class _TargetProtocolErrorMessage extends StatelessWidget { const Icon(Icons.warning_amber, size: 14, color: Colors.orange), const SizedBox(width: 4), Flexible( - child: SelectableText( - error.message, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - )), + child: SelectableText( + error.message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), const SizedBox(height: 4), UiSimpleButton( onPressed: onRetry, @@ -185,7 +196,7 @@ class _TargetProtocolErrorMessage extends StatelessWidget { LocaleKeys.retryButtonText.tr(), style: Theme.of(context).textTheme.bodySmall, ), - ) + ), ], ), ], diff --git a/lib/views/bridge/view/table/bridge_tickers_list.dart b/lib/views/bridge/view/table/bridge_tickers_list.dart index 7ae3e0a1f9..1e56186c2d 100644 --- a/lib/views/bridge/view/table/bridge_tickers_list.dart +++ b/lib/views/bridge/view/table/bridge_tickers_list.dart @@ -15,12 +15,10 @@ import 'package:web_dex/shared/ui/ui_flat_button.dart'; import 'package:web_dex/views/bridge/bridge_ticker_selector.dart'; import 'package:web_dex/views/bridge/bridge_tickers_list_item.dart'; 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, Key? key}) : super(key: key); final Function(Coin) onSelect; @@ -53,7 +51,7 @@ class _BridgeTickersListState extends State { spreadRadius: 0, blurRadius: 4, offset: const Offset(0, 4), - ) + ), ], ), child: Column( @@ -86,9 +84,9 @@ class _BridgeTickersListState extends State { UiFlatButton( text: LocaleKeys.close.tr(), height: 40, - onPressed: () => context - .read() - .add(const BridgeShowTickerDropdown(false)), + onPressed: () => context.read().add( + const BridgeShowTickerDropdown(false), + ), ), ], ), @@ -104,12 +102,20 @@ class _BridgeTickersListState extends State { builder: (context, tickers) { if (tickers == null) return const UiSpinnerList(); - final Coins coinsList = - tickers.entries.fold([], (previousValue, element) { + final tradingState = context.watch().state; + + var coinsList = tickers.entries.fold([], ( + previousValue, + element, + ) { previousValue.add(element.value.first); return previousValue; }); + coinsList = coinsList + .where((coin) => tradingState.canTradeAssets([coin.id])) + .toList(); + if (_searchTerm != null && _searchTerm!.isNotEmpty) { final String searchTerm = _searchTerm!.toLowerCase(); coinsList.removeWhere((t) { diff --git a/lib/views/common/header/actions/header_actions.dart b/lib/views/common/header/actions/header_actions.dart deleted file mode 100644 index 11edff5779..0000000000 --- a/lib/views/common/header/actions/header_actions.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -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/cex_market_data/portfolio_growth/portfolio_growth_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/release_options.dart'; -import 'package:web_dex/shared/utils/formatters.dart'; -import 'package:web_dex/shared/utils/utils.dart'; -import 'package:web_dex/views/common/header/actions/account_switcher.dart'; - -const EdgeInsets headerActionsPadding = EdgeInsets.fromLTRB(38, 18, 0, 0); -final _languageCodes = localeList.map((e) => e.languageCode).toList(); -final _langCode2flags = { - for (var loc in _languageCodes) - loc: SvgPicture.asset( - '$assetsPath/flags/$loc.svg', - ), -}; -List? getHeaderActions(BuildContext context) { - return [ - if (showLanguageSwitcher) - Padding( - padding: headerActionsPadding, - child: LanguageSwitcher( - currentLocale: context.locale.toString(), - languageCodes: _languageCodes, - flags: _langCode2flags, - ), - ), - Padding( - padding: headerActionsPadding, - child: BlocBuilder( - builder: (context, pgState) { - final coins = context.select>( - (bloc) => bloc.state.walletCoins.values, - ); - final totalBalance = pgState is PortfolioGrowthChartLoadSuccess - ? pgState.totalBalance - : _getTotalBalance(coins, context); - - return ActionTextButton( - text: LocaleKeys.balance.tr(), - secondaryText: '\$${formatAmt(totalBalance)}', - onTap: null, - ); - }, - ), - ), - const Padding( - padding: headerActionsPadding, - child: AccountSwitcher(), - ), - if (!isWideScreen) const SizedBox(width: mainLayoutPadding), - ]; -} - -double _getTotalBalance(Iterable coins, BuildContext context) { - 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; -} diff --git a/lib/views/common/header/app_header.dart b/lib/views/common/header/app_header.dart deleted file mode 100644 index fdcf792da1..0000000000 --- a/lib/views/common/header/app_header.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:app_theme/app_theme.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:web_dex/app_config/app_config.dart'; -import 'package:web_dex/common/screen.dart'; -import 'package:web_dex/model/main_menu_value.dart'; -import 'package:web_dex/router/state/routing_state.dart'; -import 'package:web_dex/shared/utils/utils.dart'; -import 'package:web_dex/views/common/header/actions/header_actions.dart'; - -class AppHeader extends StatefulWidget { - const AppHeader({Key? key}) : super(key: key); - - @override - State createState() => _AppHeaderState(); -} - -class _AppHeaderState extends State { - @override - Widget build(BuildContext context) { - return Container( - color: theme.currentGlobal.colorScheme.surface, - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: maxScreenWidth, - ), - child: AppBar( - centerTitle: false, - titleSpacing: 0, - title: _buildTitle(), - elevation: 0, - actions: getHeaderActions(context), - backgroundColor: Colors.transparent, - ), - ), - )); - } - - Widget _buildTitle() { - return Container( - padding: isWideScreen - ? const EdgeInsets.fromLTRB(12, 14, 0, 0) - : const EdgeInsets.fromLTRB(mainLayoutPadding + 12, 14, 0, 0), - child: InkWell( - hoverColor: theme.custom.noColor, - splashColor: theme.custom.noColor, - highlightColor: theme.custom.noColor, - onTap: () { - routingState.selectedMenu = MainMenuValue.wallet; - }, - child: SvgPicture.asset( - '$assetsPath/logo/logo$themeAssetPostfix.svg', - ), - ), - ); - } -} diff --git a/lib/views/common/hw_wallet_dialog/show_trezor_passphrase_dialog.dart b/lib/views/common/hw_wallet_dialog/show_trezor_passphrase_dialog.dart index 50d3508173..adb1a8e894 100644 --- a/lib/views/common/hw_wallet_dialog/show_trezor_passphrase_dialog.dart +++ b/lib/views/common/hw_wallet_dialog/show_trezor_passphrase_dialog.dart @@ -5,6 +5,7 @@ import 'package:web_dex/common/screen.dart'; import 'package:web_dex/dispatchers/popup_dispatcher.dart'; import 'package:web_dex/model/hw_wallet/trezor_task.dart'; import 'package:web_dex/views/common/hw_wallet_dialog/constants.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; import 'trezor_steps/trezor_dialog_select_wallet.dart'; @@ -23,12 +24,14 @@ Future showTrezorPassphraseDialog(TrezorTask task) async { context: context, width: trezorDialogWidth, onDismiss: close, - popupContent: TrezorDialogSelectWallet( - onComplete: (String passphrase) async { - final authBloc = context.read(); - authBloc.add(AuthTrezorPassphraseProvided(passphrase)); - close(); - }, + popupContent: ScreenshotSensitive( + child: TrezorDialogSelectWallet( + onComplete: (String passphrase) async { + final authBloc = context.read(); + authBloc.add(AuthTrezorPassphraseProvided(passphrase)); + close(); + }, + ), ), ); diff --git a/lib/views/common/hw_wallet_dialog/show_trezor_pin_dialog.dart b/lib/views/common/hw_wallet_dialog/show_trezor_pin_dialog.dart index 58899febe7..0d970e871b 100644 --- a/lib/views/common/hw_wallet_dialog/show_trezor_pin_dialog.dart +++ b/lib/views/common/hw_wallet_dialog/show_trezor_pin_dialog.dart @@ -6,6 +6,7 @@ import 'package:web_dex/dispatchers/popup_dispatcher.dart'; import 'package:web_dex/model/hw_wallet/trezor_task.dart'; import 'package:web_dex/views/common/hw_wallet_dialog/constants.dart'; import 'package:web_dex/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_pin_pad.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; Future showTrezorPinDialog(TrezorTask task) async { late PopupDispatcher popupManager; @@ -22,16 +23,18 @@ Future showTrezorPinDialog(TrezorTask task) async { context: context, width: trezorDialogWidth, onDismiss: close, - popupContent: TrezorDialogPinPad( - onComplete: (String pin) async { - final authBloc = context.read(); - authBloc.add(AuthTrezorPinProvided(pin)); - close(); - }, - onClose: () { - context.read().add(AuthTrezorCancelled()); - close(); - }, + popupContent: ScreenshotSensitive( + child: TrezorDialogPinPad( + onComplete: (String pin) async { + final authBloc = context.read(); + authBloc.add(AuthTrezorPinProvided(pin)); + close(); + }, + onClose: () { + context.read().add(AuthTrezorCancelled()); + close(); + }, + ), ), ); diff --git a/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_pin_pad.dart b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_pin_pad.dart index 8173cfbe9e..41fb70ae31 100644 --- a/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_pin_pad.dart +++ b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_pin_pad.dart @@ -6,6 +6,7 @@ import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/shared/ui/ui_light_button.dart'; import 'package:web_dex/views/common/hw_wallet_dialog/constants.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; const List> _keys = [ [7, 8, 9], @@ -44,7 +45,7 @@ class _TrezorDialogPinPadState extends State { @override Widget build(BuildContext context) { - return KeyboardListener( + return ScreenshotSensitive(child: KeyboardListener( autofocus: true, onKeyEvent: _onKeyEvent, focusNode: _focus, @@ -70,7 +71,7 @@ class _TrezorDialogPinPadState extends State { _buildButtons(), ], ), - ); + )); } Widget _buildObscuredPin() { 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 21842e4bf3..3a8e81b7d7 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 @@ -1,31 +1,30 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:web_dex/shared/constants.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; class TrezorDialogSelectWallet extends StatelessWidget { - const TrezorDialogSelectWallet({ - Key? key, - required this.onComplete, - }) : super(key: key); + const TrezorDialogSelectWallet({Key? key, required this.onComplete}) + : super(key: key); final Function(String) onComplete; @override Widget build(BuildContext context) { - return Column( + return ScreenshotSensitive(child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( LocaleKeys.selectWalletType.tr(), - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 18), - _TrezorStandardWallet( - onTap: () => onComplete(''), - ), + _TrezorStandardWallet(onTap: () => onComplete('')), const Padding( padding: EdgeInsets.symmetric(vertical: 6.0), child: UiDivider(), @@ -34,7 +33,7 @@ class TrezorDialogSelectWallet extends StatelessWidget { onSubmit: (String passphrase) => onComplete(passphrase), ), ], - ); + )); } } @@ -87,7 +86,7 @@ class _TrezorHiddenWalletState extends State<_TrezorHiddenWallet> { const SizedBox(height: 12), ConstrainedBox( constraints: const BoxConstraints(minHeight: 30), - child: _buildObscuredPassphrase(), + child: AutofillGroup(child: _buildObscuredPassphrase()), ), ], ); @@ -100,8 +99,11 @@ class _TrezorHiddenWalletState extends State<_TrezorHiddenWallet> { controller: _passphraseController, autofocus: true, hintText: LocaleKeys.passphrase.tr(), - keyboardType: TextInputType.emailAddress, + keyboardType: TextInputType.text, + autofillHints: const [AutofillHints.password], obscureText: true, + maxLength: passwordMaxLength, + counterText: '', focusNode: _passphraseFieldFocusNode, onFieldSubmitted: (_) => _onSubmit(), validator: (String? text) { @@ -121,7 +123,8 @@ class _TrezorHiddenWalletState extends State<_TrezorHiddenWallet> { _passphraseFieldFocusNode.requestFocus(); return; } - + // We deliberately do not save passphrases + TextInput.finishAutofillContext(shouldSave: false); widget.onSubmit(_passphraseController.text); } @@ -153,24 +156,23 @@ class _TrezorWalletItem extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon( - icon, - size: 36.0, - ), + Icon(icon, size: 36.0), const SizedBox(width: 24), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, - style: theme.textTheme.bodyLarge - ?.copyWith(color: theme.textTheme.bodySmall?.color), + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.textTheme.bodySmall?.color, + ), ), const SizedBox(height: 6), Text( description, - style: theme.textTheme.bodySmall - ?.copyWith(color: theme.textTheme.bodyLarge?.color), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.textTheme.bodyLarge?.color, + ), ), ], ), 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 516ab5da8c..1cb66a7110 100644 --- a/lib/views/common/main_menu/main_menu_bar_mobile.dart +++ b/lib/views/common/main_menu/main_menu_bar_mobile.dart @@ -21,8 +21,10 @@ class MainMenuBarMobile extends StatelessWidget { return BlocBuilder( builder: (context, state) { final bool isMMBotEnabled = state.mmBotSettings.isMMBotEnabled; - final bool tradingEnabled = - context.watch().state is TradingEnabled; + final bool tradingEnabled = context + .watch() + .state + .isEnabled; return DecoratedBox( decoration: BoxDecoration( color: theme.currentGlobal.cardColor, diff --git a/lib/views/common/main_menu/main_menu_desktop.dart b/lib/views/common/main_menu/main_menu_desktop.dart index b68cf0b8d4..4ae6450582 100644 --- a/lib/views/common/main_menu/main_menu_desktop.dart +++ b/lib/views/common/main_menu/main_menu_desktop.dart @@ -26,8 +26,9 @@ class MainMenuDesktop extends StatefulWidget { class _MainMenuDesktopState extends State { @override Widget build(BuildContext context) { - final isAuthenticated = context - .select((AuthBloc bloc) => bloc.state.mode == AuthorizeMode.logIn); + final isAuthenticated = context.select( + (AuthBloc bloc) => bloc.state.mode == AuthorizeMode.logIn, + ); return BlocBuilder( builder: (context, state) { @@ -36,8 +37,10 @@ class _MainMenuDesktopState extends State { final bool isDarkTheme = settingsState.themeMode == ThemeMode.dark; final bool isMMBotEnabled = settingsState.mmBotSettings.isMMBotEnabled; - final bool tradingEnabled = - context.watch().state is TradingEnabled; + final bool tradingEnabled = context + .watch() + .state + .isEnabled; final SettingsBloc settings = context.read(); final currentWallet = state.currentUser?.wallet; @@ -76,16 +79,18 @@ class _MainMenuDesktopState extends State { key: const Key('main-menu-wallet'), menu: MainMenuValue.wallet, onTap: onTapItem, - isSelected: - _checkSelectedItem(MainMenuValue.wallet), + isSelected: _checkSelectedItem( + MainMenuValue.wallet, + ), ), DesktopMenuDesktopItem( key: const Key('main-menu-fiat'), enabled: currentWallet?.isHW != true, menu: MainMenuValue.fiat, onTap: onTapItem, - isSelected: - _checkSelectedItem(MainMenuValue.fiat), + isSelected: _checkSelectedItem( + MainMenuValue.fiat, + ), ), Tooltip( message: tradingEnabled @@ -96,8 +101,9 @@ class _MainMenuDesktopState extends State { enabled: currentWallet?.isHW != true, menu: MainMenuValue.dex, onTap: onTapItem, - isSelected: - _checkSelectedItem(MainMenuValue.dex), + isSelected: _checkSelectedItem( + MainMenuValue.dex, + ), ), ), Tooltip( @@ -109,8 +115,9 @@ class _MainMenuDesktopState extends State { enabled: currentWallet?.isHW != true, menu: MainMenuValue.bridge, onTap: onTapItem, - isSelected: - _checkSelectedItem(MainMenuValue.bridge), + isSelected: _checkSelectedItem( + MainMenuValue.bridge, + ), ), ), if (isMMBotEnabled && isAuthenticated) @@ -124,16 +131,17 @@ class _MainMenuDesktopState extends State { menu: MainMenuValue.marketMakerBot, onTap: onTapItem, isSelected: _checkSelectedItem( - MainMenuValue.marketMakerBot), + MainMenuValue.marketMakerBot, + ), ), ), DesktopMenuDesktopItem( - key: const Key('main-menu-nft'), - enabled: currentWallet?.isHW != true, - menu: MainMenuValue.nft, - onTap: onTapItem, - isSelected: - _checkSelectedItem(MainMenuValue.nft)), + key: const Key('main-menu-nft'), + enabled: currentWallet?.isHW != true, + menu: MainMenuValue.nft, + onTap: onTapItem, + isSelected: _checkSelectedItem(MainMenuValue.nft), + ), const Spacer(), Divider(thickness: 1), DesktopMenuDesktopItem( @@ -142,8 +150,9 @@ class _MainMenuDesktopState extends State { onTap: onTapItem, needAttention: currentWallet?.config.hasBackup == false, - isSelected: - _checkSelectedItem(MainMenuValue.settings), + isSelected: _checkSelectedItem( + MainMenuValue.settings, + ), ), ], ), @@ -156,31 +165,33 @@ class _MainMenuDesktopState extends State { padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), child: Theme( data: isDarkTheme ? newThemeDark : newThemeLight, - child: Builder(builder: (context) { - final ColorSchemeExtension colorScheme = - Theme.of(context) - .extension()!; - return DexThemeSwitcher( - isDarkTheme: isDarkTheme, - lightThemeTitle: LocaleKeys.lightMode.tr(), - darkThemeTitle: LocaleKeys.darkMode.tr(), - buttonKeyValue: 'theme-switcher', - onThemeModeChanged: (mode) { - settings.add( - ThemeModeChanged( - mode: isDarkTheme - ? ThemeMode.light - : ThemeMode.dark, - ), - ); - }, - switcherStyle: DexThemeSwitcherStyle( - textColor: colorScheme.primary, - thumbBgColor: colorScheme.surfContLow, - switcherBgColor: colorScheme.p10, - ), - ); - }), + child: Builder( + builder: (context) { + final ColorSchemeExtension colorScheme = Theme.of( + context, + ).extension()!; + return DexThemeSwitcher( + isDarkTheme: isDarkTheme, + lightThemeTitle: LocaleKeys.lightMode.tr(), + darkThemeTitle: LocaleKeys.darkMode.tr(), + buttonKeyValue: 'theme-switcher', + onThemeModeChanged: (mode) { + settings.add( + ThemeModeChanged( + mode: isDarkTheme + ? ThemeMode.light + : ThemeMode.dark, + ), + ); + }, + switcherStyle: DexThemeSwitcherStyle( + textColor: colorScheme.primary, + thumbBgColor: colorScheme.surfContLow, + switcherBgColor: colorScheme.p10, + ), + ); + }, + ), ), ), ], diff --git a/lib/views/common/wallet_password_dialog/password_dialog_content.dart b/lib/views/common/wallet_password_dialog/password_dialog_content.dart index cbfea49b72..ad91859559 100644 --- a/lib/views/common/wallet_password_dialog/password_dialog_content.dart +++ b/lib/views/common/wallet_password_dialog/password_dialog_content.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:web_dex/shared/constants.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'; @@ -60,11 +61,8 @@ class _PasswordDialogContentState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Hidden username field for password manager context - Visibility( - visible: false, - maintainSize: false, - maintainAnimation: false, - maintainState: false, + Offstage( + offstage: true, child: UiTextFormField( initialValue: widget.wallet?.name ?? '', autofillHints: const [AutofillHints.username], @@ -77,7 +75,8 @@ class _PasswordDialogContentState extends State { autofocus: true, autocorrect: false, obscureText: _isObscured, - inputFormatters: [LengthLimitingTextInputFormatter(40)], + maxLength: passwordMaxLength, + counterText: '', errorMaxLines: 6, errorText: _error, hintText: LocaleKeys.enterThePassword.tr(), @@ -152,6 +151,8 @@ class _PasswordDialogContentState extends State { } if (mounted) { + // Finish autofill context to allow password managers to record usage + TextInput.finishAutofillContext(shouldSave: false); widget.onSuccess(password); setState(() => _inProgress = false); } @@ -263,11 +264,8 @@ class _PasswordDialogContentWithLoadingState crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Hidden username field for password manager context - Visibility( - visible: false, - maintainSize: false, - maintainAnimation: false, - maintainState: false, + Offstage( + offstage: true, child: UiTextFormField( initialValue: widget.wallet?.name ?? '', autofillHints: const [AutofillHints.username], @@ -280,7 +278,8 @@ class _PasswordDialogContentWithLoadingState autofocus: true, autocorrect: false, obscureText: _isObscured, - inputFormatters: [LengthLimitingTextInputFormatter(40)], + maxLength: passwordMaxLength, + counterText: '', errorMaxLines: 6, errorText: _error, hintText: LocaleKeys.enterThePassword.tr(), @@ -370,6 +369,8 @@ class _PasswordDialogContentWithLoadingState try { final success = await widget.onPasswordValidated(password); if (mounted) { + // Not saving new credentials here; just closing context + TextInput.finishAutofillContext(shouldSave: false); widget.onComplete(success); } } catch (e) { diff --git a/lib/views/common/wallet_password_dialog/wallet_password_dialog.dart b/lib/views/common/wallet_password_dialog/wallet_password_dialog.dart index c4f99b5a59..5185c3f1ac 100644 --- a/lib/views/common/wallet_password_dialog/wallet_password_dialog.dart +++ b/lib/views/common/wallet_password_dialog/wallet_password_dialog.dart @@ -4,6 +4,7 @@ import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; import 'package:web_dex/dispatchers/popup_dispatcher.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/views/common/wallet_password_dialog/password_dialog_content.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; // Shows wallet password dialog and // returns password value or null (if wrong or cancelled) @@ -24,14 +25,15 @@ Future walletPasswordDialog( popupManager = PopupDispatcher( context: context, - popupContent: PasswordDialogContent( + popupContent: ScreenshotSensitive( + child: PasswordDialogContent( wallet: wallet, onSuccess: (String pass) { password = pass; close(); }, onCancel: close, - ), + )), ); isOpen = true; @@ -72,7 +74,8 @@ Future walletPasswordDialogWithLoading( popupManager = PopupDispatcher( context: context, - popupContent: PasswordDialogContentWithLoading( + popupContent: ScreenshotSensitive( + child: PasswordDialogContentWithLoading( wallet: wallet, onPasswordValidated: onPasswordValidated, onComplete: (bool success) { @@ -84,7 +87,7 @@ Future walletPasswordDialogWithLoading( loadingMessage: loadingMessage, operationFailedMessage: operationFailedMessage, passwordFieldKey: passwordFieldKey, - ), + )), ); isOpen = true; diff --git a/lib/views/dex/dex_helpers.dart b/lib/views/dex/dex_helpers.dart index 20fbc527d5..a0bb7c2b83 100644 --- a/lib/views/dex/dex_helpers.dart +++ b/lib/views/dex/dex_helpers.dart @@ -1,3 +1,4 @@ +import 'package:decimal/decimal.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -22,16 +23,17 @@ class FiatAmount extends StatelessWidget { final TextStyle? style; const FiatAmount({ - Key? key, + super.key, required this.coin, required this.amount, this.style, - }) : super(key: key); + }); @override Widget build(BuildContext context) { - final TextStyle? textStyle = - Theme.of(context).textTheme.bodySmall?.merge(style); + final TextStyle? textStyle = Theme.of( + context, + ).textTheme.bodySmall?.merge(style); return Text( getFormattedFiatAmount(context, coin.abbr, amount), @@ -53,7 +55,9 @@ String getFormattedFiatAmount( } List applyFiltersForSwap( - List swaps, TradingEntitiesFilter entitiesFilterData) { + List swaps, + TradingEntitiesFilter entitiesFilterData, +) { return swaps.where((swap) { final String? sellCoin = entitiesFilterData.sellCoin; final String? buyCoin = entitiesFilterData.buyCoin; @@ -93,7 +97,9 @@ List applyFiltersForSwap( } List applyFiltersForOrders( - List orders, TradingEntitiesFilter entitiesFilterData) { + List orders, + TradingEntitiesFilter entitiesFilterData, +) { return orders.where((order) { final String? sellCoin = entitiesFilterData.sellCoin; final String? buyCoin = entitiesFilterData.buyCoin; @@ -118,7 +124,9 @@ List applyFiltersForOrders( } Map> getCoinAbbrMapFromOrderList( - List list, bool isSellCoin) { + List list, + bool isSellCoin, +) { final Map> coinAbbrMap = isSellCoin ? list.fold>>({}, (previousValue, element) { final List coinAbbrList = previousValue[element.base] ?? []; @@ -136,7 +144,9 @@ Map> getCoinAbbrMapFromOrderList( } Map> getCoinAbbrMapFromSwapList( - List list, bool isSellCoin) { + List list, + bool isSellCoin, +) { final Map> coinAbbrMap = isSellCoin ? list.fold>>({}, (previousValue, element) { final List coinAbbrList = @@ -155,8 +165,11 @@ Map> getCoinAbbrMapFromSwapList( return coinAbbrMap; } -int getCoinPairsCountFromCoinAbbrMap(Map> coinAbbrMap, - String coinAbbr, String? secondCoinAbbr) { +int getCoinPairsCountFromCoinAbbrMap( + Map> coinAbbrMap, + String coinAbbr, + String? secondCoinAbbr, +) { return (coinAbbrMap[coinAbbr] ?? []) .where((abbr) => secondCoinAbbr == null || secondCoinAbbr == abbr) .toList() @@ -223,8 +236,11 @@ Future> activateCoinIfNeeded( // activation here await coinsRepository.activateCoinsSync([coin]); } catch (e) { - errors.add(DexFormError( - error: '${LocaleKeys.unableToActiveCoin.tr(args: [coin.abbr])}: $e')); + errors.add( + DexFormError( + error: '${LocaleKeys.unableToActiveCoin.tr(args: [coin.abbr])}: $e', + ), + ); } return errors; @@ -313,47 +329,58 @@ Rational? calculateBuyAmount({ /// print(result); // Output: "\$6.01 +0.001 BTC +0.01 ETH" /// ``` /// unit tests: [testGetTotalFee] -String getTotalFee(List? totalFeesInitial, - Coin? Function(String abbr) getCoin) { +String getTotalFee( + List? totalFeesInitial, + Coin? Function(String abbr) getCoin, +) { if (totalFeesInitial == null) return '\$0.00'; - final Map normalizedTotals = - totalFeesInitial.fold>( - {'USD': 0}, - (previousValue, fee) => _combineFees(getCoin(fee.coin), fee, previousValue), - ); + final Map normalizedTotals = totalFeesInitial + .fold>( + {'USD': Rational.zero}, + (previousValue, fee) => + _combineFees(getCoin(fee.coin), fee, previousValue), + ); - final String totalFees = - normalizedTotals.entries.fold('', _combineTotalFee); + final String totalFees = normalizedTotals.entries.fold( + '', + _combineTotalFee, + ); return totalFees; } final String _nbsp = String.fromCharCode(0x00A0); String _combineTotalFee( - String previousValue, MapEntry element) { - final double amount = element.value; + String previousValue, + MapEntry element, +) { + final Rational amount = element.value; final String coin = element.key; - if (amount == 0) return previousValue; + if (amount == Rational.zero) return previousValue; if (previousValue.isNotEmpty) previousValue += ' +$_nbsp'; if (coin == 'USD') { - previousValue += '\$${cutTrailingZeros(formatAmt(amount))}'; + previousValue += '\$${cutTrailingZeros(formatAmt(amount.toDouble()))}'; } else { previousValue += - '${cutTrailingZeros(formatAmt(amount))}$_nbsp${Coin.normalizeAbbr(coin)}'; + '${cutTrailingZeros(formatAmt(amount.toDouble()))}$_nbsp${Coin.normalizeAbbr(coin)}'; } return previousValue; } -Map _combineFees(Coin? coin, TradePreimageExtendedFeeInfo fee, - Map previousValue) { - final feeAmount = double.tryParse(fee.amount) ?? 0; - final double feeUsdAmount = feeAmount * (coin?.usdPrice?.price ?? 0); +Map _combineFees( + Coin? coin, + TradePreimageExtendedFeeInfo fee, + Map previousValue, +) { + final feeAmount = Rational.tryParse(fee.amount) ?? Rational.zero; + final feeUsdAmount = + feeAmount * (coin?.usdPrice?.price ?? Decimal.zero).toRational(); - if (feeUsdAmount > 0) { + if (feeUsdAmount > Rational.zero) { previousValue['USD'] = previousValue['USD']! + feeUsdAmount; - } else if (feeAmount > 0) { + } else if (feeAmount > Rational.zero) { previousValue[fee.coin] = feeAmount; } return previousValue; @@ -399,7 +426,10 @@ Rational getFractionOfAmount(Rational amount, double fraction) { /// print(result); // Output: (200, 2) /// ``` (Rational?, Rational?)? processBuyAmountAndPrice( - Rational? sellAmount, Rational? price, Rational? buyAmount) { + Rational? sellAmount, + Rational? price, + Rational? buyAmount, +) { if (sellAmount == null) return null; if (price == null && buyAmount == null) return null; if (price != null) { diff --git a/lib/views/dex/dex_page.dart b/lib/views/dex/dex_page.dart index 0224ea28cb..5acf92bf94 100644 --- a/lib/views/dex/dex_page.dart +++ b/lib/views/dex/dex_page.dart @@ -17,6 +17,7 @@ import 'package:web_dex/views/common/pages/page_layout.dart'; import 'package:web_dex/views/dex/dex_tab_bar.dart'; import 'package:web_dex/views/dex/entities_list/dex_list_wrapper.dart'; import 'package:web_dex/views/dex/entity_details/trading_details.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart'; class DexPage extends StatefulWidget { const DexPage({super.key}); @@ -42,8 +43,9 @@ class _DexPageState 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); @@ -66,13 +68,11 @@ class _DexPageState extends State { ? TradingDetails(uuid: routingState.dexState.uuid) : _DexContent(), ); - return pageContent; + return ZhtlcConfigurationHandler(child: pageContent); } void _onRouteChange() { - setState( - () => isTradingDetails = routingState.dexState.isTradingDetails, - ); + setState(() => isTradingDetails = routingState.dexState.isTradingDetails); } } diff --git a/lib/views/dex/entity_details/trading_details.dart b/lib/views/dex/entity_details/trading_details.dart index 1398d771c6..30e6b15e3c 100644 --- a/lib/views/dex/entity_details/trading_details.dart +++ b/lib/views/dex/entity_details/trading_details.dart @@ -3,16 +3,16 @@ import 'dart:async'; 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/analytics/analytics_factory.dart'; import 'package:web_dex/bloc/dex_repository.dart'; import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; import 'package:web_dex/analytics/events/transaction_events.dart'; +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/model/wallet.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'; import 'package:web_dex/views/dex/entity_details/maker_order/maker_order_details_page.dart'; @@ -56,7 +56,8 @@ class _TradingDetailsState extends State { @override Widget build(BuildContext context) { - final dynamic entityStatus = _swapStatus ?? + final dynamic entityStatus = + _swapStatus ?? _orderStatus?.takerOrderStatus ?? _orderStatus?.makerOrderStatus; @@ -67,14 +68,16 @@ class _TradingDetailsState extends State { isMobile: isMobile, child: SingleChildScrollView( controller: scrollController, - child: Builder(builder: (context) { - return Padding( - padding: isMobile - ? const EdgeInsets.all(0) - : const EdgeInsets.fromLTRB(15, 23, 15, 20), - child: _getDetailsPage(entityStatus), - ); - }), + child: Builder( + builder: (context) { + return Padding( + padding: isMobile + ? const EdgeInsets.all(0) + : const EdgeInsets.fromLTRB(15, 23, 15, 20), + child: _getDetailsPage(entityStatus), + ); + }, + ), ), ); } @@ -116,8 +119,9 @@ class _TradingDetailsState extends State { swapStatus = null; } - final OrderStatus? orderStatus = - await myOrdersService.getStatus(widget.uuid); + final OrderStatus? orderStatus = await myOrdersService.getStatus( + widget.uuid, + ); if (!mounted) return; setState(() { @@ -127,14 +131,14 @@ class _TradingDetailsState extends State { if (swapStatus != null) { final authBloc = context.read(); - final walletType = authBloc.state.currentUser?.wallet.config.type.name; + final walletType = authBloc.state.currentUser?.type; final fromAsset = swapStatus.sellCoin; final toAsset = swapStatus.buyCoin; final int? durationMs = swapStatus.events.isNotEmpty && swapStatus.myInfo != null - ? swapStatus.events.last.timestamp - - swapStatus.myInfo!.startedAt * 1000 - : null; + ? swapStatus.events.last.timestamp - + swapStatus.myInfo!.startedAt * 1000 + : null; if (swapStatus.isSuccessful && !_loggedSuccess) { _loggedSuccess = true; // Find trade fee from events @@ -151,57 +155,65 @@ class _TradingDetailsState extends State { break; } } - context.read().logEvent( - SwapSucceededEventData( - fromAsset: fromAsset, - toAsset: toAsset, - amount: swapStatus.sellAmount.toDouble(), - fee: fee, - walletType: walletType ?? 'unknown', - durationMs: durationMs, - ), - ); - final coinsRepo = RepositoryProvider.of(context); + final fromNetwork = + coinsRepo.getCoin(fromAsset)?.protocolType ?? 'unknown'; + final toNetwork = coinsRepo.getCoin(toAsset)?.protocolType ?? 'unknown'; + context.read().logEvent( + SwapSucceededEventData( + asset: fromAsset, + secondaryAsset: toAsset, + network: fromNetwork, + secondaryNetwork: toNetwork, + amount: swapStatus.sellAmount.toDouble(), + fee: fee, + hdType: walletType ?? 'unknown', + durationMs: durationMs, + ), + ); if (swapStatus.isTheSameTicker) { - final fromChain = - coinsRepo.getCoin(fromAsset)?.protocolType ?? 'unknown'; - final toChain = coinsRepo.getCoin(toAsset)?.protocolType ?? 'unknown'; context.read().logEvent( - AnalyticsEvents.bridgeSuccess( - fromChain: fromChain, - toChain: toChain, - asset: fromAsset, - amount: swapStatus.sellAmount.toDouble(), - durationMs: durationMs, - ), - ); + BridgeSucceededEventData( + asset: fromAsset, + secondaryAsset: toAsset, + network: fromNetwork, + secondaryNetwork: toNetwork, + amount: swapStatus.sellAmount.toDouble(), + hdType: walletType ?? 'unknown', + durationMs: durationMs, + ), + ); } } else if (swapStatus.isFailed && !_loggedFailure) { _loggedFailure = true; - context.read().logEvent( - SwapFailedEventData( - fromAsset: fromAsset, - toAsset: toAsset, - failStage: swapStatus.status.name, - walletType: walletType ?? 'unknown', - durationMs: durationMs, - ), - ); - final coinsRepo = RepositoryProvider.of(context); + final fromNetwork = + coinsRepo.getCoin(fromAsset)?.protocolType ?? 'unknown'; + final toNetwork = coinsRepo.getCoin(toAsset)?.protocolType ?? 'unknown'; + context.read().logEvent( + SwapFailedEventData( + asset: fromAsset, + secondaryAsset: toAsset, + network: fromNetwork, + secondaryNetwork: toNetwork, + failureStage: swapStatus.status.name, + hdType: walletType ?? 'unknown', + durationMs: durationMs, + ), + ); if (swapStatus.isTheSameTicker) { - final fromChain = - coinsRepo.getCoin(fromAsset)?.protocolType ?? 'unknown'; - final toChain = coinsRepo.getCoin(toAsset)?.protocolType ?? 'unknown'; context.read().logEvent( - AnalyticsEvents.bridgeFailure( - fromChain: fromChain, - toChain: toChain, - failError: swapStatus.status.name, - durationMs: durationMs, - ), - ); + BridgeFailedEventData( + asset: fromAsset, + secondaryAsset: toAsset, + network: fromNetwork, + secondaryNetwork: toNetwork, + failureStage: swapStatus.status.name, + failureDetail: swapStatus.status.name, + hdType: walletType ?? 'unknown', + durationMs: durationMs, + ), + ); } } } diff --git a/lib/views/dex/orderbook/orderbook_error_message.dart b/lib/views/dex/orderbook/orderbook_error_message.dart index ac13b32973..e7e9912a3d 100644 --- a/lib/views/dex/orderbook/orderbook_error_message.dart +++ b/lib/views/dex/orderbook/orderbook_error_message.dart @@ -1,17 +1,16 @@ 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/mm2/mm2_api/rpc/orderbook/orderbook_response.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; class OrderbookErrorMessage extends StatefulWidget { const OrderbookErrorMessage( - this.response, { + this.errorMessage, { Key? key, required this.onReloadClick, }) : super(key: key); - final OrderbookResponse response; + final String errorMessage; final VoidCallback onReloadClick; @override @@ -23,8 +22,8 @@ class _OrderbookErrorMessageState extends State { @override Widget build(BuildContext context) { - final String? error = widget.response.error; - if (error == null) return const SizedBox.shrink(); + final String error = widget.errorMessage; + if (error.isEmpty) return const SizedBox.shrink(); return Center( child: Column( @@ -45,25 +44,24 @@ class _OrderbookErrorMessageState extends State { ), const SizedBox(width: 8), InkWell( - onTap: () => setState(() => _isExpanded = !_isExpanded), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - _isExpanded - ? LocaleKeys.close.tr() - : LocaleKeys.details.tr(), - style: const TextStyle(fontSize: 12), - ), - Icon( - _isExpanded - ? Icons.arrow_drop_up - : Icons.arrow_drop_down, - size: 16, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), - ], - )), + onTap: () => setState(() => _isExpanded = !_isExpanded), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _isExpanded + ? LocaleKeys.close.tr() + : LocaleKeys.details.tr(), + style: const TextStyle(fontSize: 12), + ), + Icon( + _isExpanded ? Icons.arrow_drop_up : Icons.arrow_drop_down, + size: 16, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ], + ), + ), ], ), if (_isExpanded) diff --git a/lib/views/dex/orderbook/orderbook_table.dart b/lib/views/dex/orderbook/orderbook_table.dart index 4d12b231ac..0356ff84b3 100644 --- a/lib/views/dex/orderbook/orderbook_table.dart +++ b/lib/views/dex/orderbook/orderbook_table.dart @@ -63,8 +63,8 @@ class OrderbookTable extends StatelessWidget { final Coin? relCoin = coinsRepository.getCoin(orderbook.rel); if (baseCoin == null || relCoin == null) return const SizedBox.shrink(); - final double? baseUsdPrice = baseCoin.usdPrice?.price; - final double? relUsdPrice = relCoin.usdPrice?.price; + final double? baseUsdPrice = baseCoin.usdPrice?.price?.toDouble(); + final double? relUsdPrice = relCoin.usdPrice?.price?.toDouble(); if (baseUsdPrice == null || relUsdPrice == null) { return const SizedBox.shrink(); } diff --git a/lib/views/dex/orderbook/orderbook_view.dart b/lib/views/dex/orderbook/orderbook_view.dart index e9ae5ddba8..50fe0127c4 100644 --- a/lib/views/dex/orderbook/orderbook_view.dart +++ b/lib/views/dex/orderbook/orderbook_view.dart @@ -5,7 +5,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/blocs/orderbook_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_response.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/orderbook/order.dart'; import 'package:web_dex/model/orderbook/orderbook.dart'; @@ -66,30 +65,33 @@ class _OrderbookViewState extends State { @override Widget build(BuildContext context) { - return StreamBuilder( + return StreamBuilder( initialData: _model.response, stream: _model.outResponse, builder: (context, snapshot) { if (!_model.isComplete) return const SizedBox.shrink(); - final OrderbookResponse? response = snapshot.data; + final OrderbookResult? result = snapshot.data; - if (response == null) { + if (result == null) { return const Center(child: UiSpinner()); } - if (response.error != null) { + if (result.hasError) { return OrderbookErrorMessage( - response, + result.error ?? LocaleKeys.orderBookFailedLoadError.tr(), onReloadClick: _model.reload, ); } - final Orderbook? orderbook = response.result; - if (orderbook == null) { - return Center( - child: Text(LocaleKeys.orderBookEmpty.tr()), - ); + final response = result.response; + if (response == null) { + return const Center(child: UiSpinner()); + } + + final Orderbook orderbook = Orderbook.fromSdkResponse(response); + if (orderbook.asks.isEmpty && orderbook.bids.isEmpty) { + return Center(child: Text(LocaleKeys.orderBookEmpty.tr())); } return GradientBorder( @@ -97,8 +99,10 @@ class _OrderbookViewState extends State { gradient: dexPageColors.formPlateGradient, child: Container( constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), - padding: - const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 16.0, + ), child: Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/views/dex/simple/confirm/maker_order_confirmation.dart b/lib/views/dex/simple/confirm/maker_order_confirmation.dart index b0733ff78c..7a6d18b9ba 100644 --- a/lib/views/dex/simple/confirm/maker_order_confirmation.dart +++ b/lib/views/dex/simple/confirm/maker_order_confirmation.dart @@ -16,7 +16,7 @@ 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/model/trade_preimage.dart'; -import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/shared/utils/extensions/kdf_user_extensions.dart'; import 'package:web_dex/shared/ui/ui_light_button.dart'; import 'package:web_dex/shared/utils/balances_formatter.dart'; import 'package:web_dex/shared/utils/formatters.dart'; @@ -28,8 +28,11 @@ import 'package:web_dex/views/dex/simple/form/maker/maker_form_exchange_rate.dar import 'package:web_dex/views/dex/simple/form/maker/maker_form_total_fees.dart'; class MakerOrderConfirmation extends StatefulWidget { - const MakerOrderConfirmation( - {super.key, required this.onCreateOrder, required this.onCancel}); + const MakerOrderConfirmation({ + super.key, + required this.onCreateOrder, + required this.onCancel, + }); final VoidCallback onCancel; final VoidCallback onCreateOrder; @@ -53,55 +56,60 @@ class _MakerOrderConfirmationState extends State { : const EdgeInsets.only(top: 9.0), constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), child: StreamBuilder( - initialData: makerFormBloc.preimage, - stream: makerFormBloc.outPreimage, - builder: (BuildContext context, - AsyncSnapshot preimageSnapshot) { - final preimage = preimageSnapshot.data; - if (preimage == null) return const UiSpinner(); - - final Coin? sellCoin = - coinsRepository.getCoin(preimage.request.base); - final Coin? buyCoin = coinsRepository.getCoin(preimage.request.rel); - final Rational? sellAmount = preimage.request.volume; - final Rational buyAmount = - (sellAmount ?? Rational.zero) * preimage.request.price; - - if (sellCoin == null || buyCoin == null) { - return Center(child: Text(LocaleKeys.dexErrorMessage.tr())); - } - - return SingleChildScrollView( - key: const Key('maker-order-conformation-scroll'), - controller: ScrollController(), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _buildTitle(), - const SizedBox(height: 37), - _buildReceive(buyCoin, buyAmount), - _buildFiatReceive( - sellCoin: sellCoin, - buyCoin: buyCoin, - sellAmount: sellAmount, - buyAmount: buyAmount, - ), - const SizedBox(height: 23), - _buildSend(sellCoin, sellAmount), - const SizedBox(height: 24), - const MakerFormExchangeRate(), - const SizedBox(height: 10), - const MakerFormTotalFees(), - const SizedBox(height: 24), - _buildError(), - Flexible( - child: _buildButtons(), - ) - ], - ), - ); - }), + initialData: makerFormBloc.preimage, + stream: makerFormBloc.outPreimage, + builder: + ( + BuildContext context, + AsyncSnapshot preimageSnapshot, + ) { + final preimage = preimageSnapshot.data; + if (preimage == null) return const UiSpinner(); + + final Coin? sellCoin = coinsRepository.getCoin( + preimage.request.base, + ); + final Coin? buyCoin = coinsRepository.getCoin( + preimage.request.rel, + ); + final Rational? sellAmount = preimage.request.volume; + final Rational buyAmount = + (sellAmount ?? Rational.zero) * preimage.request.price; + + if (sellCoin == null || buyCoin == null) { + return Center(child: Text(LocaleKeys.dexErrorMessage.tr())); + } + + return SingleChildScrollView( + key: const Key('maker-order-conformation-scroll'), + controller: ScrollController(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildTitle(), + const SizedBox(height: 37), + _buildReceive(buyCoin, buyAmount), + _buildFiatReceive( + sellCoin: sellCoin, + buyCoin: buyCoin, + sellAmount: sellAmount, + buyAmount: buyAmount, + ), + const SizedBox(height: 23), + _buildSend(sellCoin, sellAmount), + const SizedBox(height: 24), + const MakerFormExchangeRate(), + const SizedBox(height: 10), + const MakerFormTotalFees(), + const SizedBox(height: 24), + _buildError(), + Flexible(child: _buildButtons(sellCoin, buyCoin)), + ], + ), + ); + }, + ), ); } @@ -112,43 +120,43 @@ class _MakerOrderConfirmationState extends State { ); } - Widget _buildButtons() { + Widget _buildButtons(Coin sellCoin, Coin buyCoin) { return Row( children: [ - Flexible( - child: _buildBackButton(), - ), + Flexible(child: _buildBackButton()), const SizedBox(width: 23), - Flexible( - child: _buildConfirmButton(), - ), + Flexible(child: _buildConfirmButton(sellCoin, buyCoin)), ], ); } - Widget _buildConfirmButton() { + Widget _buildConfirmButton(Coin sellCoin, Coin buyCoin) { final tradingState = context.watch().state; - final bool tradingEnabled = tradingState.isEnabled; + final bool tradingEnabled = tradingState.canTradeAssets([ + sellCoin.id, + buyCoin.id, + ]); return Opacity( opacity: _inProgress ? 0.8 : 1, child: UiPrimaryButton( - key: const Key('make-order-confirm-button'), - prefix: _inProgress - ? Padding( - padding: const EdgeInsets.only(right: 8), - child: UiSpinner( - height: 10, - width: 10, - strokeWidth: 1, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), - ) - : null, - onPressed: _inProgress || !tradingEnabled ? null : _startSwap, - text: tradingEnabled - ? LocaleKeys.confirm.tr() - : LocaleKeys.tradingDisabled.tr()), + key: const Key('make-order-confirm-button'), + prefix: _inProgress + ? Padding( + padding: const EdgeInsets.only(right: 8), + child: UiSpinner( + height: 10, + width: 10, + strokeWidth: 1, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ) + : null, + onPressed: _inProgress || !tradingEnabled ? null : _startSwap, + text: tradingEnabled + ? LocaleKeys.confirm.tr() + : LocaleKeys.tradingDisabled.tr(), + ), ); } @@ -160,10 +168,9 @@ class _MakerOrderConfirmationState extends State { padding: const EdgeInsets.fromLTRB(8, 0, 8, 20), child: Text( message, - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(color: Theme.of(context).colorScheme.error), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), ), ); } @@ -197,12 +204,14 @@ class _MakerOrderConfirmationState extends State { children: [ FiatAmount(coin: buyCoin, amount: buyAmount), if (percentage != null) - Text(' (${percentage > 0 ? '+' : ''}${formatAmt(percentage)}%)', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: 11, - color: color, - fontWeight: FontWeight.w200, - )), + Text( + ' (${percentage > 0 ? '+' : ''}${formatAmt(percentage)}%)', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 11, + color: color, + fontWeight: FontWeight.w200, + ), + ), ], ); } @@ -210,8 +219,9 @@ class _MakerOrderConfirmationState extends State { Widget _buildFiatSend(Coin coin, Rational? amount) { if (amount == null) return const SizedBox(); return Container( - padding: const EdgeInsets.fromLTRB(0, 0, 2, 0), - child: FiatAmount(coin: coin, amount: amount)); + padding: const EdgeInsets.fromLTRB(0, 0, 2, 0), + child: FiatAmount(coin: coin, amount: amount), + ); } Widget _buildReceive(Coin coin, Rational? amount) { @@ -219,24 +229,22 @@ class _MakerOrderConfirmationState extends State { children: [ SelectableText( LocaleKeys.swapConfirmationYouReceive.tr(), - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: theme.custom.dexSubTitleColor, - ), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: theme.custom.dexSubTitleColor), ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - SelectableText('${formatDexAmt(amount)} ', - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - )), + SelectableText( + '${formatDexAmt(amount)} ', + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), SelectableText( Coin.normalizeAbbr(coin.abbr), - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: theme.custom.balanceColor), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: theme.custom.balanceColor, + ), ), if (coin.mode == CoinMode.segwit) const Padding( @@ -251,10 +259,7 @@ class _MakerOrderConfirmationState extends State { Widget _buildSend(Coin coin, Rational? amount) { return Container( - padding: const EdgeInsets.symmetric( - vertical: 14, - horizontal: 16, - ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), color: theme.custom.subCardBackgroundColor, @@ -266,8 +271,8 @@ class _MakerOrderConfirmationState extends State { SelectableText( LocaleKeys.swapConfirmationYouSending.tr(), style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: theme.custom.dexSubTitleColor, - ), + color: theme.custom.dexSubTitleColor, + ), ), const SizedBox(height: 10), Row( @@ -283,9 +288,9 @@ class _MakerOrderConfirmationState extends State { SelectableText( formatDexAmt(amount), style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontSize: 14.0, - fontWeight: FontWeight.w500, - ), + fontSize: 14.0, + fontWeight: FontWeight.w500, + ), ), _buildFiatSend(coin, amount), ], @@ -311,25 +316,21 @@ class _MakerOrderConfirmationState extends State { }); final authBloc = context.read(); - final walletType = - authBloc.state.currentUser?.wallet.config.type.name ?? ''; + final walletType = authBloc.state.currentUser?.type ?? ''; final makerFormBloc = RepositoryProvider.of(context); - final sellCoin = makerFormBloc.sellCoin!.abbr; - final buyCoin = makerFormBloc.buyCoin!.abbr; - final networks = - '${makerFormBloc.sellCoin!.protocolType},${makerFormBloc.buyCoin!.protocolType}'; + final sellCoin = makerFormBloc.sellCoin!; + final buyCoin = makerFormBloc.buyCoin!; context.read().logEvent( - SwapInitiatedEventData( - fromAsset: sellCoin, - toAsset: buyCoin, - networks: networks, - walletType: walletType, - ), - ); + SwapInitiatedEventData( + asset: sellCoin.abbr, + secondaryAsset: buyCoin.abbr, + network: sellCoin.protocolType, + secondaryNetwork: buyCoin.protocolType, + hdType: walletType, + ), + ); - final int callStart = DateTime.now().millisecondsSinceEpoch; final TextError? error = await makerFormBloc.makeOrder(); - final int durationMs = DateTime.now().millisecondsSinceEpoch - callStart; final tradingEntitiesBloc = // ignore: use_build_context_synchronously @@ -342,29 +343,12 @@ class _MakerOrderConfirmationState extends State { setState(() => _inProgress = false); if (error != null) { - context.read().logEvent( - SwapFailedEventData( - fromAsset: sellCoin, - toAsset: buyCoin, - failStage: 'order_submission', - walletType: walletType, - durationMs: durationMs, - ), - ); + // We log swap failures when the actual swap fails, not on order submission. setState(() => _errorMessage = error.error); return; } - context.read().logEvent( - SwapSucceededEventData( - fromAsset: sellCoin, - toAsset: buyCoin, - amount: makerFormBloc.sellAmount!.toDouble(), - fee: 0, // Fee data not available - walletType: walletType, - durationMs: durationMs, - ), - ); + // Swap success is tracked when the trade completes in trading_details.dart. makerFormBloc.clear(); widget.onCreateOrder(); } diff --git a/lib/views/dex/simple/confirm/taker_order_confirmation.dart b/lib/views/dex/simple/confirm/taker_order_confirmation.dart index 16d9a373f8..8ee2b829f6 100644 --- a/lib/views/dex/simple/confirm/taker_order_confirmation.dart +++ b/lib/views/dex/simple/confirm/taker_order_confirmation.dart @@ -18,7 +18,7 @@ import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/dex_form_error.dart'; import 'package:web_dex/model/trade_preimage.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/routing_state.dart'; import 'package:web_dex/shared/ui/ui_light_button.dart'; import 'package:web_dex/shared/utils/balances_formatter.dart'; @@ -90,9 +90,7 @@ class _TakerOrderConfirmationState extends State { const TakerFormTotalFees(), const SizedBox(height: 24), _buildError(), - Flexible( - child: _buildButtons(), - ) + Flexible(child: _buildButtons(sellCoin, buyCoin)), ], ), ), @@ -116,23 +114,22 @@ class _TakerOrderConfirmationState extends State { ); } - Widget _buildButtons() { + Widget _buildButtons(Coin sellCoin, Coin buyCoin) { return Row( children: [ - Flexible( - child: _buildBackButton(), - ), + Flexible(child: _buildBackButton()), const SizedBox(width: 23), - Flexible( - child: _buildConfirmButton(), - ), + Flexible(child: _buildConfirmButton(sellCoin, buyCoin)), ], ); } - Widget _buildConfirmButton() { + Widget _buildConfirmButton(Coin sellCoin, Coin buyCoin) { final tradingStatusState = context.watch().state; - final bool tradingEnabled = tradingStatusState.isEnabled; + final bool tradingEnabled = tradingStatusState.canTradeAssets([ + sellCoin.id, + buyCoin.id, + ]); return BlocSelector( selector: (state) => state.inProgress, @@ -140,24 +137,25 @@ class _TakerOrderConfirmationState extends State { return Opacity( opacity: inProgress ? 0.8 : 1, child: UiPrimaryButton( - key: const Key('take-order-confirm-button'), - prefix: inProgress - ? Padding( - padding: const EdgeInsets.only(right: 8), - child: UiSpinner( - width: 10, - height: 10, - strokeWidth: 1, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), - ) - : null, - onPressed: inProgress || !tradingEnabled - ? null - : () => _startSwap(context), - text: tradingEnabled - ? LocaleKeys.confirm.tr() - : LocaleKeys.tradingDisabled.tr()), + key: const Key('take-order-confirm-button'), + prefix: inProgress + ? Padding( + padding: const EdgeInsets.only(right: 8), + child: UiSpinner( + width: 10, + height: 10, + strokeWidth: 1, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ) + : null, + onPressed: inProgress || !tradingEnabled + ? null + : () => _startSwap(context), + text: tradingEnabled + ? LocaleKeys.confirm.tr() + : LocaleKeys.tradingDisabled.tr(), + ), ); }, ); @@ -174,10 +172,9 @@ class _TakerOrderConfirmationState extends State { padding: const EdgeInsets.fromLTRB(8, 0, 8, 20), child: Text( message, - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(color: Theme.of(context).colorScheme.error), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), ), ); }, @@ -213,12 +210,14 @@ class _TakerOrderConfirmationState extends State { children: [ FiatAmount(coin: buyCoin, amount: buyAmount), if (percentage != null) - Text(' (${percentage > 0 ? '+' : ''}${formatAmt(percentage)}%)', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: 11, - color: color, - fontWeight: FontWeight.w200, - )), + Text( + ' (${percentage > 0 ? '+' : ''}${formatAmt(percentage)}%)', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 11, + color: color, + fontWeight: FontWeight.w200, + ), + ), ], ); } @@ -226,8 +225,9 @@ class _TakerOrderConfirmationState extends State { Widget _buildFiatSend(Coin coin, Rational? amount) { if (amount == null) return const SizedBox(); return Container( - padding: const EdgeInsets.fromLTRB(0, 0, 2, 0), - child: FiatAmount(coin: coin, amount: amount)); + padding: const EdgeInsets.fromLTRB(0, 0, 2, 0), + child: FiatAmount(coin: coin, amount: amount), + ); } Widget _buildReceive(Coin coin, Rational? amount) { @@ -235,24 +235,22 @@ class _TakerOrderConfirmationState extends State { children: [ SelectableText( LocaleKeys.swapConfirmationYouReceive.tr(), - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: theme.custom.dexSubTitleColor, - ), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: theme.custom.dexSubTitleColor), ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - SelectableText('${formatDexAmt(amount)} ', - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - )), + SelectableText( + '${formatDexAmt(amount)} ', + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), SelectableText( Coin.normalizeAbbr(coin.abbr), - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: theme.custom.balanceColor), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: theme.custom.balanceColor, + ), ), if (coin.mode == CoinMode.segwit) const Padding( @@ -267,10 +265,7 @@ class _TakerOrderConfirmationState extends State { Widget _buildSend(Coin coin, Rational? amount) { return Container( - padding: const EdgeInsets.symmetric( - vertical: 14, - horizontal: 16, - ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), color: theme.custom.subCardBackgroundColor, @@ -282,8 +277,8 @@ class _TakerOrderConfirmationState extends State { SelectableText( LocaleKeys.swapConfirmationYouSending.tr(), style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: theme.custom.dexSubTitleColor, - ), + color: theme.custom.dexSubTitleColor, + ), ), const SizedBox(height: 10), Row( @@ -299,9 +294,9 @@ class _TakerOrderConfirmationState extends State { SelectableText( formatDexAmt(amount), style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontSize: 14.0, - fontWeight: FontWeight.w500, - ), + fontSize: 14.0, + fontWeight: FontWeight.w500, + ), ), _buildFiatSend(coin, amount), ], @@ -322,24 +317,22 @@ class _TakerOrderConfirmationState extends State { Future _startSwap(BuildContext context) async { final authBloc = context.read(); - final walletType = - authBloc.state.currentUser?.wallet.config.type.name ?? ''; + final walletType = authBloc.state.currentUser?.type ?? ''; final takerBloc = context.read(); final coinsRepo = RepositoryProvider.of(context); final sellCoinObj = takerBloc.state.sellCoin!; final buyCoinObj = coinsRepo.getCoin(takerBloc.state.selectedOrder!.coin); final sellCoin = sellCoinObj.abbr; final buyCoin = buyCoinObj?.abbr ?? takerBloc.state.selectedOrder!.coin; - final networks = - '${sellCoinObj.protocolType},${buyCoinObj?.protocolType ?? ''}'; context.read().logEvent( - SwapInitiatedEventData( - fromAsset: sellCoin, - toAsset: buyCoin, - networks: networks, - walletType: walletType, - ), - ); + SwapInitiatedEventData( + asset: sellCoin, + secondaryAsset: buyCoin, + network: sellCoinObj.protocolType, + secondaryNetwork: buyCoinObj?.protocolType ?? 'unknown', + hdType: walletType, + ), + ); context.read().add(TakerStartSwap()); } @@ -350,8 +343,9 @@ class _TakerOrderConfirmationState extends State { context.read().add(TakerClear()); routingState.dexState.setDetailsAction(uuid); - final tradingEntitiesBloc = - RepositoryProvider.of(context); + final tradingEntitiesBloc = RepositoryProvider.of( + context, + ); await tradingEntitiesBloc.fetch(); } } diff --git a/lib/views/dex/simple/form/dex_fiat_amount.dart b/lib/views/dex/simple/form/dex_fiat_amount.dart index 67a37eaf8b..22341a7423 100644 --- a/lib/views/dex/simple/form/dex_fiat_amount.dart +++ b/lib/views/dex/simple/form/dex_fiat_amount.dart @@ -6,12 +6,12 @@ import 'package:web_dex/shared/utils/formatters.dart'; class DexFiatAmount extends StatelessWidget { const DexFiatAmount({ - Key? key, + super.key, required this.coin, required this.amount, this.padding, this.textStyle, - }) : super(key: key); + }); final Coin? coin; final Rational? amount; @@ -21,17 +21,19 @@ class DexFiatAmount extends StatelessWidget { @override Widget build(BuildContext context) { final Rational estAmount = amount ?? Rational.zero; - final double usdPrice = coin?.usdPrice?.price ?? 0.0; + final double usdPrice = coin?.usdPrice?.price?.toDouble() ?? 0.0; final double fiatAmount = estAmount.toDouble() * usdPrice; return Padding( padding: padding ?? EdgeInsets.zero, - child: Text('~ \$${formatAmt(fiatAmount)}', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - color: theme.custom.fiatAmountColor, - ).merge(textStyle)), + child: Text( + '~ \$${formatAmt(fiatAmount)}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: theme.custom.fiatAmountColor, + ).merge(textStyle), + ), ); } } diff --git a/lib/views/dex/simple/form/exchange_info/dex_compared_to_cex.dart b/lib/views/dex/simple/form/exchange_info/dex_compared_to_cex.dart index 47f8c32061..16f3f8c260 100644 --- a/lib/views/dex/simple/form/exchange_info/dex_compared_to_cex.dart +++ b/lib/views/dex/simple/form/exchange_info/dex_compared_to_cex.dart @@ -26,8 +26,8 @@ class DexComparedToCex extends StatelessWidget { @override Widget build(BuildContext context) { - final double? baseUsd = base?.usdPrice?.price; - final double? relUsd = rel?.usdPrice?.price; + final double? baseUsd = base?.usdPrice?.price?.toDouble(); + final double? relUsd = rel?.usdPrice?.price?.toDouble(); double diff = 0; if (baseUsd != null && relUsd != null && rate != null) { diff --git a/lib/views/dex/simple/form/maker/maker_form_layout.dart b/lib/views/dex/simple/form/maker/maker_form_layout.dart index 6d764c4c36..5111f33bef 100644 --- a/lib/views/dex/simple/form/maker/maker_form_layout.dart +++ b/lib/views/dex/simple/form/maker/maker_form_layout.dart @@ -101,12 +101,33 @@ class _MakerFormLayoutState extends State { } } -class _MakerFormDesktopLayout extends StatelessWidget { +class _MakerFormDesktopLayout extends StatefulWidget { const _MakerFormDesktopLayout(); + @override + State<_MakerFormDesktopLayout> createState() => _MakerFormDesktopLayoutState(); +} + +class _MakerFormDesktopLayoutState extends State<_MakerFormDesktopLayout> { + late final ScrollController _mainScrollController; + late final ScrollController _orderbookScrollController; + + @override + void initState() { + super.initState(); + _mainScrollController = ScrollController(); + _orderbookScrollController = ScrollController(); + } + + @override + void dispose() { + _mainScrollController.dispose(); + _orderbookScrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - final scrollController = ScrollController(); return Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.center, @@ -119,11 +140,11 @@ class _MakerFormDesktopLayout extends StatelessWidget { Flexible( flex: 6, child: DexScrollbar( - scrollController: scrollController, + scrollController: _mainScrollController, isMobile: isMobile, child: SingleChildScrollView( key: const Key('maker-form-layout-scroll'), - controller: scrollController, + controller: _mainScrollController, child: ConstrainedBox( constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), @@ -144,7 +165,7 @@ class _MakerFormDesktopLayout extends StatelessWidget { child: Padding( padding: const EdgeInsets.only(left: 20), child: SingleChildScrollView( - controller: ScrollController(), + controller: _orderbookScrollController, child: const MakerFormOrderbook(), ), ), @@ -154,14 +175,33 @@ class _MakerFormDesktopLayout extends StatelessWidget { } } -class _MakerFormMobileLayout extends StatelessWidget { +class _MakerFormMobileLayout extends StatefulWidget { const _MakerFormMobileLayout(); + @override + State<_MakerFormMobileLayout> createState() => _MakerFormMobileLayoutState(); +} + +class _MakerFormMobileLayoutState extends State<_MakerFormMobileLayout> { + late final ScrollController _scrollController; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return SingleChildScrollView( key: const Key('maker-form-layout-scroll'), - controller: ScrollController(), + controller: _scrollController, child: ConstrainedBox( constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), child: const Stack( 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 44d1704bb2..df31afbc5e 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 @@ -8,6 +8,7 @@ import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/blocs/maker_form_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; class MakerFormTradeButton extends StatelessWidget { const MakerFormTradeButton({Key? key}) : super(key: key); @@ -15,59 +16,80 @@ class MakerFormTradeButton extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder( - builder: (context, systemHealthState) { - // Determine if system clock is valid - final bool isSystemClockValid = - systemHealthState is SystemHealthLoadSuccess && - systemHealthState.isValid; + builder: (context, systemHealthState) { + // Determine if system clock is valid + final bool isSystemClockValid = + systemHealthState is SystemHealthLoadSuccess && + systemHealthState.isValid; - final tradingState = context.watch().state; - final isTradingEnabled = tradingState.isEnabled; + final makerFormBloc = RepositoryProvider.of(context); + final authBloc = context.watch(); - final makerFormBloc = RepositoryProvider.of(context); - final authBloc = context.watch(); + return StreamBuilder( + initialData: makerFormBloc.sellCoin, + stream: makerFormBloc.outSellCoin, + builder: (context, sellSnapshot) { + return StreamBuilder( + initialData: makerFormBloc.buyCoin, + stream: makerFormBloc.outBuyCoin, + builder: (context, buySnapshot) { + final tradingState = context.watch().state; + final isTradingEnabled = tradingState.canTradeAssets([ + sellSnapshot.data?.id, + buySnapshot.data?.id, + ]); - return StreamBuilder( - initialData: makerFormBloc.inProgress, - stream: makerFormBloc.outInProgress, - builder: (context, snapshot) { - final bool inProgress = snapshot.data ?? false; - final bool disabled = inProgress || !isSystemClockValid; + return StreamBuilder( + initialData: makerFormBloc.inProgress, + stream: makerFormBloc.outInProgress, + builder: (context, snapshot) { + final bool inProgress = snapshot.data ?? false; + final bool disabled = inProgress || !isSystemClockValid; - return Opacity( - opacity: disabled ? 0.8 : 1, - child: UiPrimaryButton( - key: const Key('make-order-button'), - 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 { - while (!authBloc.state.isSignedIn) { - await Future.delayed( - const Duration(milliseconds: 300)); - } - final bool isValid = await makerFormBloc.validate(); - if (!isValid) return; + return Opacity( + opacity: disabled ? 0.8 : 1, + child: UiPrimaryButton( + key: const Key('make-order-button'), + 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 { + while (!authBloc.state.isSignedIn) { + await Future.delayed( + const Duration(milliseconds: 300), + ); + } + final bool isValid = await makerFormBloc + .validate(); + if (!isValid) return; - makerFormBloc.showConfirmation = true; - }, - height: 40, - ), + makerFormBloc.showConfirmation = true; + }, + height: 40, + ), + ); + }, + ); + }, ); - }); - }); + }, + ); + }, + ); } } 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 c8140c10e4..04c6d392e1 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 @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:komodo_ui/komodo_ui.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/views/dex/common/front_plate.dart'; import 'package:web_dex/views/dex/simple/form/tables/coins_table/coins_table_content.dart'; @@ -22,6 +23,13 @@ class CoinsTable extends StatefulWidget { class _CoinsTableState extends State { String? _searchTerm; + late final Debouncer _searchDebouncer; + + @override + void initState() { + super.initState(); + _searchDebouncer = Debouncer(duration: const Duration(milliseconds: 200)); + } @override Widget build(BuildContext context) { @@ -38,8 +46,12 @@ class _CoinsTableState extends State { child: TableSearchField( height: 30, onChanged: (String value) { - if (_searchTerm == value) return; - setState(() => _searchTerm = value); + final nextValue = value; + _searchDebouncer.run(() { + if (!mounted) return; + if (_searchTerm == nextValue) return; + setState(() => _searchTerm = nextValue); + }); }, ), ), @@ -54,4 +66,10 @@ class _CoinsTableState extends State { ), ); } + + @override + void dispose() { + _searchDebouncer.dispose(); + super.dispose(); + } } diff --git a/lib/views/dex/simple/form/tables/coins_table/coins_table_content.dart b/lib/views/dex/simple/form/tables/coins_table/coins_table_content.dart index c44648c4ab..4b35c3402f 100644 --- a/lib/views/dex/simple/form/tables/coins_table/coins_table_content.dart +++ b/lib/views/dex/simple/form/tables/coins_table/coins_table_content.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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/trading_status/trading_status_bloc.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/views/dex/simple/form/tables/nothing_found.dart'; import 'package:web_dex/views/dex/simple/form/tables/orders_table/grouped_list_view.dart'; @@ -20,20 +21,30 @@ class CoinsTableContent extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final coins = prepareCoinsForTable( - context, - state.coins.values.toList(), - searchString, - testCoinsEnabled: context.read().state.testCoinsEnabled, - ); - if (coins.isEmpty) return const NothingFound(); + // FIX: Using BlocBuilder to listen to TradingStatusBloc changes + // This ensures the coin list is re-filtered when geo-blocking status changes. + // Following BLoC best practices: widgets should rebuild when dependent bloc states change. + return BlocBuilder( + builder: (context, tradingStatus) { + return BlocBuilder( + builder: (context, coinsState) { + final coins = prepareCoinsForTable( + context, + coinsState.coins.values.toList(), + searchString, + testCoinsEnabled: context + .read() + .state + .testCoinsEnabled, + ); + if (coins.isEmpty) return const NothingFound(); - return GroupedListView( - items: coins, - onSelect: onSelect, - maxHeight: maxHeight, + return GroupedListView( + items: coins, + onSelect: onSelect, + maxHeight: maxHeight, + ); + }, ); }, ); diff --git a/lib/views/dex/simple/form/tables/orders_table/grouped_list_view.dart b/lib/views/dex/simple/form/tables/orders_table/grouped_list_view.dart index 0474a6c297..1958deb110 100644 --- a/lib/views/dex/simple/form/tables/orders_table/grouped_list_view.dart +++ b/lib/views/dex/simple/form/tables/orders_table/grouped_list_view.dart @@ -29,7 +29,8 @@ class GroupedListView extends StatelessWidget { // Add right padding to the last column if there are grouped items // to align the grouped and non-grouped - final areGroupedItemsPresent = groupedItems.isNotEmpty && + final areGroupedItemsPresent = + groupedItems.isNotEmpty && groupedItems.entries .where((element) => element.value.length > 1) .isNotEmpty; @@ -60,8 +61,9 @@ class GroupedListView extends StatelessWidget { coin: _createHeaderCoinData(context, group.value), onSelect: onSelect, isGroupHeader: true, - subtitleText: LocaleKeys.nNetworks - .tr(args: [group.value.length.toString()]), + subtitleText: LocaleKeys.nNetworks.tr( + args: [group.value.length.toString()], + ), ), children: group.value .map((item) => buildItem(context, item, onSelect)) @@ -109,7 +111,7 @@ class GroupedListView extends StatelessWidget { final Map> grouped = {}; for (final item in list) { final coin = getCoin(context, item); - grouped.putIfAbsent(coin.name, () => []).add(item); + grouped.putIfAbsent(coin.displayName, () => []).add(item); } return grouped; } diff --git a/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart b/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart index a1d321403f..f8a9b812ea 100644 --- a/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart +++ b/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart @@ -6,6 +6,7 @@ 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/taker_form/taker_state.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; @@ -28,36 +29,46 @@ class OrdersTableContent extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocSelector( - selector: (state) => state.bestOrders, - builder: (context, bestOrders) { - if (bestOrders == null) { - return Container( - padding: const EdgeInsets.fromLTRB(0, 30, 0, 10), - alignment: const Alignment(0, 0), - child: const UiSpinner(), - ); - } + // FIX: Using BlocBuilder to listen to TradingStatusBloc changes + // This ensures the orders list is re-filtered when geo-blocking status changes. + // Following BLoC best practices: widgets should rebuild when dependent bloc states change. + return BlocBuilder( + builder: (context, tradingStatus) { + return BlocSelector( + selector: (state) => state.bestOrders, + builder: (context, bestOrders) { + if (bestOrders == null) { + return Container( + padding: const EdgeInsets.fromLTRB(0, 30, 0, 10), + alignment: const Alignment(0, 0), + child: const UiSpinner(), + ); + } - final BaseError? error = bestOrders.error; - if (error != null) return _ErrorMessage(error); + final BaseError? error = bestOrders.error; + if (error != null) return _ErrorMessage(error); - final Map> ordersMap = bestOrders.result!; - final AuthorizeMode mode = context.watch().state.mode; - final List orders = prepareOrdersForTable( - context, - ordersMap, - searchString, - mode, - testCoinsEnabled: context.read().state.testCoinsEnabled, - ); + final Map> ordersMap = bestOrders.result!; + final AuthorizeMode mode = context.watch().state.mode; + final List orders = prepareOrdersForTable( + context, + ordersMap, + searchString, + mode, + testCoinsEnabled: context + .read() + .state + .testCoinsEnabled, + ); - if (orders.isEmpty) return const NothingFound(); + if (orders.isEmpty) return const NothingFound(); - return GroupedListView( - items: orders, - onSelect: onSelect, - maxHeight: maxHeight, + return GroupedListView( + items: orders, + onSelect: onSelect, + maxHeight: maxHeight, + ); + }, ); }, ); @@ -83,11 +94,12 @@ class _ErrorMessage extends StatelessWidget { const Icon(Icons.warning_amber, size: 14, color: Colors.orange), const SizedBox(width: 4), Flexible( - child: SelectableText( - error.message, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - )), + child: SelectableText( + error.message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), const SizedBox(height: 4), UiSimpleButton( child: Text( @@ -96,7 +108,7 @@ class _ErrorMessage extends StatelessWidget { ), onPressed: () => context.read().add(TakerUpdateBestOrders()), - ) + ), ], ), ], diff --git a/lib/views/dex/simple/form/tables/table_utils.dart b/lib/views/dex/simple/form/tables/table_utils.dart index ac11a02529..d827d6373b 100644 --- a/lib/views/dex/simple/form/tables/table_utils.dart +++ b/lib/views/dex/simple/form/tables/table_utils.dart @@ -1,14 +1,14 @@ +import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; -import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart' show AssetId; import 'package:web_dex/bloc/coins_bloc/coins_repo.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/coin.dart'; import 'package:web_dex/model/coin_utils.dart'; -import 'package:web_dex/shared/utils/balances_formatter.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; List prepareCoinsForTable( BuildContext context, @@ -16,12 +16,15 @@ List prepareCoinsForTable( String? searchString, { bool testCoinsEnabled = true, }) { - final authBloc = RepositoryProvider.of(context); - coins = List.from(coins); - if (!testCoinsEnabled) coins = removeTestCoins(coins); + final sdk = RepositoryProvider.of(context); + + coins = List.of(coins); + if (!testCoinsEnabled) { + coins = removeTestCoins(coins); + } coins = removeWalletOnly(coins); - coins = removeSuspended(coins, authBloc.state.isSignedIn); - coins = sortByPriorityAndBalance(coins, GetIt.I()); + coins = removeDisallowedCoins(context, coins); + coins = sortByPriorityAndBalance(coins, sdk); coins = filterCoinsByPhrase(coins, searchString ?? '').toList(); return coins; } @@ -30,32 +33,58 @@ List prepareOrdersForTable( BuildContext context, Map>? orders, String? searchString, - AuthorizeMode mode, { + AuthorizeMode _mode, { bool testCoinsEnabled = true, + Coin? Function(String)? coinLookup, }) { if (orders == null) return []; - final List sorted = _sortBestOrders(context, orders); - if (sorted.isEmpty) return []; + final caches = buildOrderCoinCaches(context, orders, coinLookup: coinLookup); + + final ordersByAssetId = caches.ordersByAssetId; + final coinsByAssetId = caches.coinsByAssetId; + final assetIdByAbbr = caches.assetIdByAbbr; + + final List sorted = _sortBestOrders( + ordersByAssetId, + coinsByAssetId, + ); + if (sorted.isEmpty) { + return []; + } if (!testCoinsEnabled) { - removeTestCoinOrders(sorted, context); - if (sorted.isEmpty) return []; + removeTestCoinOrders( + sorted, + ordersByAssetId, + coinsByAssetId, + assetIdByAbbr, + ); + if (sorted.isEmpty) { + return []; + } } - removeSuspendedCoinOrders(sorted, mode, context); - if (sorted.isEmpty) return []; + removeWalletOnlyCoinOrders( + sorted, + ordersByAssetId, + coinsByAssetId, + assetIdByAbbr, + ); + if (sorted.isEmpty) { + return []; + } - removeWalletOnlyCoinOrders(sorted, context); + removeDisallowedCoinOrders(sorted, context); if (sorted.isEmpty) return []; - final String? filter = searchString?.toLowerCase(); if (filter == null || filter.isEmpty) { return sorted; } - final coinsRepository = RepositoryProvider.of(context); final List filtered = sorted.where((order) { - final Coin? coin = coinsRepository.getCoin(order.coin); + final AssetId? assetId = assetIdByAbbr[order.coin]; + if (assetId == null) return false; + final Coin? coin = coinsByAssetId[assetId]; if (coin == null) return false; return compareCoinByPhrase(coin, filter); }).toList(); @@ -63,65 +92,187 @@ List prepareOrdersForTable( return filtered; } -List _sortBestOrders( - BuildContext context, Map> unsorted) { - if (unsorted.isEmpty) return []; +/// Filters out coins that are geo-blocked based on the current trading status. +/// +/// TECH DEBT / BLoC ANTI-PATTERN WARNING: +/// This function uses [context.read] to access [TradingStatusBloc] state. +/// According to BLoC best practices, [context.read] should NOT be used to +/// retrieve state within build methods because it doesn't establish a subscription +/// to state changes. +/// +/// IMPACT: When this function is called from a build method, the widget won't +/// automatically rebuild when [TradingStatusBloc] state changes (e.g., when +/// geo-blocking status updates). +/// +/// FIX APPLIED: All widgets calling this function now wrap their build methods +/// with [BlocBuilder] to ensure rebuilds when trading status changes. +/// +/// RECOMMENDED REFACTOR: +/// Following SOLID principles (Single Responsibility), filtering logic should be +/// moved into the respective Blocs rather than utility functions that access +/// other Blocs' state. This would: +/// 1. Remove presentation layer's direct dependency on [TradingStatusBloc] +/// 2. Enable proper bloc-to-bloc communication through events +/// 3. Make state changes more predictable and testable +/// 4. Follow the unidirectional data flow pattern +List removeDisallowedCoins(BuildContext context, List coins) { + final tradingState = context.read().state; + if (!tradingState.isEnabled) return []; + return coins.where((coin) => tradingState.canTradeAssets([coin.id])).toList(); +} +/// Filters out orders for coins that are geo-blocked based on the current trading status. +/// Modifies the [orders] list in-place. +/// +/// TECH DEBT / BLoC ANTI-PATTERN WARNING: +/// This function uses [context.read] to access [TradingStatusBloc] state. +/// According to BLoC best practices, [context.read] should NOT be used to +/// retrieve state within build methods because it doesn't establish a subscription +/// to state changes. +/// +/// IMPACT: When this function is called from a build method, the widget won't +/// automatically rebuild when [TradingStatusBloc] state changes (e.g., when +/// geo-blocking status updates). +/// +/// FIX APPLIED: All widgets calling this function now wrap their build methods +/// with [BlocBuilder] to ensure rebuilds when trading status changes. +/// +/// RECOMMENDED REFACTOR: +/// Following SOLID principles (Single Responsibility), filtering logic should be +/// moved into the respective Blocs rather than utility functions that access +/// other Blocs' state. This would: +/// 1. Remove presentation layer's direct dependency on [TradingStatusBloc] +/// 2. Enable proper bloc-to-bloc communication through events +/// 3. Make state changes more predictable and testable +/// 4. Follow the unidirectional data flow pattern +/// +/// ADDITIONAL TECH DEBT: +/// This function mutates the input list in-place, which is a side effect that +/// can make code harder to reason about and test. Consider returning a new +/// filtered list instead (similar to [removeDisallowedCoins]). +void removeDisallowedCoinOrders(List orders, BuildContext context) { + final tradingState = context.read().state; + if (!tradingState.isEnabled) { + orders.clear(); + return; + } final coinsRepository = RepositoryProvider.of(context); - final List sorted = []; - unsorted.forEach((ticker, list) { - if (coinsRepository.getCoin(list[0].coin) == null) return; - sorted.add(list[0]); + orders.removeWhere((order) { + final Coin? coin = coinsRepository.getCoin(order.coin); + if (coin == null) return true; + return !tradingState.canTradeAssets([coin.id]); }); +} - sorted.sort((a, b) { - final Coin? coinA = coinsRepository.getCoin(a.coin); - final Coin? coinB = coinsRepository.getCoin(b.coin); - if (coinA == null || coinB == null) return 0; +({ + Map ordersByAssetId, + Map coinsByAssetId, + Map assetIdByAbbr, +}) +buildOrderCoinCaches( + BuildContext context, + Map> orders, { + Coin? Function(String)? coinLookup, +}) { + final Coin? Function(String) resolveCoin = + coinLookup ?? RepositoryProvider.of(context).getCoin; - final double fiatPriceA = getFiatAmount(coinA, a.price); - final double fiatPriceB = getFiatAmount(coinB, b.price); + final ordersByAssetId = {}; + final coinsByAssetId = {}; + final assetIdByAbbr = {}; - if (fiatPriceA > fiatPriceB) return -1; - if (fiatPriceA < fiatPriceB) return 1; + orders.forEach((_, list) { + if (list.isEmpty) return; + final BestOrder order = list[0]; + final Coin? coin = resolveCoin(order.coin); + if (coin == null) return; - return coinA.abbr.compareTo(coinB.abbr); + final AssetId assetId = coin.assetId; + ordersByAssetId[assetId] = order; + coinsByAssetId[assetId] = coin; + assetIdByAbbr[coin.abbr] = assetId; }); - return sorted; + return ( + ordersByAssetId: ordersByAssetId, + coinsByAssetId: coinsByAssetId, + assetIdByAbbr: assetIdByAbbr, + ); } -void removeSuspendedCoinOrders( - List orders, - AuthorizeMode authorizeMode, - BuildContext context, +List _sortBestOrders( + Map ordersByAssetId, + Map coinsByAssetId, ) { - if (authorizeMode == AuthorizeMode.noLogin) return; - final coinsRepository = RepositoryProvider.of(context); - orders.removeWhere((BestOrder order) { - final Coin? coin = coinsRepository.getCoin(order.coin); - if (coin == null) return true; + if (ordersByAssetId.isEmpty) return []; + final entries = + <({AssetId assetId, BestOrder order, Coin coin, double fiatPrice})>[]; + + ordersByAssetId.forEach((assetId, order) { + final Coin? coin = coinsByAssetId[assetId]; + if (coin == null) return; + + final Decimal? usdPrice = coin.usdPrice?.price; + final double fiatPrice = + order.price.toDouble() * (usdPrice?.toDouble() ?? 0.0); + entries.add(( + assetId: assetId, + order: order, + coin: coin, + fiatPrice: fiatPrice, + )); + }); - return coin.isSuspended; + entries.sort((a, b) { + final int fiatComparison = b.fiatPrice.compareTo(a.fiatPrice); + if (fiatComparison != 0) return fiatComparison; + return a.coin.abbr.compareTo(b.coin.abbr); }); + + final result = entries.map((entry) => entry.order).toList(); + return result; } -void removeWalletOnlyCoinOrders(List orders, BuildContext context) { - final coinsRepository = RepositoryProvider.of(context); +void removeWalletOnlyCoinOrders( + List orders, + Map ordersByAssetId, + Map coinsByAssetId, + Map assetIdByAbbr, +) { orders.removeWhere((BestOrder order) { - final Coin? coin = coinsRepository.getCoin(order.coin); + final AssetId? assetId = assetIdByAbbr[order.coin]; + if (assetId == null) return true; + final Coin? coin = coinsByAssetId[assetId]; if (coin == null) return true; - return coin.walletOnly; + final bool shouldRemove = coin.walletOnly; + if (shouldRemove) { + ordersByAssetId.remove(assetId); + coinsByAssetId.remove(assetId); + assetIdByAbbr.remove(order.coin); + } + return shouldRemove; }); } -void removeTestCoinOrders(List orders, BuildContext context) { - final coinsRepository = RepositoryProvider.of(context); +void removeTestCoinOrders( + List orders, + Map ordersByAssetId, + Map coinsByAssetId, + Map assetIdByAbbr, +) { orders.removeWhere((BestOrder order) { - final Coin? coin = coinsRepository.getCoin(order.coin); + final AssetId? assetId = assetIdByAbbr[order.coin]; + if (assetId == null) return true; + final Coin? coin = coinsByAssetId[assetId]; if (coin == null) return true; - return coin.isTestCoin; + final bool shouldRemove = coin.isTestCoin; + if (shouldRemove) { + ordersByAssetId.remove(assetId); + coinsByAssetId.remove(assetId); + assetIdByAbbr.remove(order.coin); + } + return shouldRemove; }); } diff --git a/lib/views/dex/simple/form/taker/coin_item/taker_form_sell_item.dart b/lib/views/dex/simple/form/taker/coin_item/taker_form_sell_item.dart index cef689793d..c58567daec 100644 --- a/lib/views/dex/simple/form/taker/coin_item/taker_form_sell_item.dart +++ b/lib/views/dex/simple/form/taker/coin_item/taker_form_sell_item.dart @@ -50,6 +50,8 @@ class _SellHeader extends StatelessWidget { actions: [ Flexible(child: _AvailableGroup()), const SizedBox(width: 8), + _ExactButton(), + const SizedBox(width: 3), _MaxButton(), const SizedBox(width: 3), _HalfButton(), @@ -85,3 +87,13 @@ class _MaxButton extends DexSmallButton { context.read().add(TakerAmountButtonClick(1)); }); } + +class _ExactButton extends DexSmallButton { + _ExactButton() + : super(LocaleKeys.exact.tr(), (context) { + final state = context.read().state; + final order = state.selectedOrder; + if (order == null) return; + context.read().add(TakerSetSellAmount(order.maxVolume)); + }); +} 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 ff6d4f1de3..3b9da98bba 100644 --- a/lib/views/dex/simple/form/taker/taker_form_content.dart +++ b/lib/views/dex/simple/form/taker/taker_form_content.dart @@ -127,8 +127,16 @@ class TradeButton extends StatelessWidget { systemHealthState.isValid; final tradingStatusState = context.watch().state; - - final isTradingEnabled = tradingStatusState.isEnabled; + final takerState = context.watch().state; + final coinsRepo = RepositoryProvider.of(context); + final buyCoin = takerState.selectedOrder == null + ? null + : coinsRepo.getCoin(takerState.selectedOrder!.coin); + + final isTradingEnabled = tradingStatusState.canTradeAssets([ + takerState.sellCoin?.id, + buyCoin?.id, + ]); return BlocSelector( selector: (state) => state.inProgress, diff --git a/lib/views/fiat/fiat_currency_list_tile.dart b/lib/views/fiat/fiat_currency_list_tile.dart index 0cab8b8e2f..fa2f5cadc2 100644 --- a/lib/views/fiat/fiat_currency_list_tile.dart +++ b/lib/views/fiat/fiat_currency_list_tile.dart @@ -21,7 +21,9 @@ class FiatCurrencyListTile extends StatelessWidget { @override Widget build(BuildContext context) { final coinType = currency.isCrypto - ? getCoinTypeName((currency as CryptoCurrency).chainType) + ? getCoinTypeName( + (currency as CryptoCurrency).chainType, + (currency as CryptoCurrency).symbol) : ''; return ListTile( diff --git a/lib/views/fiat/fiat_inputs.dart b/lib/views/fiat/fiat_inputs.dart index 6b80e9e833..8ed83e5f4b 100644 --- a/lib/views/fiat/fiat_inputs.dart +++ b/lib/views/fiat/fiat_inputs.dart @@ -1,13 +1,15 @@ +import 'package:decimal/decimal.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.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:komodo_ui/komodo_ui.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart' show KomodoDefiSdk; +import 'package:komodo_defi_types/komodo_defi_types.dart' + show PubkeyInfo, AssetPubkeys; +import 'package:komodo_ui/komodo_ui.dart' show Debouncer, SourceAddressField; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/fiat/models/fiat_price_info.dart'; import 'package:web_dex/bloc/fiat/models/i_currency.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; -import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/views/fiat/custom_fiat_input_field.dart'; import 'package:web_dex/views/fiat/fiat_currency_item.dart'; import 'package:web_dex/views/fiat/fiat_icon.dart'; @@ -57,17 +59,20 @@ class FiatInputs extends StatefulWidget { class FiatInputsState extends State { TextEditingController fiatController = TextEditingController(); + late final Debouncer _debouncer; + bool _hasUserInput = false; @override void dispose() { fiatController.dispose(); - + _debouncer.dispose(); super.dispose(); } @override void initState() { super.initState(); + _debouncer = Debouncer(duration: const Duration(milliseconds: 300)); fiatController.text = widget.initialFiatAmount?.toString() ?? ''; } @@ -75,14 +80,20 @@ class FiatInputsState extends State { void didUpdateWidget(FiatInputs oldWidget) { super.didUpdateWidget(oldWidget); + // Reset _hasUserInput flag when asset or fiat currency changes + if (oldWidget.selectedAsset != widget.selectedAsset || + oldWidget.initialFiat != widget.initialFiat) { + _hasUserInput = false; + } + final Decimal? newFiatAmount = widget.initialFiatAmount; // Convert the current text to Decimal for comparison final Decimal currentFiatAmount = Decimal.tryParse(fiatController.text) ?? Decimal.zero; - // Compare using Decimal values - if (newFiatAmount != currentFiatAmount) { + // Only update if user hasn't made changes or if amounts are different + if (!_hasUserInput && newFiatAmount != currentFiatAmount) { final newFiatAmountText = newFiatAmount?.toString() ?? ''; fiatController ..text = newFiatAmountText @@ -105,7 +116,16 @@ class FiatInputsState extends State { } void fiatAmountChanged(String? newValue) { - widget.onFiatAmountUpdate(newValue); + // track if user has made inputs to avoid overwriting them + // with stale bloc state updates (e.g. race condition) + _hasUserInput = true; + _debouncer.run(() { + if (mounted) { + widget.onFiatAmountUpdate(newValue); + // Reset flag after API call to allow future bloc state updates + _hasUserInput = false; + } + }); } @override @@ -125,8 +145,8 @@ class FiatInputsState extends State { final maxFiatAmount = widget.fiatMaxAmount?.toStringAsFixed(2); final boundariesString = widget.fiatMaxAmount == null && widget.fiatMinAmount == null - ? '' - : '(${minFiatAmount ?? '1'} - ${maxFiatAmount ?? '∞'})'; + ? '' + : '(${minFiatAmount ?? '1'} - ${maxFiatAmount ?? '∞'})'; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -158,8 +178,10 @@ class FiatInputsState extends State { margin: EdgeInsets.zero, color: Theme.of(context).colorScheme.onSurface, child: ListTile( - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), subtitle: Padding( padding: const EdgeInsets.only(top: 6.0), child: Row( @@ -174,10 +196,9 @@ class FiatInputsState extends State { text: fiatController.text.isEmpty || priceInfo == null ? '0.00' : coinAmount ?? '0.00', - style: Theme.of(context) - .textTheme - .headlineMedium - ?.copyWith(fontSize: 24), + style: Theme.of( + context, + ).textTheme.headlineMedium?.copyWith(fontSize: 24), ), ], ), @@ -187,8 +208,9 @@ class FiatInputsState extends State { height: 48, child: FiatCurrencyItem( key: const Key('fiat-onramp-coin-dropdown'), - foregroundColor: - Theme.of(context).colorScheme.onSurfaceVariant, + foregroundColor: Theme.of( + context, + ).colorScheme.onSurfaceVariant, disabled: coinListLoading, currency: widget.selectedAsset, icon: Icon(_getDefaultAssetIcon('coin')), @@ -268,8 +290,9 @@ class FiatInputsState extends State { final item = itemList.elementAt(index); return FiatCurrencyItem( key: Key('fiat-onramp-currency-item-${item.symbol}'), - foregroundColor: - Theme.of(context).colorScheme.onSurfaceVariant, + foregroundColor: Theme.of( + context, + ).colorScheme.onSurfaceVariant, disabled: false, currency: item, icon: icon, diff --git a/lib/views/fiat/fiat_select_button.dart b/lib/views/fiat/fiat_select_button.dart index e8f19a84ff..fd44a9a517 100644 --- a/lib/views/fiat/fiat_select_button.dart +++ b/lib/views/fiat/fiat_select_button.dart @@ -28,42 +28,44 @@ class FiatSelectButton extends StatelessWidget { @override Widget build(BuildContext context) { final isFiat = currency?.isFiat ?? false; + final cryptoCurrency = currency is CryptoCurrency + ? currency as CryptoCurrency + : null; return FilledButton.icon( onPressed: enabled ? onTap : null, label: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (isFiat) - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + (isFiat ? currency?.getAbbr() : currency?.name) ?? + (isFiat + ? LocaleKeys.selectFiat.tr() + : LocaleKeys.selectCoin.tr()), + style: DefaultTextStyle.of(context).style.copyWith( + fontWeight: FontWeight.w500, + color: enabled + ? foregroundColor + : foregroundColor.withValues(alpha: 0.5), + ), + ), + if (!isFiat && cryptoCurrency != null) Text( - (isFiat ? currency?.getAbbr() : currency?.name) ?? - (isFiat - ? LocaleKeys.selectFiat.tr() - : LocaleKeys.selectCoin.tr()), + getCoinTypeName( + cryptoCurrency.chainType, + cryptoCurrency.symbol, + ), style: DefaultTextStyle.of(context).style.copyWith( - fontWeight: FontWeight.w500, - color: enabled - ? foregroundColor - : foregroundColor.withValues(alpha: 0.5), - ), - ), - if (!isFiat && currency != null) - Text( - (currency! as CryptoCurrency).isCrypto - ? getCoinTypeName( - (currency! as CryptoCurrency).chainType) - : '', - style: DefaultTextStyle.of(context).style.copyWith( - color: enabled - ? foregroundColor.withValues(alpha: 0.5) - : foregroundColor.withValues(alpha: 0.25), - ), + color: enabled + ? foregroundColor.withValues(alpha: 0.5) + : foregroundColor.withValues(alpha: 0.25), ), - ], - ), + ), + ], + ), const SizedBox(width: 4), Icon( Icons.keyboard_arrow_down, @@ -74,18 +76,16 @@ class FiatSelectButton extends StatelessWidget { ), style: (Theme.of(context).filledButtonTheme.style ?? const ButtonStyle()) .copyWith( - backgroundColor: WidgetStateProperty.all( - Theme.of(context).colorScheme.onSurface, - ), - padding: WidgetStateProperty.all( - const EdgeInsets.symmetric(), - ), - shape: WidgetStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + backgroundColor: WidgetStateProperty.all( + Theme.of(context).colorScheme.onSurface, + ), + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric(), + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), ), - ), - ), icon: currency == null ? Icon(_getDefaultAssetIcon(isFiat ? 'fiat' : 'coin')) : FiatAssetIcon( diff --git a/lib/views/fiat/webview_dialog.dart b/lib/views/fiat/webview_dialog.dart index edb1734791..d586ffee6d 100644 --- a/lib/views/fiat/webview_dialog.dart +++ b/lib/views/fiat/webview_dialog.dart @@ -6,6 +6,7 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:web_dex/common/screen.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/utils/window/window.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; /// The display mode for the webview dialog. enum WebViewDialogMode { @@ -155,12 +156,14 @@ class InAppWebviewDialog extends StatelessWidget { bottomLeft: Radius.circular(12.0), bottomRight: Radius.circular(12.0), ), - child: MessageInAppWebView( - key: const Key('dialog-inappwebview'), - settings: webviewSettings, - url: url, - onConsoleMessage: onConsoleMessage, - onCloseWindow: onCloseWindow, + child: ScreenshotSensitive( + child: MessageInAppWebView( + key: const Key('dialog-inappwebview'), + settings: webviewSettings, + url: url, + onConsoleMessage: onConsoleMessage, + onCloseWindow: onCloseWindow, + ), ), ), ), @@ -203,12 +206,14 @@ class FullscreenInAppWebview extends StatelessWidget { ), ), body: SafeArea( - child: MessageInAppWebView( - key: const Key('fullscreen-inapp-webview'), - settings: webviewSettings, - url: url, - onConsoleMessage: onConsoleMessage, - onCloseWindow: onCloseWindow, + child: ScreenshotSensitive( + child: MessageInAppWebView( + key: const Key('fullscreen-inapp-webview'), + settings: webviewSettings, + url: url, + onConsoleMessage: onConsoleMessage, + onCloseWindow: onCloseWindow, + ), ), ), ); diff --git a/lib/views/main_layout/main_layout.dart b/lib/views/main_layout/main_layout.dart index 4ea063e2c9..a64f61f82a 100644 --- a/lib/views/main_layout/main_layout.dart +++ b/lib/views/main_layout/main_layout.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:easy_localization/easy_localization.dart'; + import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; @@ -10,17 +11,18 @@ import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; import 'package:web_dex/blocs/update_bloc.dart'; import 'package:web_dex/common/screen.dart'; + import 'package:web_dex/model/authorize_mode.dart'; import 'package:web_dex/router/navigators/main_layout/main_layout_router.dart'; import 'package:web_dex/router/state/routing_state.dart'; import 'package:web_dex/services/alpha_version_alert_service/alpha_version_alert_service.dart'; import 'package:web_dex/services/feedback/feedback_service.dart'; +import 'package:web_dex/shared/utils/window/window.dart'; +import 'package:web_dex/views/common/main_menu/main_menu_bar_mobile.dart'; import 'package:web_dex/bloc/coins_manager/coins_manager_bloc.dart'; import 'package:web_dex/router/state/wallet_state.dart'; import 'package:web_dex/model/main_menu_value.dart'; -import 'package:web_dex/shared/utils/window/window.dart'; -import 'package:web_dex/views/common/header/app_header.dart'; -import 'package:web_dex/views/common/main_menu/main_menu_bar_mobile.dart'; +import 'package:web_dex/shared/widgets/quick_login_switch.dart'; class MainLayout extends StatefulWidget { const MainLayout({super.key}); @@ -42,8 +44,17 @@ class _MainLayoutState extends State { await AlphaVersionWarningService().run(); await updateBloc.init(); + if (mounted) { + try { + await QuickLoginSwitch.maybeShowRememberedWallet(context); + } catch (e) { + // If showing the remembered wallet dialog fails, we continue normally + // The error has already been logged by RememberWalletService + } + } + if (!mounted) return; - final tradingEnabled = tradingStatusBloc.state is TradingEnabled; + final tradingEnabled = tradingStatusBloc.state.isEnabled; if (tradingEnabled && kShowTradingWarning && !await _hasAgreedNoTrading()) { @@ -54,36 +65,54 @@ class _MainLayoutState extends State { super.initState(); } + @override + void dispose() { + super.dispose(); + } + @override Widget build(BuildContext context) { - return BlocConsumer(listener: (context, state) { - if (state.mode == AuthorizeMode.noLogin) { - routingState.resetOnLogOut(); - } - }, builder: (context, state) { - final isAuthenticated = state.mode == AuthorizeMode.logIn; - - return LayoutBuilder( - builder: (context, constraints) { - return Scaffold( - key: scaffoldKey, - floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, - appBar: null, - body: SafeArea(child: MainLayoutRouter()), - bottomNavigationBar: - (isMobile || isTablet) ? MainMenuBarMobile() : null, - floatingActionButton: MainLayoutFab( - showAddCoinButton: routingState.selectedMenu == - MainMenuValue.wallet && - routingState.walletState.selectedCoin.isEmpty && - routingState.walletState.action.isEmpty && - context.watch().state.mode == AuthorizeMode.logIn, - isMini: isMobile, - ), - ); - }, - ); - }); + return BlocConsumer( + listener: (context, state) { + // Track when user has been logged in + if (state.mode == AuthorizeMode.logIn) { + QuickLoginSwitch.trackUserLoggedIn(); + } + + // Only reset the remember me dialog flag if user was logged in and is now logged out + // This prevents the flag from being reset on initial app startup + if (state.mode == AuthorizeMode.noLogin && + QuickLoginSwitch.hasBeenLoggedInThisSession) { + routingState.resetOnLogOut(); + // Reset dialog state so it can be shown again after user logs out + QuickLoginSwitch.resetOnLogout(); + } + }, + builder: (context, state) { + return LayoutBuilder( + builder: (context, constraints) { + return Scaffold( + key: scaffoldKey, + floatingActionButtonLocation: + FloatingActionButtonLocation.endFloat, + appBar: null, + body: SafeArea(child: MainLayoutRouter()), + bottomNavigationBar: (isMobile || isTablet) + ? MainMenuBarMobile() + : null, + floatingActionButton: MainLayoutFab( + showAddCoinButton: + routingState.selectedMenu == MainMenuValue.wallet && + routingState.walletState.selectedCoin.isEmpty && + routingState.walletState.action.isEmpty && + context.watch().state.mode == AuthorizeMode.logIn, + isMini: isMobile, + ), + ); + }, + ); + }, + ); } // Method to show an alert dialog with an option to agree if the app is in @@ -123,8 +152,11 @@ class _MainLayoutState extends State { } class MainLayoutFab extends StatelessWidget { - const MainLayoutFab( - {super.key, required this.showAddCoinButton, required this.isMini}); + const MainLayoutFab({ + super.key, + required this.showAddCoinButton, + required this.isMini, + }); final bool showAddCoinButton; final bool isMini; @@ -139,14 +171,12 @@ class MainLayoutFab extends StatelessWidget { child: UiGradientButton( onPressed: () { context.read().add( - const CoinsManagerCoinsListReset(CoinsManagerAction.add)); + const CoinsManagerCoinsListReset(CoinsManagerAction.add), + ); routingState.walletState.action = coinsManagerRouteAction.addAssets; }, - child: const Icon( - Icons.add_rounded, - size: 36, - ), + child: const Icon(Icons.add_rounded, size: 36), ), ), ) @@ -159,10 +189,12 @@ class MainLayoutFab extends StatelessWidget { dimension: isMini ? 48 : 58, child: UiGradientButton( isMini: isMini, - gradient: LinearGradient(colors: [ - Theme.of(context).primaryColor, - Theme.of(context).primaryColor - ]), + gradient: LinearGradient( + colors: [ + Theme.of(context).primaryColor, + Theme.of(context).primaryColor, + ], + ), onPressed: () => context.showFeedback(), child: const Icon(Icons.bug_report, size: 24), ), @@ -173,11 +205,7 @@ class MainLayoutFab extends StatelessWidget { if (feedbackFab != null && addAssetsFab != null) { return Column( mainAxisSize: MainAxisSize.min, - children: [ - feedbackFab, - const SizedBox(height: 16), - addAssetsFab, - ], + children: [feedbackFab, const SizedBox(height: 16), addAssetsFab], ); } 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 0d613d65b9..6466729310 100644 --- a/lib/views/main_layout/widgets/main_layout_top_bar.dart +++ b/lib/views/main_layout/widgets/main_layout_top_bar.dart @@ -1,6 +1,6 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:easy_localization/easy_localization.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'; @@ -21,10 +21,7 @@ class MainLayoutTopBar extends StatelessWidget { return Container( decoration: BoxDecoration( border: Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor, - width: 1, - ), + bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1), ), ), child: AppBar( @@ -33,12 +30,20 @@ class MainLayoutTopBar extends StatelessWidget { elevation: 0, leading: BlocBuilder( builder: (context, state) { + final totalBalance = _getTotalBalance( + state.walletCoins.values, + context, + ); + + if (totalBalance == null) { + return const SizedBox.shrink(); + } + return Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: ActionTextButton( text: LocaleKeys.balance.tr(), - secondaryText: - '\$${formatAmt(_getTotalBalance(state.walletCoins.values, context))}', + secondaryText: '\$${formatAmt(totalBalance)}', onTap: null, ), ); @@ -51,9 +56,19 @@ class MainLayoutTopBar extends StatelessWidget { ); } - double _getTotalBalance(Iterable coins, BuildContext context) { + 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)); + 0, + (prev, coin) => prev + (coin.usdBalance(context.sdk) ?? 0), + ); if (total > 0.01) { return total; @@ -66,29 +81,27 @@ class MainLayoutTopBar extends StatelessWidget { final languageCodes = localeList.map((e) => e.languageCode).toList(); final langCode2flags = { for (var loc in languageCodes) - loc: SvgPicture.asset( - '$assetsPath/flags/$loc.svg', - ), + loc: SvgPicture.asset('$assetsPath/flags/$loc.svg'), }; return [ Padding( padding: const EdgeInsets.symmetric(horizontal: 32), - child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ - if (showLanguageSwitcher) ...[ - LanguageSwitcher( - currentLocale: context.locale.toString(), - languageCodes: languageCodes, - flags: langCode2flags, - ), - const SizedBox(width: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (showLanguageSwitcher) ...[ + LanguageSwitcher( + currentLocale: context.locale.toString(), + languageCodes: languageCodes, + flags: langCode2flags, + ), + const SizedBox(width: 16), + ], + SizedBox(height: 40, child: const AccountSwitcher()), ], - SizedBox( - height: 40, - child: const AccountSwitcher(), - ), - ]), - ) + ), + ), ]; } } diff --git a/lib/views/market_maker_bot/add_market_maker_bot_trade_button.dart b/lib/views/market_maker_bot/add_market_maker_bot_trade_button.dart index ddf22b7c39..47659ef945 100644 --- a/lib/views/market_maker_bot/add_market_maker_bot_trade_button.dart +++ b/lib/views/market_maker_bot/add_market_maker_bot_trade_button.dart @@ -5,16 +5,21 @@ import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; class AddMarketMakerBotTradeButton extends StatelessWidget { const AddMarketMakerBotTradeButton({ super.key, required this.onPressed, this.enabled = true, + this.sellCoin, + this.buyCoin, }); final bool enabled; final VoidCallback onPressed; + final Coin? sellCoin; + final Coin? buyCoin; @override Widget build(BuildContext context) { @@ -22,7 +27,10 @@ class AddMarketMakerBotTradeButton extends StatelessWidget { builder: (context, systemHealthState) { final tradingStatusBloc = context.watch(); - final bool tradingEnabled = tradingStatusBloc.state.isEnabled; + final bool tradingEnabled = tradingStatusBloc.state.canTradeAssets([ + sellCoin?.id, + buyCoin?.id, + ]); return Opacity( opacity: !enabled ? 0.8 : 1, diff --git a/lib/views/market_maker_bot/coin_search_dropdown.dart b/lib/views/market_maker_bot/coin_search_dropdown.dart index eef9e49720..5c8e1a5138 100644 --- a/lib/views/market_maker_bot/coin_search_dropdown.dart +++ b/lib/views/market_maker_bot/coin_search_dropdown.dart @@ -3,10 +3,10 @@ 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_ui/komodo_ui.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/shared/widgets/coin_item/coin_item_body.dart'; -import 'package:komodo_ui/komodo_ui.dart'; bool doesCoinMatchSearch(String searchQuery, DropdownMenuItem item) { final lowerCaseQuery = searchQuery.toLowerCase(); @@ -37,10 +37,7 @@ Future showCoinSearch( if (isMobile) { return showSearch( context: context, - delegate: SearchableSelectorDelegate( - items, - searchHint: 'Search coins', - ), + delegate: SearchableSelectorDelegate(items, searchHint: 'Search coins'), ); } else { return showSearchableSelect( @@ -99,12 +96,15 @@ class _CoinDropdownState extends State { final availableHeightBelow = screenSize.height - offset.dy - size.height; final availableHeightAbove = offset.dy; - final showAbove = availableHeightBelow < widget.items.length * 48 && + final showAbove = + availableHeightBelow < widget.items.length * 48 && availableHeightAbove > availableHeightBelow; final dropdownHeight = - (showAbove ? availableHeightAbove : availableHeightBelow) - .clamp(100.0, 330.0); + (showAbove ? availableHeightAbove : availableHeightBelow).clamp( + 100.0, + 330.0, + ); return OverlayEntry( builder: (context) { @@ -145,14 +145,16 @@ class _CoinDropdownState extends State { @override Widget build(BuildContext context) { final coinsRepository = RepositoryProvider.of(context); - final coin = - selectedItem == null ? null : coinsRepository.getCoin(selectedItem!); + final coin = selectedItem == null + ? null + : coinsRepository.getCoin(selectedItem!); return CompositedTransformTarget( link: _layerLink, child: InkWell( onTap: _showSearch, - child: widget.child ?? + child: + widget.child ?? Padding( padding: const EdgeInsets.only(left: 15), child: CoinItemBody(coin: coin), @@ -239,15 +241,7 @@ class _SearchableDropdownState extends State<_SearchableDropdown> { itemBuilder: (context, index) { final item = filteredItems[index]; return ListTile( - leading: item.child is Row - ? (item.child as Row).children.first - : item.child, - title: item.child is Row - ? Row( - children: - (item.child as Row).children.skip(1).toList(), - ) - : null, + title: item.child, onTap: () => widget.onItemSelected(item.value), ); }, diff --git a/lib/views/market_maker_bot/coin_selection_and_amount_input.dart b/lib/views/market_maker_bot/coin_selection_and_amount_input.dart index 8a208ed602..b363b65451 100644 --- a/lib/views/market_maker_bot/coin_selection_and_amount_input.dart +++ b/lib/views/market_maker_bot/coin_selection_and_amount_input.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/bloc/settings/settings_bloc.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/shared/widgets/coin_item/coin_item_body.dart'; import 'package:web_dex/shared/widgets/coin_item/coin_item_size.dart'; @@ -39,6 +40,11 @@ class CoinSelectionAndAmountInput extends StatefulWidget { class _CoinSelectionAndAmountInputState extends State { + // TECH DEBT: This widget uses StatefulWidget with local state (_items) + // which is an anti-pattern when the data depends on Bloc state. + // Following BLoC best practices, this should be refactored to use BlocBuilder + // and compute the items in the build method based on bloc state changes. + // Currently, we work around this by wrapping the widget in BlocBuilder below. late List> _items; @override @@ -57,73 +63,87 @@ class _CoinSelectionAndAmountInputState late final _sdk = context.read(); void _prepareItems() { - _items = prepareCoinsForTable( - context, - widget.coins, - null, - testCoinsEnabled: context.read().state.testCoinsEnabled, - ) - .map( - (coin) => DropdownMenuItem( - value: coin.abbr, - child: CoinSelectItemWidget( - name: coin.name, - coinId: coin.abbr, - trailing: AssetBalanceText(coin.toSdkAsset(_sdk).id, - activateIfNeeded: false), - title: CoinItemBody(coin: coin, size: CoinItemSize.large), - ), - ), - ) - .toList(); + _items = + prepareCoinsForTable( + context, + widget.coins, + null, + testCoinsEnabled: context + .read() + .state + .testCoinsEnabled, + ) + .map( + (coin) => DropdownMenuItem( + value: coin.abbr, + child: CoinSelectItemWidget( + name: coin.displayName, + coinId: coin.abbr, + trailing: AssetBalanceText( + coin.toSdkAsset(_sdk).id, + activateIfNeeded: false, + ), + title: CoinItemBody(coin: coin, size: CoinItemSize.large), + ), + ), + ) + .toList(); } @override Widget build(BuildContext context) { - Widget content = Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DexFormGroupHeader( - title: widget.title, - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.fromLTRB(15, 8, 0, 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - mainAxisSize: MainAxisSize.min, + // FIX: Using BlocBuilder to listen to TradingStatusBloc changes + // This ensures _prepareItems is called when geo-blocking status changes. + // Following BLoC best practices: widgets should rebuild when dependent bloc states change. + return BlocBuilder( + builder: (context, tradingStatus) { + // Rebuild items when trading status changes + _prepareItems(); + + Widget content = Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DexFormGroupHeader(title: widget.title), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(15, 8, 0, 12), + child: Row( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - widget.selectedCoin == null - ? AssetLogo.placeholder(isBlank: true) - : AssetLogo.ofId(widget.selectedCoin!.id), - const SizedBox(width: 9), - CoinNameAndProtocol(widget.selectedCoin, true), - const SizedBox(width: 9), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + widget.selectedCoin == null + ? AssetLogo.placeholder(isBlank: true) + : AssetLogo.ofId(widget.selectedCoin!.id), + const SizedBox(width: 9), + CoinNameAndProtocol(widget.selectedCoin, true), + const SizedBox(width: 9), + ], + ), + const SizedBox(width: 5), + Expanded(child: widget.trailing ?? const SizedBox.shrink()), ], ), - const SizedBox(width: 5), - Expanded(child: widget.trailing ?? const SizedBox.shrink()), - ], - ), - ), - ], - ); + ), + ], + ); - if (widget.useFrontPlate) { - content = FrontPlate(child: content); - } + if (widget.useFrontPlate) { + content = FrontPlate(child: content); + } - final coinsRepository = RepositoryProvider.of(context); - return CoinDropdown( - items: _items, - onItemSelected: (item) => - widget.onItemSelected?.call(coinsRepository.getCoin(item)), - child: content, + final coinsRepository = RepositoryProvider.of(context); + return CoinDropdown( + items: _items, + onItemSelected: (item) => + widget.onItemSelected?.call(coinsRepository.getCoin(item)), + child: content, + ); + }, ); } } diff --git a/lib/views/market_maker_bot/market_maker_bot_confirmation_form.dart b/lib/views/market_maker_bot/market_maker_bot_confirmation_form.dart index 67588caf10..51d14a2f66 100644 --- a/lib/views/market_maker_bot/market_maker_bot_confirmation_form.dart +++ b/lib/views/market_maker_bot/market_maker_bot_confirmation_form.dart @@ -20,6 +20,8 @@ import 'package:web_dex/views/dex/simple/form/exchange_info/exchange_rate.dart'; import 'package:web_dex/views/dex/simple/form/exchange_info/total_fees.dart'; import 'package:web_dex/views/market_maker_bot/important_note.dart'; import 'package:web_dex/views/market_maker_bot/market_maker_form_error_message_extensions.dart'; +import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; +import 'package:web_dex/analytics/events/market_bot_events.dart'; class MarketMakerBotConfirmationForm extends StatefulWidget { const MarketMakerBotConfirmationForm({ @@ -40,9 +42,9 @@ class _MarketMakerBotConfirmationFormState extends State { @override void initState() { - context - .read() - .add(const MarketMakerConfirmationPreviewRequested()); + context.read().add( + const MarketMakerConfirmationPreviewRequested(), + ); super.initState(); } @@ -63,9 +65,26 @@ class _MarketMakerBotConfirmationFormState return const SizedBox(); } - final hasError = state.tradePreImageError != null || + final hasError = + state.tradePreImageError != null || state.status == MarketMakerTradeFormStatus.error; + final VoidCallback? onCreateOrderCallback = hasError + ? null + : () { + final sellAmt = state.sellAmount.valueAsRational.toDouble(); + final price = + state.priceFromUsd ?? state.priceFromUsdWithMargin ?? 0.0; + final baseCapital = price > 0 ? sellAmt * price : sellAmt; + context.read().logEvent( + MarketbotSetupCompleteEventData( + strategyType: 'simple', + baseCapital: baseCapital, + ), + ); + widget.onCreateOrder(); + }; + return SingleChildScrollView( key: const Key('maker-order-conformation-scroll'), controller: ScrollController(), @@ -75,15 +94,15 @@ class _MarketMakerBotConfirmationFormState children: [ SelectableText( LocaleKeys.mmBotFirstTradePreview.tr(), - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontSize: 16), + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontSize: 16), ), const SizedBox(height: 37), ImportantNote( - text: LocaleKeys.mmBotFirstOrderVolume - .tr(args: [state.sellCoin.value?.abbr ?? '']), + text: LocaleKeys.mmBotFirstOrderVolume.tr( + args: [state.sellCoin.value?.abbr ?? ''], + ), ), const SizedBox(height: 10), SwapReceiveAmount( @@ -114,14 +133,18 @@ class _MarketMakerBotConfirmationFormState TotalFees(preimage: state.tradePreImage), const SizedBox(height: 24), SwapErrorMessage( - errorMessage: state.tradePreImageError - ?.text(state.sellCoin.value, state.buyCoin.value), + errorMessage: state.tradePreImageError?.text( + state.sellCoin.value, + state.buyCoin.value, + ), context: context, ), Flexible( child: SwapActionButtons( onCancel: widget.onCancel, - onCreateOrder: hasError ? null : widget.onCreateOrder, + onCreateOrder: onCreateOrderCallback, + sellCoin: state.sellCoin.value, + buyCoin: state.buyCoin.value, ), ), ], @@ -138,24 +161,28 @@ class SwapActionButtons extends StatelessWidget { super.key, required this.onCancel, required this.onCreateOrder, + required this.sellCoin, + required this.buyCoin, }); final VoidCallback onCancel; final VoidCallback? onCreateOrder; + final Coin? sellCoin; + final Coin? buyCoin; @override Widget build(BuildContext context) { final tradingStatusBloc = context.watch(); - final bool tradingEnabled = tradingStatusBloc.state is TradingEnabled; + final bool tradingEnabled = tradingStatusBloc.state.canTradeAssets([ + sellCoin?.id, + buyCoin?.id, + ]); return Row( children: [ Flexible( - child: UiLightButton( - onPressed: onCancel, - text: LocaleKeys.back.tr(), - ), + child: UiLightButton(onPressed: onCancel, text: LocaleKeys.back.tr()), ), const SizedBox(width: 23), Flexible( @@ -191,10 +218,9 @@ class SwapErrorMessage extends StatelessWidget { padding: const EdgeInsets.fromLTRB(8, 0, 8, 20), child: Text( message, - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(color: Theme.of(context).colorScheme.error), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), ), ); } @@ -215,10 +241,7 @@ class SwapSendAmount extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.symmetric( - vertical: 14, - horizontal: 16, - ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), color: theme.custom.subCardBackgroundColor, @@ -230,8 +253,8 @@ class SwapSendAmount extends StatelessWidget { SelectableText( LocaleKeys.swapConfirmationYouSending.tr(), style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: theme.custom.dexSubTitleColor, - ), + color: theme.custom.dexSubTitleColor, + ), ), const SizedBox(height: 10), Row( @@ -247,9 +270,9 @@ class SwapSendAmount extends StatelessWidget { SelectableText( formatDexAmt(amount), style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontSize: 14.0, - fontWeight: FontWeight.w500, - ), + fontSize: 14.0, + fontWeight: FontWeight.w500, + ), ), SwapFiatSendAmount(coin: coin, amount: amount), ], @@ -305,10 +328,14 @@ class SwapFiatReceivedAmount extends StatelessWidget { Color? color = Theme.of(context).textTheme.bodyMedium?.color; double? percentage; - final double sellAmtFiat = - getFiatAmount(sellCoin, sellAmount ?? Rational.zero); - final double receiveAmtFiat = - getFiatAmount(buyCoin, buyAmount ?? Rational.zero); + final double sellAmtFiat = getFiatAmount( + sellCoin, + sellAmount ?? Rational.zero, + ); + final double receiveAmtFiat = getFiatAmount( + buyCoin, + buyAmount ?? Rational.zero, + ); if (sellAmtFiat < receiveAmtFiat) { color = theme.custom.increaseColor; @@ -328,10 +355,10 @@ class SwapFiatReceivedAmount extends StatelessWidget { Text( ' (${percentage > 0 ? '+' : ''}${formatAmt(percentage)}%)', style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: 11, - color: color, - fontWeight: FontWeight.w200, - ), + fontSize: 11, + color: color, + fontWeight: FontWeight.w200, + ), ), ], ); @@ -356,26 +383,22 @@ class SwapReceiveAmount extends StatelessWidget { children: [ SelectableText( LocaleKeys.swapConfirmationYouReceive.tr(), - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: theme.custom.dexSubTitleColor, - ), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: theme.custom.dexSubTitleColor), ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ SelectableText( '${formatDexAmt(amount)} ', - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - ), + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700), ), SelectableText( Coin.normalizeAbbr(coin.abbr), - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: theme.custom.balanceColor), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: theme.custom.balanceColor, + ), ), if (coin.mode == CoinMode.segwit) const Padding( 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 04387265ce..3f27e1190c 100644 --- a/lib/views/market_maker_bot/market_maker_bot_form.dart +++ b/lib/views/market_maker_bot/market_maker_bot_form.dart @@ -19,17 +19,20 @@ class MarketMakerBotForm extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocSelector( + return BlocSelector< + MarketMakerTradeFormBloc, + MarketMakerTradeFormState, + MarketMakerTradeFormStage + >( selector: (state) => state.stage, builder: (context, formStage) { if (formStage == MarketMakerTradeFormStage.confirmationRequired) { return MarketMakerBotConfirmationForm( onCreateOrder: () => _onCreateOrderPressed(context), onCancel: () { - context - .read() - .add(const MarketMakerConfirmationPreviewCancelRequested()); + context.read().add( + const MarketMakerConfirmationPreviewCancelRequested(), + ); }, ); } @@ -45,9 +48,9 @@ class MarketMakerBotForm extends StatelessWidget { final marketMakerTradeFormBloc = context.read(); final tradePair = marketMakerTradeFormBloc.state.toTradePairConfig(); - context - .read() - .add(MarketMakerBotOrderUpdateRequested(tradePair)); + context.read().add( + MarketMakerBotOrderUpdateRequested(tradePair), + ); context.read().add(const TabChanged(2)); @@ -55,12 +58,34 @@ class MarketMakerBotForm extends StatelessWidget { } } -class _MakerFormDesktopLayout extends StatelessWidget { +class _MakerFormDesktopLayout extends StatefulWidget { const _MakerFormDesktopLayout(); + @override + State<_MakerFormDesktopLayout> createState() => + _MakerFormDesktopLayoutState(); +} + +class _MakerFormDesktopLayoutState extends State<_MakerFormDesktopLayout> { + late final ScrollController _mainScrollController; + late final ScrollController _orderbookScrollController; + + @override + void initState() { + super.initState(); + _mainScrollController = ScrollController(); + _orderbookScrollController = ScrollController(); + } + + @override + void dispose() { + _mainScrollController.dispose(); + _orderbookScrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - final scrollController = ScrollController(); return Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.center, @@ -73,20 +98,22 @@ class _MakerFormDesktopLayout extends StatelessWidget { Flexible( flex: 6, child: DexScrollbar( - scrollController: scrollController, + scrollController: _mainScrollController, isMobile: isMobile, child: SingleChildScrollView( key: const Key('maker-form-layout-scroll'), - controller: scrollController, + controller: _mainScrollController, child: ConstrainedBox( - constraints: - BoxConstraints(maxWidth: theme.custom.dexFormWidth), + constraints: BoxConstraints( + maxWidth: theme.custom.dexFormWidth, + ), child: BlocBuilder( builder: (context, state) { final coins = state.walletCoins.values - .where( - (e) => e.usdPrice != null && e.usdPrice!.price > 0, - ) + .where((e) { + final usdPrice = e.usdPrice?.price?.toDouble() ?? 0.0; + return usdPrice > 0; + }) .cast() .toList(); return MarketMakerBotFormContent(coins: coins); @@ -101,7 +128,7 @@ class _MakerFormDesktopLayout extends StatelessWidget { child: Padding( padding: const EdgeInsets.only(left: 20), child: SingleChildScrollView( - controller: ScrollController(), + controller: _orderbookScrollController, child: const MarketMakerBotOrderbook(), ), ), @@ -111,14 +138,33 @@ class _MakerFormDesktopLayout extends StatelessWidget { } } -class _MakerFormMobileLayout extends StatelessWidget { +class _MakerFormMobileLayout extends StatefulWidget { const _MakerFormMobileLayout(); + @override + State<_MakerFormMobileLayout> createState() => _MakerFormMobileLayoutState(); +} + +class _MakerFormMobileLayoutState extends State<_MakerFormMobileLayout> { + late final ScrollController _scrollController; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return SingleChildScrollView( key: const Key('maker-form-layout-scroll'), - controller: ScrollController(), + controller: _scrollController, child: ConstrainedBox( constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), child: Column( @@ -127,9 +173,10 @@ class _MakerFormMobileLayout extends StatelessWidget { BlocBuilder( builder: (context, state) { final coins = state.walletCoins.values - .where( - (e) => e.usdPrice != null && e.usdPrice!.price > 0, - ) + .where((e) { + final usdPrice = e.usdPrice?.price?.toDouble() ?? 0.0; + return usdPrice > 0; + }) .cast() .toList(); return MarketMakerBotFormContent(coins: coins); @@ -145,9 +192,7 @@ class _MakerFormMobileLayout extends StatelessWidget { } class MarketMakerBotOrderbook extends StatelessWidget { - const MarketMakerBotOrderbook({ - super.key, - }); + const MarketMakerBotOrderbook({super.key}); @override Widget build(BuildContext context) { @@ -185,7 +230,7 @@ Order? _getMyOrder(BuildContext context, Rational? price) { } void _onAskClick(BuildContext context, Order order) { - context - .read() - .add(MarketMakerTradeFormAskOrderbookSelected(order)); + context.read().add( + MarketMakerTradeFormAskOrderbookSelected(order), + ); } 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 1e4792a016..d3abe49c99 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 @@ -3,7 +3,9 @@ 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/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'; @@ -47,11 +49,11 @@ class _MarketMakerBotFormContentState extends State { final formBloc = context.read(); if (formBloc.state.sellCoin.value == null) { _setSellCoinToDefaultCoin(); - } else { - formBloc.add( - MarketMakerTradeFormSellCoinChanged(formBloc.state.sellCoin.value), - ); } + // Removed re-dispatch of MarketMakerTradeFormSellCoinChanged event + // as it causes flickering when coins list updates during coin selection. + // The event is already dispatched by _onSelectSellCoin when user + // selects a coin, so re-dispatching here creates a race condition. } super.didUpdateWidget(oldWidget); } @@ -100,8 +102,9 @@ class _MarketMakerBotFormContentState extends State { ), initialValue: state.tradeMargin.value, onChanged: _onTradeMarginChanged, - errorText: state.tradeMargin.displayError - ?.text(maxValue: 1000), + errorText: state.tradeMargin.displayError?.text( + maxValue: 1000, + ), maxIntegerDigits: 4, maxFractionDigits: 5, ), @@ -127,7 +130,8 @@ class _MarketMakerBotFormContentState extends State { const SizedBox(height: 12), if (state.tradePreImageError != null) ImportantNote( - text: state.tradePreImageError?.text( + text: + state.tradePreImageError?.text( state.sellCoin.value, state.buyCoin.value, ) ?? @@ -154,6 +158,8 @@ class _MarketMakerBotFormContentState extends State { child: AddMarketMakerBotTradeButton( enabled: state.isValid, onPressed: _onMakeOrderPressed, + sellCoin: state.sellCoin.value, + buyCoin: state.buyCoin.value, ), ), ), @@ -175,9 +181,21 @@ class _MarketMakerBotFormContentState extends State { } void _onMakeOrderPressed() { - context - .read() - .add(const MarketMakerConfirmationPreviewRequested()); + final tradeForm = context.read().state; + final pairsCount = + tradeForm.sellCoin.value != null && tradeForm.buyCoin.value != null + ? 1 + : 0; + context.read().logEvent( + MarketbotSetupStartedEventData( + strategyType: 'simple', + pairsCount: pairsCount, + ), + ); + + context.read().add( + const MarketMakerConfirmationPreviewRequested(), + ); } void _setSellCoinToDefaultCoin() { @@ -194,50 +212,50 @@ class _MarketMakerBotFormContentState extends State { } void _onTradeMarginChanged(String value) { - context - .read() - .add(MarketMakerTradeFormTradeMarginChanged(value)); + context.read().add( + MarketMakerTradeFormTradeMarginChanged(value), + ); } void _onUpdateIntervalChanged(TradeBotUpdateInterval? value) { context.read().add( - MarketMakerTradeFormUpdateIntervalChanged( - value?.seconds.toString() ?? '', - ), - ); + MarketMakerTradeFormUpdateIntervalChanged( + value?.seconds.toString() ?? '', + ), + ); } void _onClearFormPressed() { - context - .read() - .add(const MarketMakerTradeFormClearRequested()); + context.read().add( + const MarketMakerTradeFormClearRequested(), + ); } void _onBuyCoinSelected(Coin? value) { - context - .read() - .add(MarketMakerTradeFormBuyCoinChanged(value)); + context.read().add( + MarketMakerTradeFormBuyCoinChanged(value), + ); } Future _swapBuyAndSellCoins() async { - context - .read() - .add(const MarketMakerTradeFormSwapCoinsRequested()); + context.read().add( + const MarketMakerTradeFormSwapCoinsRequested(), + ); return true; } void _onSelectSellCoin(Coin? value) { - context - .read() - .add(MarketMakerTradeFormSellCoinChanged(value)); + context.read().add( + MarketMakerTradeFormSellCoinChanged(value), + ); } void _onVolumeRangeChanged(RangeValues values) { context.read().add( - MarketMakerTradeFormTradeVolumeChanged( - minimumTradeVolume: values.start, - maximumTradeVolume: values.end, - ), - ); + MarketMakerTradeFormTradeVolumeChanged( + minimumTradeVolume: values.start, + maximumTradeVolume: values.end, + ), + ); } } diff --git a/lib/views/market_maker_bot/update_interval_dropdown.dart b/lib/views/market_maker_bot/update_interval_dropdown.dart index ee5e3c99f5..84232cb699 100644 --- a/lib/views/market_maker_bot/update_interval_dropdown.dart +++ b/lib/views/market_maker_bot/update_interval_dropdown.dart @@ -29,7 +29,6 @@ class UpdateIntervalDropdown extends StatelessWidget { child: DropdownButtonFormField( value: interval, onChanged: onChanged, - focusColor: Colors.transparent, items: TradeBotUpdateInterval.values .map( (interval) => DropdownMenuItem( diff --git a/lib/views/nfts/common/widgets/nft_image.dart b/lib/views/nfts/common/widgets/nft_image.dart index 7227107f4f..1fb9cc172d 100644 --- a/lib/views/nfts/common/widgets/nft_image.dart +++ b/lib/views/nfts/common/widgets/nft_image.dart @@ -1,38 +1,55 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/svg.dart'; import 'package:video_player/video_player.dart'; import 'package:web_dex/shared/utils/platform_tuner.dart'; +import 'package:web_dex/shared/utils/ipfs_gateway_manager.dart'; +import 'package:web_dex/bloc/nft_image/nft_image_bloc.dart'; enum NftImageType { image, video, placeholder } class NftImage extends StatelessWidget { - const NftImage({ - super.key, - this.imagePath, - }); + const NftImage({super.key, this.imageUrl}); - final String? imagePath; + final String? imageUrl; @override Widget build(BuildContext context) { - switch (type) { - case NftImageType.image: - return _NftImage(imageUrl: imagePath!); - case NftImageType.video: - // According to [video_player](https://pub.dev/packages/video_player) - // it works only on Android, iOS, Web - // Waiting for a future updates - return PlatformTuner.isNativeDesktop - ? const _NftPlaceholder() - : _NftVideo(videoUrl: imagePath!); - case NftImageType.placeholder: - return const _NftPlaceholder(); - } + return BlocProvider( + create: (context) => + NftImageBloc(ipfsGatewayManager: context.read()), + child: Builder( + builder: (context) { + switch (type) { + case NftImageType.image: + return _NftImageWithFallback( + key: ValueKey(imageUrl!), + imageUrl: imageUrl!, + ); + case NftImageType.video: + // According to [video_player](https://pub.dev/packages/video_player) + // it works only on Android, iOS, Web + // Waiting for a future updates + return PlatformTuner.isNativeDesktop + ? const _NftPlaceholder() + : _NftVideoWithFallback( + key: ValueKey(imageUrl!), + videoUrl: imageUrl!, + ); + case NftImageType.placeholder: + return const _NftPlaceholder(); + } + }, + ), + ); } NftImageType get type { - if (imagePath != null) { - if (imagePath!.endsWith('.mp4')) { + if (imageUrl != null) { + final path = imageUrl!.toLowerCase(); + if (path.endsWith('.mp4') || + path.endsWith('.webm') || + path.endsWith('.mov')) { return NftImageType.video; } else { return NftImageType.image; @@ -42,77 +59,234 @@ class NftImage extends StatelessWidget { } } -class _NftImage extends StatelessWidget { - const _NftImage({required this.imageUrl}); +class _NftImageWithFallback extends StatefulWidget { + const _NftImageWithFallback({required this.imageUrl, super.key}); + final String imageUrl; + @override + State<_NftImageWithFallback> createState() => _NftImageWithFallbackState(); +} + +class _NftImageWithFallbackState extends State<_NftImageWithFallback> { + @override + void initState() { + super.initState(); + // Request the bloc to start loading and finding a working URL + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().add( + NftImageLoadStarted(imageUrl: widget.imageUrl), + ); + }); + } + + @override + void didUpdateWidget(covariant _NftImageWithFallback oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.imageUrl != widget.imageUrl) { + final bloc = context.read(); + bloc.add(const NftImageCleared()); + bloc.add(NftImageLoadStarted(imageUrl: widget.imageUrl)); + } + } + @override Widget build(BuildContext context) { - final isSvg = imageUrl.endsWith('.svg'); - final isGif = imageUrl.endsWith('.gif'); - - return ClipRRect( - borderRadius: BorderRadius.circular(24), - child: isSvg - ? SvgPicture.network(imageUrl, fit: BoxFit.cover) - : Image.network( - imageUrl, - filterQuality: FilterQuality.high, - fit: BoxFit.cover, - gaplessPlayback: isGif, // Ensures smoother GIF animation - errorBuilder: (_, error, stackTrace) { - debugPrint('Error loading image: $error'); - debugPrintStack(stackTrace: stackTrace); - return const _NftPlaceholder(); - }, - ), + return BlocBuilder( + builder: (context, state) { + // Show placeholder for exhausted or error states + if (state.shouldShowPlaceholder) { + return const _NftPlaceholder(); + } + + // Show loading indicator if no URL is ready yet + if (state.isLoading && state.currentUrl == null) { + return const Center(child: CircularProgressIndicator(strokeWidth: 2)); + } + + // Don't render anything if we don't have a current URL + if (state.currentUrl == null) { + return const _NftPlaceholder(); + } + + final currentUrl = state.currentUrl!; + + return ClipRRect( + borderRadius: BorderRadius.circular(24), + child: _buildImageWidget(context, state, currentUrl), + ); + }, ); } + + Widget _buildImageWidget( + BuildContext context, + NftImageState state, + String currentUrl, + ) { + switch (state.mediaType) { + case NftMediaType.svg: + return SvgPicture.network( + currentUrl, + fit: BoxFit.cover, + placeholderBuilder: (_) => + const Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + case NftMediaType.gif: + case NftMediaType.image: + default: + return Image.network( + currentUrl, + filterQuality: FilterQuality.high, + fit: BoxFit.cover, + gaplessPlayback: state.mediaType == NftMediaType.gif, + loadingBuilder: (context, child, loadingProgress) { + // If frame is available, image is successfully loaded + if (loadingProgress == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + context.read().add( + NftImageLoadSucceeded(loadedUrl: currentUrl), + ); + } + }); + + return child; + } + + // Show loading indicator while image is loading + return const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ); + }, + errorBuilder: (context, error, stackTrace) { + // Handle image load error - notify bloc to try next URL + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + context.read().add( + NftImageLoadFailed( + failedUrl: currentUrl, + errorMessage: error.toString(), + ), + ); + } + }); + + // Show loading indicator while bloc processes the failure + return const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ); + }, + ); + } + } } -class _NftVideo extends StatefulWidget { - const _NftVideo({required this.videoUrl}); +class _NftVideoWithFallback extends StatefulWidget { + const _NftVideoWithFallback({required this.videoUrl, super.key}); final String videoUrl; @override - State<_NftVideo> createState() => _NftVideoState(); + State<_NftVideoWithFallback> createState() => _NftVideoWithFallbackState(); } -class _NftVideoState extends State<_NftVideo> { - late final VideoPlayerController _controller; +class _NftVideoWithFallbackState extends State<_NftVideoWithFallback> { + VideoPlayerController? _controller; + String? currentVideoUrl; @override void initState() { - _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)); + super.initState(); + // Don't initialize controller with empty URI - wait for valid URL - _controller.initialize().then((_) { - _controller.setLooping(true); - _controller.play(); - setState(() {}); + // Request the bloc to start loading and finding a working URL + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().add( + NftImageLoadStarted(imageUrl: widget.videoUrl), + ); }); - - super.initState(); } @override - void dispose() { - _controller.dispose(); - super.dispose(); + void didUpdateWidget(covariant _NftVideoWithFallback oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.videoUrl != widget.videoUrl) { + _controller?.dispose(); + _controller = null; + currentVideoUrl = null; + final bloc = context.read(); + bloc.add(const NftImageCleared()); + bloc.add(NftImageLoadStarted(imageUrl: widget.videoUrl)); + } } @override Widget build(BuildContext context) { - return _controller.value.isInitialized - ? ClipRRect( - borderRadius: BorderRadius.circular(24), - child: VideoPlayer(_controller), - ) - : const Center( - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ); + return BlocConsumer( + listener: (context, state) { + // Handle URL changes from the bloc + if (state.currentUrl != null && state.currentUrl != currentVideoUrl) { + _initializeVideoController(state.currentUrl!); + } + }, + builder: (context, state) { + if (state.shouldShowPlaceholder) { + return const _NftPlaceholder(); + } + + if (currentVideoUrl == null || state.isLoading || _controller == null) { + return const Center(child: CircularProgressIndicator(strokeWidth: 2)); + } + + return _controller!.value.isInitialized + ? ClipRRect( + borderRadius: BorderRadius.circular(24), + child: VideoPlayer(_controller!), + ) + : const Center(child: CircularProgressIndicator(strokeWidth: 2)); + }, + ); + } + + void _initializeVideoController(String videoUrl) { + _controller?.dispose(); + currentVideoUrl = videoUrl; + + _controller = VideoPlayerController.networkUrl(Uri.parse(videoUrl)); + + _controller! + .initialize() + .then((_) { + if (mounted) { + setState(() {}); + _controller!.setLooping(true); + _controller!.play(); + + // Notify bloc of successful load + context.read().add( + NftImageLoadSucceeded(loadedUrl: videoUrl), + ); + } + }) + .catchError((error) { + debugPrint('Error initializing video from $videoUrl: $error'); + if (mounted) { + // Notify bloc of failed load + context.read().add( + NftImageLoadFailed( + failedUrl: videoUrl, + errorMessage: error.toString(), + ), + ); + } + }); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); } } @@ -122,12 +296,8 @@ class _NftPlaceholder extends StatelessWidget { @override Widget build(BuildContext context) { return Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - child: const Center( - child: Icon(Icons.monetization_on, size: 36), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + child: const Center(child: Icon(Icons.monetization_on, size: 36)), ); } } diff --git a/lib/views/nfts/details_page/desktop/nft_details_page_desktop.dart b/lib/views/nfts/details_page/desktop/nft_details_page_desktop.dart index 2663b665ae..6d8f1ae84d 100644 --- a/lib/views/nfts/details_page/desktop/nft_details_page_desktop.dart +++ b/lib/views/nfts/details_page/desktop/nft_details_page_desktop.dart @@ -31,39 +31,41 @@ class NftDetailsPageDesktop extends StatelessWidget { children: [ Flexible( child: ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 389, maxHeight: 440), - child: NftImage(imagePath: nft.imageUrl), + constraints: const BoxConstraints( + maxWidth: 389, + maxHeight: 440, + ), + child: NftImage(imageUrl: nft.imageUrl), ), ), const SizedBox(width: 32), Flexible( child: ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 416, maxHeight: 440), + constraints: const BoxConstraints( + maxWidth: 416, + maxHeight: 440, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.max, children: [ - NftDescription( - nft: nft, - isDescriptionShown: !isSend, - ), + NftDescription(nft: nft, isDescriptionShown: !isSend), const SizedBox(height: 12), if (state is! NftWithdrawSuccessState) NftData(nft: nft), if (isSend) - Flexible( - child: NftWithdrawView(nft: nft), - ) + Flexible(child: NftWithdrawView(nft: nft)) else ...[ const Spacer(), UiPrimaryButton( - text: LocaleKeys.send.tr(), - height: 40, - onPressed: () { - routingState.nftsState - .setDetailsAction(nft.uuid, true); - }), + text: LocaleKeys.send.tr(), + height: 40, + onPressed: () { + routingState.nftsState.setDetailsAction( + nft.uuid, + true, + ); + }, + ), ], ], ), diff --git a/lib/views/nfts/details_page/mobile/nft_details_page_mobile.dart b/lib/views/nfts/details_page/mobile/nft_details_page_mobile.dart index 64b8ca9dfe..4029a734fb 100644 --- a/lib/views/nfts/details_page/mobile/nft_details_page_mobile.dart +++ b/lib/views/nfts/details_page/mobile/nft_details_page_mobile.dart @@ -35,22 +35,16 @@ class _NftDetailsPageMobileState extends State { @override Widget build(BuildContext context) { return BlocBuilder( - builder: (BuildContext context, NftWithdrawState state) { - final nft = state.nft; + builder: (BuildContext context, NftWithdrawState state) { + final nft = state.nft; - return SingleChildScrollView( - child: _isSend - ? _Send( - nft: nft, - close: _closeSend, - ) - : _Details( - nft: nft, - onBack: _livePage, - onSend: _showSend, - ), - ); - }); + return SingleChildScrollView( + child: _isSend + ? _Send(nft: nft, close: _closeSend) + : _Details(nft: nft, onBack: _livePage, onSend: _showSend), + ); + }, + ); } void _showSend() { @@ -91,16 +85,11 @@ class _Details extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 50), - PageHeader( - title: nft.name, - onBackButtonPressed: onBack, - ), + PageHeader(title: nft.name, onBackButtonPressed: onBack), const SizedBox(height: 5), ConstrainedBox( constraints: const BoxConstraints(maxHeight: 343), - child: NftImage( - imagePath: nft.imageUrl, - ), + child: NftImage(imageUrl: nft.imageUrl), ), const SizedBox(height: 28), UiPrimaryButton( @@ -109,17 +98,14 @@ class _Details extends StatelessWidget { onPressed: onSend, ), const SizedBox(height: 28), - NftDescription(nft: nft) + NftDescription(nft: nft), ], ); } } class _Send extends StatelessWidget { - const _Send({ - required this.nft, - required this.close, - }); + const _Send({required this.nft, required this.close}); final NftToken nft; final VoidCallback close; @@ -134,9 +120,7 @@ class _Send extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 50), - NftDetailsHeaderMobile( - close: close, - ), + NftDetailsHeaderMobile(close: close), const SizedBox(height: 10), if (state is! NftWithdrawSuccessState) Padding( @@ -153,7 +137,7 @@ class _Send extends StatelessWidget { maxWidth: 40, maxHeight: 40, ), - child: NftImage(imagePath: nft.imageUrl), + child: NftImage(imageUrl: nft.imageUrl), ), const SizedBox(width: 8), Flexible( @@ -165,14 +149,18 @@ class _Send extends StatelessWidget { Text( nft.name, style: textTheme.bodySBold.copyWith( - color: colorScheme.primary, height: 1), + color: colorScheme.primary, + height: 1, + ), ), const SizedBox(height: 10), Text( nft.collectionName ?? '', - style: textTheme.bodyXS - .copyWith(color: colorScheme.s70, height: 1), - ) + style: textTheme.bodyXS.copyWith( + color: colorScheme.s70, + height: 1, + ), + ), ], ), ), diff --git a/lib/views/nfts/details_page/withdraw/nft_withdraw_success.dart b/lib/views/nfts/details_page/withdraw/nft_withdraw_success.dart index 41fd138588..84053b456e 100644 --- a/lib/views/nfts/details_page/withdraw/nft_withdraw_success.dart +++ b/lib/views/nfts/details_page/withdraw/nft_withdraw_success.dart @@ -39,99 +39,102 @@ class _NftWithdrawSuccessState extends State { borderRadius: BorderRadius.circular(20.0), color: colorScheme.surfContLow, ), - child: Column(children: [ - SvgPicture.asset( - '$assetsPath/ui_icons/success.svg', - colorFilter: ColorFilter.mode( - colorScheme.primary, - BlendMode.srcIn, + child: Column( + children: [ + SvgPicture.asset( + '$assetsPath/ui_icons/success.svg', + colorFilter: ColorFilter.mode(colorScheme.primary, BlendMode.srcIn), + height: 64, + width: 64, ), - height: 64, - width: 64, - ), - const SizedBox(height: 12), - Text( - LocaleKeys.successfullySent.tr(), - style: textTheme.heading2.copyWith(color: colorScheme.primary), - ), - const SizedBox(height: 20), - if (isMobile) - Container( - padding: const EdgeInsets.symmetric(vertical: 11), - decoration: BoxDecoration( + const SizedBox(height: 12), + Text( + LocaleKeys.successfullySent.tr(), + style: textTheme.heading2.copyWith(color: colorScheme.primary), + ), + const SizedBox(height: 20), + if (isMobile) + Container( + padding: const EdgeInsets.symmetric(vertical: 11), + decoration: BoxDecoration( border: Border( - top: BorderSide(color: colorScheme.surfContHigh), - bottom: BorderSide(color: colorScheme.surfContHigh))), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 40, - maxHeight: 40, + top: BorderSide(color: colorScheme.surfContHigh), + bottom: BorderSide(color: colorScheme.surfContHigh), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 40, + maxHeight: 40, + ), + child: NftImage(imageUrl: nft.imageUrl), ), - child: NftImage(imagePath: nft.imageUrl), - ), - const SizedBox(width: 8), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - nft.name, - style: textTheme.bodySBold.copyWith( - color: colorScheme.primary, - height: 1, + const SizedBox(width: 8), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + nft.name, + style: textTheme.bodySBold.copyWith( + color: colorScheme.primary, + height: 1, + ), ), - ), - const SizedBox(height: 8), - Text( - nft.collectionName ?? '', - style: textTheme.bodyXS.copyWith( - color: colorScheme.s70, - height: 1, + const SizedBox(height: 8), + Text( + nft.collectionName ?? '', + style: textTheme.bodyXS.copyWith( + color: colorScheme.s70, + height: 1, + ), ), - ) - ], - ), - ], - ), - ], + ], + ), + ], + ), + ], + ), ), - ), - SizedBox(height: isMobile ? 38 : 4), - NftDataRow( - title: LocaleKeys.date.tr(), - value: DateFormat('dd MMM yyyy HH:mm').format( + SizedBox(height: isMobile ? 38 : 4), + NftDataRow( + title: LocaleKeys.date.tr(), + value: DateFormat('dd MMM yyyy HH:mm').format( DateTime.fromMillisecondsSinceEpoch( - widget.state.timestamp * 1000)), - ), - const SizedBox(height: 24), - NftDataRow( - title: LocaleKeys.transactionId.tr(), - valueWidget: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 150), - child: HashExplorerLink( - hash: widget.state.txHash, - type: HashExplorerType.tx, - coin: widget.state.nft.parentCoin, + widget.state.timestamp * 1000, + ), ), ), - ), - const SizedBox(height: 24), - NftDataRow( - title: LocaleKeys.to.tr(), - valueWidget: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 165), - child: HashExplorerLink( - hash: widget.state.to, - type: HashExplorerType.address, - coin: widget.state.nft.parentCoin, + const SizedBox(height: 24), + NftDataRow( + title: LocaleKeys.transactionId.tr(), + valueWidget: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 150), + child: HashExplorerLink( + hash: widget.state.txHash, + type: HashExplorerType.tx, + coin: widget.state.nft.parentCoin, + ), ), ), - ), - ]), + const SizedBox(height: 24), + NftDataRow( + title: LocaleKeys.to.tr(), + valueWidget: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 165), + child: HashExplorerLink( + hash: widget.state.to, + type: HashExplorerType.address, + coin: widget.state.nft.parentCoin, + ), + ), + ), + ], + ), ); } } diff --git a/lib/views/nfts/nft_list/nft_list_item.dart b/lib/views/nfts/nft_list/nft_list_item.dart index 2155b16dd0..e67b082b9c 100644 --- a/lib/views/nfts/nft_list/nft_list_item.dart +++ b/lib/views/nfts/nft_list/nft_list_item.dart @@ -62,17 +62,14 @@ class _NftListItemState extends State { child: InkWell( onTap: _onTap, child: GridTile( - footer: _NftData( - nft: widget.nft, - onSendTap: _onSendTap, - ), + footer: _NftData(nft: widget.nft, onSendTap: _onSendTap), child: Stack( children: [ Positioned.fill( child: AnimatedScale( duration: const Duration(milliseconds: 200), scale: isHover ? 1.05 : 1, - child: NftImage(imagePath: widget.nft.imageUrl), + child: NftImage(imageUrl: widget.nft.imageUrl), ), ), @@ -117,29 +114,19 @@ class _NftAmount extends StatelessWidget { shape: const CircleBorder(), child: Padding( padding: const EdgeInsets.all(12), - child: Text( - nft.amount, - style: Theme.of(context).textTheme.bodyLarge, - ), + child: Text(nft.amount, style: Theme.of(context).textTheme.bodyLarge), ), ); } } class _NftData extends StatelessWidget { - const _NftData({ - required this.nft, - required this.onSendTap, - }); + const _NftData({required this.nft, required this.onSendTap}); final NftToken nft; final VoidCallback onSendTap; - Text _tileText(String text) => Text( - text, - maxLines: 1, - softWrap: false, - overflow: TextOverflow.fade, - ); + Text _tileText(String text) => + Text(text, maxLines: 1, softWrap: false, overflow: TextOverflow.fade); @override Widget build(BuildContext context) { diff --git a/lib/views/nfts/nft_page.dart b/lib/views/nfts/nft_page.dart index a9bec724d4..7a7660d415 100644 --- a/lib/views/nfts/nft_page.dart +++ b/lib/views/nfts/nft_page.dart @@ -40,10 +40,7 @@ class NftPage extends StatelessWidget { ), ), ], - child: NFTPageView( - pageState: pageState, - uuid: uuid, - ), + child: NFTPageView(pageState: pageState, uuid: uuid), ), ); }, @@ -86,36 +83,40 @@ class _NFTPageViewState extends State { !_loggedOpen && curr.isInitialized && !prev.isInitialized, listener: (context, state) { _loggedOpen = true; - final count = state.nftCount.values - .fold(0, (sum, item) => sum + (item ?? 0)); + final count = state.nftCount.values.fold( + 0, + (sum, item) => sum + (item ?? 0), + ); context.read().logEvent( - NftGalleryOpenedEventData( - nftCount: count, - loadTimeMs: _loadStopwatch.elapsedMilliseconds, - ), - ); + NftGalleryOpenedEventData( + nftCount: count, + loadTimeMs: _loadStopwatch.elapsedMilliseconds, + ), + ); }, child: PageLayout( header: null, content: Expanded( child: Container( margin: isMobile ? const EdgeInsets.only(top: 14) : null, - child: Builder(builder: (context) { - switch (widget.pageState) { - case NFTSelectedState.details: - case NFTSelectedState.send: - return NftDetailsPage( - uuid: widget.uuid, - isSend: widget.pageState == NFTSelectedState.send, - ); - case NFTSelectedState.receive: - return const NftReceivePage(); - case NFTSelectedState.transactions: - return const NftListOfTransactionsPage(); - case NFTSelectedState.none: - return const NftMain(); - } - }), + child: Builder( + builder: (context) { + switch (widget.pageState) { + case NFTSelectedState.details: + case NFTSelectedState.send: + return NftDetailsPage( + uuid: widget.uuid, + isSend: widget.pageState == NFTSelectedState.send, + ); + case NFTSelectedState.receive: + return const NftReceivePage(); + case NFTSelectedState.transactions: + return const NftListOfTransactionsPage(); + case NFTSelectedState.none: + return const NftMain(); + } + }, + ), ), ), ), diff --git a/lib/views/nfts/nft_transactions/common/widgets/nft_txn_media.dart b/lib/views/nfts/nft_transactions/common/widgets/nft_txn_media.dart index a5871d65ce..6951ef5da6 100644 --- a/lib/views/nfts/nft_transactions/common/widgets/nft_txn_media.dart +++ b/lib/views/nfts/nft_transactions/common/widgets/nft_txn_media.dart @@ -28,7 +28,7 @@ class NftTxnMedia extends StatelessWidget { children: [ ConstrainedBox( constraints: const BoxConstraints(maxWidth: 40, maxHeight: 40), - child: NftImage(imagePath: imagePath), + child: NftImage(imageUrl: imagePath), ), const SizedBox(width: 8), Expanded( @@ -41,24 +41,28 @@ class NftTxnMedia extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Flexible( - child: Text(title ?? '-', - maxLines: 1, - softWrap: false, - overflow: TextOverflow.fade, - style: titleTextStyle), + child: Text( + title ?? '-', + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + style: titleTextStyle, + ), ), Text(' ($amount)', maxLines: 1, style: titleTextStyle), ], ), ), - Text(collectionName, - maxLines: 1, - overflow: TextOverflow.fade, - softWrap: false, - style: subtitleTextStyle), + Text( + collectionName, + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, + style: subtitleTextStyle, + ), ], ), - ) + ), ], ); } 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 d95e97d482..3145180dd9 100644 --- a/lib/views/settings/widgets/general_settings/app_version_number.dart +++ b/lib/views/settings/widgets/general_settings/app_version_number.dart @@ -1,12 +1,8 @@ -import 'dart:convert'; - import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:web_dex/app_config/package_information.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/mm2/mm2_api/mm2_api.dart'; class AppVersionNumber extends StatelessWidget { const AppVersionNumber({super.key}); @@ -15,54 +11,48 @@ class AppVersionNumber extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(left: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SelectableText( - LocaleKeys.komodoWallet.tr(), - style: _textStyle, - ), - SelectableText( - '${LocaleKeys.version.tr()}: ${packageInformation.packageVersion}', - style: _textStyle, - ), - SelectableText( - '${LocaleKeys.commit.tr()}: ${packageInformation.commitHash}', - style: _textStyle, - ), - const _ApiVersion(), - const SizedBox(height: 4), - const _BundledCoinsCommitConfig(), - ], + child: BlocBuilder( + builder: (context, state) { + if (state is VersionInfoLoaded) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SelectableText(LocaleKeys.komodoWallet.tr(), style: _textStyle), + if (state.appVersion != null) + SelectableText( + '${LocaleKeys.version.tr()}: ${state.appVersion}', + style: _textStyle, + ), + if (state.commitHash != null) + SelectableText( + '${LocaleKeys.commit.tr()}: ${state.commitHash}', + style: _textStyle, + ), + if (state.apiCommitHash != null) + SelectableText( + '${LocaleKeys.api.tr()}: ${state.apiCommitHash}', + style: _textStyle, + ), + const SizedBox(height: 4), + CoinsCommitInfo(state: state), + ], + ); + } else if (state is VersionInfoLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is VersionInfoError) { + return Text('Error: ${state.message}'); + } + return const SizedBox.shrink(); + }, ), ); } } -class _BundledCoinsCommitConfig extends StatelessWidget { - // ignore: unused_element_parameter - const _BundledCoinsCommitConfig({super.key}); +class CoinsCommitInfo extends StatelessWidget { + const CoinsCommitInfo({super.key, required this.state}); - // Get the value from `app_build/build_config.json` under the key - // "coins"->"bundled_coins_repo_commit" - Future getBundledCoinsCommit() async { - final buildConfigPath = - 'packages/komodo_defi_framework/app_build/build_config.json'; - final String commit = await rootBundle - .loadString(buildConfigPath) - .then( - (String jsonString) => - json.decode(jsonString) as Map, - ) - .then( - (Map json) => json['coins'] as Map, - ) - .then( - (Map json) => - json['bundled_coins_repo_commit'] as String, - ); - return commit; - } + final VersionInfoLoaded state; @override Widget build(BuildContext context) { @@ -70,67 +60,19 @@ class _BundledCoinsCommitConfig extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(LocaleKeys.coinAssets.tr(), style: _textStyle), - FutureBuilder( - future: getBundledCoinsCommit(), - builder: (context, snapshot) { - final String? commitHash = - (!snapshot.hasData) ? null : _tryParseCommitHash(snapshot.data); - - return SelectableText( - '${LocaleKeys.bundled.tr()}: ${commitHash ?? LocaleKeys.unknown.tr()}', - style: _textStyle, - ); - }, - ), - SelectableText( - // TODO!: add sdk getter for updated commit hash - '${LocaleKeys.updated.tr()}: ${LocaleKeys.updated.tr()}', - style: _textStyle, - ), - ], - ); - } -} - -class _ApiVersion extends StatelessWidget { - // ignore: unused_element_parameter - const _ApiVersion({super.key}); - - @override - Widget build(BuildContext context) { - final mm2Api = RepositoryProvider.of(context); - - return Row( - children: [ - Flexible( - child: FutureBuilder( - future: mm2Api.version(), - builder: (context, snapshot) { - if (!snapshot.hasData) return const SizedBox.shrink(); - - final String? commitHash = _tryParseCommitHash(snapshot.data); - if (commitHash == null) return const SizedBox.shrink(); - - return SelectableText( - '${LocaleKeys.api.tr()}: $commitHash', - style: _textStyle, - ); - }, + if (state.currentCoinsCommit != null) + SelectableText( + '${LocaleKeys.bundled.tr()}: ${state.currentCoinsCommit}', + style: _textStyle, + ), + if (state.latestCoinsCommit != null) + SelectableText( + '${LocaleKeys.updated.tr()}: ${state.latestCoinsCommit}', + style: _textStyle, ), - ), ], ); } } -String? _tryParseCommitHash(String? result) { - if (result == null) return null; - - final RegExp regExp = RegExp(r'[0-9a-fA-F]{7,40}'); - final Match? match = regExp.firstMatch(result); - - // Only take first 7 characters of the first match - return match?.group(0)?.substring(0, 7); -} - const _textStyle = TextStyle(fontSize: 13, fontWeight: FontWeight.w500); diff --git a/lib/views/settings/widgets/general_settings/general_settings.dart b/lib/views/settings/widgets/general_settings/general_settings.dart index b7166177c4..9e807451e2 100644 --- a/lib/views/settings/widgets/general_settings/general_settings.dart +++ b/lib/views/settings/widgets/general_settings/general_settings.dart @@ -32,31 +32,22 @@ class GeneralSettings extends StatelessWidget { const SizedBox(height: 25), const HiddenWithoutWallet( isHiddenForHw: true, + isHiddenElse: false, child: SettingsManageWeakPasswords(), ), const SizedBox(height: 25), - if (context.watch().state is TradingEnabled) + if (context.watch().state.isEnabled) const HiddenWithoutWallet( isHiddenForHw: true, child: SettingsManageTradingBot(), ), const SizedBox(height: 25), - const HiddenWithoutWallet( - child: SettingsDownloadLogs(), - ), + const HiddenWithoutWallet(child: SettingsDownloadLogs()), const SizedBox(height: 25), - const HiddenWithWallet( - child: SettingsResetActivatedCoins(), - ), + const HiddenWithWallet(child: SettingsResetActivatedCoins()), const SizedBox(height: 25), - const HiddenWithoutWallet( - isHiddenForHw: true, - child: ShowSwapData(), - ), - const HiddenWithoutWallet( - isHiddenForHw: true, - child: ImportSwaps(), - ), + const HiddenWithoutWallet(isHiddenForHw: true, child: ShowSwapData()), + const HiddenWithoutWallet(isHiddenForHw: true, child: ImportSwaps()), ], ); } diff --git a/lib/views/settings/widgets/general_settings/settings_theme_switcher.dart b/lib/views/settings/widgets/general_settings/settings_theme_switcher.dart index e056ce2a43..e421e9dd0c 100644 --- a/lib/views/settings/widgets/general_settings/settings_theme_switcher.dart +++ b/lib/views/settings/widgets/general_settings/settings_theme_switcher.dart @@ -5,6 +5,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/settings/settings_bloc.dart'; import 'package:web_dex/bloc/settings/settings_event.dart'; +import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; +import 'package:web_dex/analytics/events/misc_events.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/views/settings/widgets/common/settings_section.dart'; @@ -55,8 +57,12 @@ class _SettingsModeSelector extends StatelessWidget { mode == context.select((SettingsBloc bloc) => bloc.state.themeMode); const double size = 16.0; return InkWell( - onTap: () => - context.read().add(ThemeModeChanged(mode: mode)), + onTap: () { + context.read().add(ThemeModeChanged(mode: mode)); + context.read().logEvent( + ThemeSelectedEventData(themeName: _analyticsName), + ); + }, mouseCursor: SystemMouseCursors.click, child: Container( key: Key('theme-settings-switcher-$_themeName'), @@ -126,6 +132,17 @@ class _SettingsModeSelector extends StatelessWidget { } } + String get _analyticsName { + switch (mode) { + case ThemeMode.dark: + return 'dark'; + case ThemeMode.light: + return 'light'; + case ThemeMode.system: + return 'auto'; + } + } + String get _iconPath { switch (mode) { case ThemeMode.dark: diff --git a/lib/views/settings/widgets/security_settings/password_update_page.dart b/lib/views/settings/widgets/security_settings/password_update_page.dart index 1a9d4439d6..5456c819e3 100644 --- a/lib/views/settings/widgets/security_settings/password_update_page.dart +++ b/lib/views/settings/widgets/security_settings/password_update_page.dart @@ -2,6 +2,7 @@ import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:web_dex/shared/constants.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'; @@ -47,8 +48,9 @@ class _PasswordUpdatePageState extends State { return Container( padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface.withValues(alpha: .3), - borderRadius: BorderRadius.circular(18.0)), + color: Theme.of(context).colorScheme.surface.withValues(alpha: .3), + borderRadius: BorderRadius.circular(18.0), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, @@ -131,43 +133,45 @@ class _FormViewState extends State<_FormView> { @override Widget build(BuildContext context) { - return Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _CurrentField( - controller: _oldController, - isObscured: _isObscured, - onVisibilityChange: _onVisibilityChange, - formKey: _formKey, - ), - const SizedBox(height: 30), - _NewField( - controller: _newController, - isObscured: _isObscured, - onVisibilityChange: _onVisibilityChange, - ), - const SizedBox(height: 20), - _ConfirmField( - confirmController: _confirmController, - newController: _newController, - isObscured: _isObscured, - onVisibilityChange: _onVisibilityChange, - ), - const SizedBox(height: 30), - if (_error != null) ...{ - Text( - _error!, - style: TextStyle(color: theme.currentGlobal.colorScheme.error), + return AutofillGroup( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _CurrentField( + controller: _oldController, + isObscured: _isObscured, + onVisibilityChange: _onVisibilityChange, + formKey: _formKey, ), - const SizedBox(height: 10), - }, - UiPrimaryButton( - onPressed: _onUpdate, - text: LocaleKeys.updatePassword.tr(), - ), - ], + const SizedBox(height: 30), + _NewField( + controller: _newController, + isObscured: _isObscured, + onVisibilityChange: _onVisibilityChange, + ), + const SizedBox(height: 20), + _ConfirmField( + confirmController: _confirmController, + newController: _newController, + isObscured: _isObscured, + onVisibilityChange: _onVisibilityChange, + ), + const SizedBox(height: 30), + if (_error != null) ...{ + Text( + _error!, + style: TextStyle(color: theme.currentGlobal.colorScheme.error), + ), + const SizedBox(height: 10), + }, + UiPrimaryButton( + onPressed: _onUpdate, + text: LocaleKeys.updatePassword.tr(), + ), + ], + ), ), ); } @@ -197,6 +201,8 @@ class _FormViewState extends State<_FormView> { setState(() => _error = null); _newController.text = ''; _confirmController.text = ''; + // Complete autofill context so managers can update stored password + TextInput.finishAutofillContext(shouldSave: true); widget.onSuccess(); } catch (e) { setState(() { @@ -254,6 +260,7 @@ class _CurrentFieldState extends State<_CurrentField> { hintText: LocaleKeys.currentPassword.tr(), controller: widget.controller, isObscured: widget.isObscured, + autofillHints: const [AutofillHints.password], validator: (String? password) { if (password == null || password.isEmpty) { return LocaleKeys.passwordIsEmpty.tr(); @@ -287,6 +294,7 @@ class _NewField extends StatelessWidget { hintText: LocaleKeys.enterNewPassword.tr(), controller: controller, isObscured: isObscured, + autofillHints: const [AutofillHints.newPassword], validator: (password) => _validatePassword(password, context), suffixIcon: PasswordVisibilityControl( onVisibilityChange: onVisibilityChange, @@ -326,10 +334,9 @@ class _ConfirmField extends StatelessWidget { hintText: LocaleKeys.confirmNewPassword.tr(), controller: confirmController, isObscured: isObscured, - validator: (String? confirmPassword) => validateConfirmPassword( - newController.text, - confirmPassword ?? '', - ), + autofillHints: const [AutofillHints.newPassword], + validator: (String? confirmPassword) => + validateConfirmPassword(newController.text, confirmPassword ?? ''), suffixIcon: PasswordVisibilityControl( onVisibilityChange: onVisibilityChange, ), @@ -344,6 +351,7 @@ class _PasswordField extends StatelessWidget { required this.suffixIcon, required this.validator, required this.hintText, + this.autofillHints, }); final TextEditingController controller; @@ -351,6 +359,7 @@ class _PasswordField extends StatelessWidget { final PasswordVisibilityControl suffixIcon; final String? Function(String?)? validator; final String hintText; + final Iterable? autofillHints; @override Widget build(BuildContext context) { @@ -361,7 +370,9 @@ class _PasswordField extends StatelessWidget { autocorrect: false, enableInteractiveSelection: true, obscureText: isObscured, - inputFormatters: [LengthLimitingTextInputFormatter(40)], + maxLength: passwordMaxLength, + counterText: '', + autofillHints: autofillHints, validator: validator, errorMaxLines: 6, hintText: hintText, @@ -379,9 +390,7 @@ class _SuccessView extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - const SizedBox( - height: 20, - ), + const SizedBox(height: 20), Padding( padding: const EdgeInsets.only(top: 30), child: UiPrimaryButton( diff --git a/lib/views/settings/widgets/security_settings/private_key_settings/private_key_show.dart b/lib/views/settings/widgets/security_settings/private_key_settings/private_key_show.dart index afc8cf4ce7..1af5c9cebf 100644 --- a/lib/views/settings/widgets/security_settings/private_key_settings/private_key_show.dart +++ b/lib/views/settings/widgets/security_settings/private_key_settings/private_key_show.dart @@ -16,6 +16,7 @@ import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/views/settings/widgets/security_settings/seed_settings/seed_back_button.dart'; import 'package:web_dex/views/wallet/wallet_page/common/expandable_private_key_list.dart'; import 'package:web_dex/views/settings/widgets/security_settings/private_key_settings/private_key_actions_widget.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; /// Widget for displaying private keys in a secure manner. /// @@ -40,91 +41,201 @@ class PrivateKeyShow extends StatelessWidget { /// [privateKeys] Map of asset IDs to their corresponding private keys. /// **Security Note**: This data should be handled with extreme care and /// cleared from memory as soon as possible. - const PrivateKeyShow({required this.privateKeys}); + const PrivateKeyShow({ + required this.privateKeys, + this.blockedAssetIds = const {}, + }); /// Private keys organized by asset ID. /// /// **Security Note**: This data is intentionally passed directly to the UI /// rather than stored in BLoC state to minimize memory exposure and lifetime. final Map> privateKeys; + final Set blockedAssetIds; @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - if (!isMobile) - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: SeedBackButton(() { - // Track analytics based on whether keys were copied - final wasBackupCompleted = context - .read() - .state - .arePrivateKeysSaved; + return ScreenshotSensitive( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + if (!isMobile) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: SeedBackButton(() { + // Track analytics based on whether keys were copied + final wasBackupCompleted = context + .read() + .state + .arePrivateKeysSaved; + + final walletType = + context + .read() + .state + .currentUser + ?.wallet + .config + .type + .name ?? + ''; - final walletType = - context - .read() - .state - .currentUser - ?.wallet - .config - .type - .name ?? - ''; + if (wasBackupCompleted) { + // User copied keys, so track as completed backup + context.read().add( + AnalyticsBackupCompletedEvent( + backupTime: 0, + method: 'private_key_export', + hdType: walletType, + ), + ); + } else { + // User didn't copy keys, so track as skipped + context.read().add( + AnalyticsBackupSkippedEvent( + stageSkipped: 'private_key_show', + hdType: walletType, + ), + ); + } - if (wasBackupCompleted) { - // User copied keys, so track as completed backup - context.read().add( - AnalyticsBackupCompletedEvent( - backupTime: 0, - method: 'private_key_export', - walletType: walletType, - ), - ); - } else { - // User didn't copy keys, so track as skipped - context.read().add( - AnalyticsBackupSkippedEvent( - stageSkipped: 'private_key_show', - walletType: walletType, - ), - ); - } + context.read().add(const ResetEvent()); + }), + ), - context.read().add(const ResetEvent()); - }), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _TitleRow(), + const SizedBox(height: 16), + const _SecurityWarning(), + const SizedBox(height: 16), + const _CopyWarning(), + const SizedBox(height: 16), + PrivateKeyExportSection( + privateKeys: privateKeys, + blockedAssetIds: blockedAssetIds, + ), + ], ), + ], + ), + ); + } +} + +class PrivateKeyExportSection extends StatefulWidget { + const PrivateKeyExportSection({ + super.key, + required this.privateKeys, + required this.blockedAssetIds, + }); + + final Map> privateKeys; + final Set blockedAssetIds; + + @override + State createState() => + _PrivateKeyExportSectionState(); +} + +class _PrivateKeyExportSectionState extends State { + bool _includeBlockedAssets = false; + + @override + void initState() { + super.initState(); + _includeBlockedAssets = !_hasBlockedAssetsInKeys(); + } + + bool _hasBlockedAssetsInKeys() { + if (widget.blockedAssetIds.isEmpty || widget.privateKeys.isEmpty) { + return false; + } + for (final assetId in widget.privateKeys.keys) { + if (widget.blockedAssetIds.contains(assetId)) return true; + } + return false; + } + + Map> _filteredPrivateKeys() { + if (_includeBlockedAssets || widget.blockedAssetIds.isEmpty) { + return widget.privateKeys; + } + final entries = widget.privateKeys.entries.where( + (e) => !widget.blockedAssetIds.contains(e.key), + ); + return Map>.fromEntries(entries); + } - Column( - crossAxisAlignment: CrossAxisAlignment.start, + @override + Widget build(BuildContext context) { + final hasBlocked = _hasBlockedAssetsInKeys(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const _TitleRow(), - const SizedBox(height: 16), - const _SecurityWarning(), - const SizedBox(height: 16), - const _CopyWarning(), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const _ShowingSwitcher(), - Flexible( - child: PrivateKeyActionsWidget(privateKeys: privateKeys), + const _ShowingSwitcher(), + if (hasBlocked) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: _IncludeBlockedToggle( + value: _includeBlockedAssets, + onChanged: (val) => + setState(() => _includeBlockedAssets = val), ), - ], + ), + Flexible( + child: PrivateKeyActionsWidget( + privateKeys: _filteredPrivateKeys(), + ), ), - const SizedBox(height: 16), - ExpandablePrivateKeyList(privateKeys: privateKeys), ], ), + const SizedBox(height: 16), + ExpandablePrivateKeyList(privateKeys: _filteredPrivateKeys()), ], ); } } +class _IncludeBlockedToggle extends StatelessWidget { + const _IncludeBlockedToggle({required this.value, required this.onChanged}); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + UiSwitcher(value: value, onChanged: onChanged, width: 38, height: 21), + const SizedBox(width: 8), + Text( + LocaleKeys.includeBlockedAssets.tr(), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), + ), + ], + ), + ); + } +} + /// Widget displaying the title for private key export. class _TitleRow extends StatelessWidget { const _TitleRow(); diff --git a/lib/views/settings/widgets/security_settings/security_settings_page.dart b/lib/views/settings/widgets/security_settings/security_settings_page.dart index 0924b13b7d..0cf6016229 100644 --- a/lib/views/settings/widgets/security_settings/security_settings_page.dart +++ b/lib/views/settings/widgets/security_settings/security_settings_page.dart @@ -24,6 +24,7 @@ import 'package:web_dex/views/settings/widgets/security_settings/seed_settings/s import 'package:web_dex/views/settings/widgets/security_settings/private_key_settings/private_key_show.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; /// Security settings page that manages both seed phrase and private key backup flows. /// @@ -180,7 +181,15 @@ class _SecuritySettingsPageState extends State { return const SeedConfirmSuccess(); case SecuritySettingsStep.privateKeyShow: - return PrivateKeyShow(privateKeys: _sdkPrivateKeys ?? {}); + final tradingState = context.read().state; + final Set blockedAssets = switch (tradingState) { + TradingStatusLoadSuccess s => Set.of(s.disallowedAssets), + _ => const {}, + }; + return PrivateKeyShow( + privateKeys: _sdkPrivateKeys ?? >{}, + blockedAssetIds: blockedAssets, + ); case SecuritySettingsStep.passwordUpdate: _clearAllSensitiveData(); // Clear data when changing password @@ -243,6 +252,9 @@ class _SecuritySettingsPageState extends State { // Fetch private keys directly into local UI state // This keeps sensitive data in minimal scope final privateKeys = await context.sdk.security.getPrivateKeys(); + + // Filter out excluded assets (NFTs only) + // Geo-blocked assets are handled by the UI toggle final filteredPrivateKeyEntries = privateKeys.entries.where( (entry) => !excludedAssetList.contains(entry.key.id), ); diff --git a/lib/views/settings/widgets/security_settings/seed_settings/seed_confirmation/seed_confirmation.dart b/lib/views/settings/widgets/security_settings/seed_settings/seed_confirmation/seed_confirmation.dart index 0eda5a39b4..a876667809 100644 --- a/lib/views/settings/widgets/security_settings/seed_settings/seed_confirmation/seed_confirmation.dart +++ b/lib/views/settings/widgets/security_settings/seed_settings/seed_confirmation/seed_confirmation.dart @@ -13,6 +13,7 @@ import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/views/settings/widgets/security_settings/seed_settings/seed_back_button.dart'; import 'package:web_dex/views/settings/widgets/security_settings/seed_settings/seed_word_button.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; class SeedConfirmation extends StatefulWidget { const SeedConfirmation({required this.seedPhrase}); @@ -30,8 +31,10 @@ class _SeedConfirmationState extends State { @override void initState() { - _originalWords = - widget.seedPhrase.split(' ').map((w) => _SeedWord(word: w)).toList(); + _originalWords = widget.seedPhrase + .split(' ') + .map((w) => _SeedWord(word: w)) + .toList(); _jumbledWords = List.from(_originalWords)..shuffle(); super.initState(); } @@ -39,77 +42,81 @@ class _SeedConfirmationState extends State { @override Widget build(BuildContext context) { final scrollController = ScrollController(); - return DexScrollbar( - isMobile: isMobile, - scrollController: scrollController, - child: SingleChildScrollView( - controller: scrollController, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isMobile) - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: SeedBackButton( - () { + return ScreenshotSensitive( + child: DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isMobile) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: SeedBackButton(() { context.read().add( - AnalyticsBackupSkippedEvent( - stageSkipped: 'seed_confirm', - walletType: context - .read() - .state - .currentUser - ?.wallet - .config - .type - .name ?? - '', - ), - ); - context - .read() - .add(const ShowSeedEvent()); - }, + AnalyticsBackupSkippedEvent( + stageSkipped: 'seed_confirm', + hdType: + context + .read() + .state + .currentUser + ?.wallet + .config + .type + .name ?? + '', + ), + ); + context.read().add( + const ShowSeedEvent(), + ); + }), ), - ), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 680), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Padding( - padding: EdgeInsets.only(right: 10), - child: _Title(), - ), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.only(right: 10), - child: _SelectedWordsField( + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 680), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(right: 10), + child: _Title(), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.only(right: 10), + child: _SelectedWordsField( selectedWords: _selectedWords, - confirmationError: _confirmationError), - ), - const SizedBox(height: 16), - _JumbledSeedWords( - words: _jumbledWords, - selectedWords: _selectedWords, - onWordPressed: _onWordPressed, - ), - const SizedBox(height: 24), - Padding( - padding: const EdgeInsets.only(right: 10), - child: _ControlButtons( - onConfirmPressed: - _isReadyForCheck ? () => _onConfirmPressed() : null, - onClearPressed: _clear), - ), - const SizedBox(height: 16), - ], + confirmationError: _confirmationError, + ), + ), + const SizedBox(height: 16), + _JumbledSeedWords( + words: _jumbledWords, + selectedWords: _selectedWords, + onWordPressed: _onWordPressed, + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.only(right: 10), + child: _ControlButtons( + onConfirmPressed: _isReadyForCheck + ? () => _onConfirmPressed() + : null, + onClearPressed: _clear, + ), + ), + const SizedBox(height: 16), + ], + ), ), - ), - ], + ], + ), ), ), ); @@ -136,19 +143,20 @@ class _SeedConfirmationState extends State { context.read().add(AuthSeedBackupConfirmed()); final walletType = context.read().state.currentUser?.wallet.config.type.name ?? - ''; + ''; context.read().add( - AnalyticsBackupCompletedEvent( - backupTime: 0, - method: 'manual', - walletType: walletType, - ), - ); + AnalyticsBackupCompletedEvent( + backupTime: 0, + method: 'manual', + hdType: walletType, + ), + ); return; } setState(() { - _confirmationError = - TextError(error: LocaleKeys.seedConfirmIncorrectText.tr()); + _confirmationError = TextError( + error: LocaleKeys.seedConfirmIncorrectText.tr(), + ); }); } @@ -194,19 +202,17 @@ class _ResultWord extends StatelessWidget { Widget build(BuildContext context) { return Text( '${word.word} ', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), ); } } class _JumbledSeedWords extends StatelessWidget { - const _JumbledSeedWords( - {required this.onWordPressed, - required this.words, - required this.selectedWords}); + const _JumbledSeedWords({ + required this.onWordPressed, + required this.words, + required this.selectedWords, + }); final List<_SeedWord> words; final List<_SeedWord> selectedWords; final void Function(_SeedWord) onWordPressed; @@ -239,8 +245,10 @@ class _JumbledSeedWords extends StatelessWidget { } class _SelectedWordsField extends StatelessWidget { - const _SelectedWordsField( - {required this.selectedWords, required this.confirmationError}); + const _SelectedWordsField({ + required this.selectedWords, + required this.confirmationError, + }); final List<_SeedWord> selectedWords; final TextError? confirmationError; @@ -275,7 +283,7 @@ class _SelectedWordsField extends StatelessWidget { error.message, style: Theme.of(context).inputDecorationTheme.errorStyle, ), - ) + ), ], ); } diff --git a/lib/views/settings/widgets/security_settings/seed_settings/seed_show.dart b/lib/views/settings/widgets/security_settings/seed_settings/seed_show.dart index 2eea29d1c0..9f409ff432 100644 --- a/lib/views/settings/widgets/security_settings/seed_settings/seed_show.dart +++ b/lib/views/settings/widgets/security_settings/seed_settings/seed_show.dart @@ -20,73 +20,76 @@ import 'package:web_dex/shared/widgets/coin_item/coin_item.dart'; import 'package:web_dex/shared/widgets/dry_intrinsic.dart'; import 'package:web_dex/views/settings/widgets/security_settings/seed_settings/seed_back_button.dart'; import 'package:web_dex/views/wallet/coin_details/receive/qr_code_address.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; class SeedShow extends StatelessWidget { - const SeedShow({ - required this.seedPhrase, - required this.privKeys, - }); + const SeedShow({required this.seedPhrase, required this.privKeys}); final String seedPhrase; final Map privKeys; @override Widget build(BuildContext context) { final scrollController = ScrollController(); - return DexScrollbar( - scrollController: scrollController, - child: SingleChildScrollView( - controller: scrollController, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isMobile) - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: SeedBackButton(() { - context.read().add( - AnalyticsBackupSkippedEvent( - stageSkipped: 'seed_show', - walletType: context - .read() - .state - .currentUser - ?.wallet - .config - .type - .name ?? - '', - ), - ); - context.read().add(const ResetEvent()); - }), - ), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 680), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _TitleRow(), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _ShowingSwitcher(), - _CopySeedButton(seed: seedPhrase), - ], - ), - const SizedBox(height: 16), - Flexible(child: _SeedPlace(seedPhrase: seedPhrase)), - const SizedBox(height: 20), - _SeedPhraseConfirmButton(seedPhrase: seedPhrase), - const SizedBox(height: 40), - _PrivateKeysList(privKeys: privKeys), - ], + return ScreenshotSensitive( + child: DexScrollbar( + scrollController: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isMobile) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: SeedBackButton(() { + context.read().add( + AnalyticsBackupSkippedEvent( + stageSkipped: 'seed_show', + hdType: + context + .read() + .state + .currentUser + ?.wallet + .config + .type + .name ?? + '', + ), + ); + context.read().add( + const ResetEvent(), + ); + }), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 680), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _TitleRow(), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _ShowingSwitcher(), + _CopySeedButton(seed: seedPhrase), + ], + ), + const SizedBox(height: 16), + Flexible(child: _SeedPlace(seedPhrase: seedPhrase)), + const SizedBox(height: 20), + _SeedPhraseConfirmButton(seedPhrase: seedPhrase), + const SizedBox(height: 40), + _PrivateKeysList(privKeys: privKeys), + ], + ), ), - ), - ], + ], + ), ), ), ); @@ -246,8 +249,8 @@ class _TitleRow extends StatelessWidget { child: Text( LocaleKeys.copyWarning.tr(), style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: theme.custom.warningColor, - ), + color: theme.custom.warningColor, + ), ), ), ], @@ -312,9 +315,9 @@ class _ShowingSwitcher extends StatelessWidget { SelectableText( LocaleKeys.seedPhraseShowingShowPhrase.tr(), style: Theme.of(context).textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w500, - fontSize: 12, - ), + fontWeight: FontWeight.w500, + fontSize: 12, + ), ), ], ); @@ -398,7 +401,8 @@ class _WordsList extends StatelessWidget { children: seedList .asMap() .map( - (index, w) => _buildSeedWord(index, w, showSeedWords)) + (index, w) => _buildSeedWord(index, w, showSeedWords), + ) .values .toList(), ), @@ -435,8 +439,9 @@ class _SelectableSeedWord extends StatelessWidget { final numStyle = TextStyle( fontSize: 14, fontWeight: FontWeight.w500, - color: - Theme.of(context).textTheme.bodyMedium?.color?.withValues(alpha: 0.4), + color: Theme.of( + context, + ).textTheme.bodyMedium?.color?.withValues(alpha: 0.4), ); final text = isSeedShown ? initialValue : '••••••'; @@ -450,13 +455,7 @@ class _SelectableSeedWord extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ - SizedBox( - width: 21, - child: Text( - '${index + 1}.', - style: numStyle, - ), - ), + SizedBox(width: 21, child: Text('${index + 1}.', style: numStyle)), Expanded( child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 31), diff --git a/lib/views/wallet/coin_details/coin_details.dart b/lib/views/wallet/coin_details/coin_details.dart index 54193eb8f2..11dc0e9095 100644 --- a/lib/views/wallet/coin_details/coin_details.dart +++ b/lib/views/wallet/coin_details/coin_details.dart @@ -43,14 +43,14 @@ class _CoinDetailsState extends State { _txHistoryBloc.add(TransactionHistorySubscribe(coin: widget.coin)); final walletType = context.read().state.currentUser?.wallet.config.type.name ?? - ''; + ''; context.read().logEvent( - AssetViewedEventData( - assetSymbol: widget.coin.abbr, - assetNetwork: widget.coin.protocolType, - walletType: walletType, - ), - ); + AssetViewedEventData( + asset: widget.coin.abbr, + network: widget.coin.protocolType, + hdType: walletType, + ), + ); }); super.initState(); } diff --git a/lib/views/wallet/coin_details/coin_details_info/charts/coin_sparkline.dart b/lib/views/wallet/coin_details/coin_details_info/charts/coin_sparkline.dart index 02a2f73e12..88fd123692 100644 --- a/lib/views/wallet/coin_details/coin_details_info/charts/coin_sparkline.dart +++ b/lib/views/wallet/coin_details/coin_details_info/charts/coin_sparkline.dart @@ -1,20 +1,21 @@ import 'package:dragon_charts_flutter/dragon_charts_flutter.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart' + show SparklineRepository; +import 'package:komodo_defi_types/komodo_defi_types.dart' show AssetId; import 'package:web_dex/generated/codegen_loader.g.dart'; -import 'package:web_dex/shared/utils/utils.dart'; class CoinSparkline extends StatelessWidget { - final String coinId; - final SparklineRepository repository = sparklineRepository; + const CoinSparkline({required this.coinId, required this.repository}); - CoinSparkline({required this.coinId}); + final AssetId coinId; + final SparklineRepository repository; @override Widget build(BuildContext context) { return FutureBuilder?>( - future: repository.fetchSparkline(abbr2Ticker(coinId)), + future: repository.fetchSparkline(coinId), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting || snapshot.hasError) { 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 2a56e5b724..6b3cece384 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 @@ -13,6 +13,7 @@ import 'package:web_dex/bloc/coin_addresses/bloc/coin_addresses_state.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/utils/formatters.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/widgets/coin_type_tag.dart'; import 'package:web_dex/shared/widgets/truncate_middle_text.dart'; @@ -243,7 +244,6 @@ class AddressCard extends StatelessWidget { ), const SizedBox(height: 12), _Balance(address: address, coin: coin), - const SizedBox(height: 4), ], ) : SizedBox( @@ -286,9 +286,13 @@ class _Balance extends StatelessWidget { @override Widget build(BuildContext context) { + final balance = address.balance.total.toDouble(); + final price = coin.lastKnownUsdPrice(context.sdk); + final usdValue = price == null ? null : price * balance; + final fiat = formatUsdValue(usdValue); + return Text( - '${doubleToString(address.balance.total.toDouble())} ' - '${abbr2Ticker(coin.abbr)} (${address.balance.total.toDouble()})', + '${doubleToString(balance)} ${abbr2Ticker(coin.abbr)} ($fiat)', style: TextStyle(fontSize: isMobile ? 12 : 14), ); } @@ -592,6 +596,7 @@ class SwapAddressTag extends StatelessWidget { @override Widget build(BuildContext context) { + // TODO: Refactor to use "DexPill" component from the SDK UI library (not yet created) return address.isActiveForSwap ? Padding( padding: EdgeInsets.only(left: isMobile ? 4 : 8), @@ -605,7 +610,7 @@ class SwapAddressTag extends StatelessWidget { borderRadius: BorderRadius.circular(16.0), ), child: Text( - LocaleKeys.tradingAddress.tr(), + LocaleKeys.dexAddress.tr(), style: TextStyle(fontSize: isMobile ? 9 : 12), ), ), @@ -642,7 +647,7 @@ class HideZeroBalanceCheckbox extends StatelessWidget { value: hideZeroBalance, onChanged: (value) { context.read().add( - UpdateHideZeroBalanceEvent(value), + CoinAddressesZeroBalanceVisibilityChanged(value), ); }, ); @@ -683,7 +688,7 @@ class CreateButton extends StatelessWidget { createAddressStatus != FormStatus.submitting ? () { context.read().add( - const SubmitCreateAddressEvent(), + const CoinAddressesAddressCreationSubmitted(), ); } : null, 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 7f71ad5f83..1df71d84c1 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 @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -5,18 +7,23 @@ import 'package:flutter_svg/svg.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; 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/app_config/app_config.dart'; -import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; 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/coins_bloc/coins_bloc.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; 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:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; import 'package:web_dex/views/bitrefill/bitrefill_button.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/contract_address_button.dart'; import 'package:web_dex/views/wallet/coin_details/coin_page_type.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart'; class CoinDetailsCommonButtons extends StatelessWidget { const CoinDetailsCommonButtons({ @@ -127,6 +134,10 @@ class CoinDetailsCommonButtonsMobileLayout extends StatelessWidget { ), ], ), + if (coin.id.subClass == CoinSubClass.zhtlc) ...[ + const SizedBox(height: 12), + ZhtlcConfigButton(coin: coin, isMobile: isMobile), + ], ], ); } @@ -150,6 +161,10 @@ class CoinDetailsCommonButtonsDesktopLayout extends StatelessWidget { @override Widget build(BuildContext context) { + final tradingState = context.watch().state; + final canTradeCoin = + !coin.walletOnly && tradingState.canTradeAssets([coin.id]); + return Row( children: [ ConstrainedBox( @@ -171,8 +186,7 @@ class CoinDetailsCommonButtonsDesktopLayout extends StatelessWidget { context: context, ), ), - if (!coin.walletOnly && - context.watch().state is TradingEnabled) + if (canTradeCoin) Container( margin: const EdgeInsets.only(left: 21), constraints: const BoxConstraints(maxWidth: 120), @@ -196,6 +210,12 @@ class CoinDetailsCommonButtonsDesktopLayout extends StatelessWidget { tooltip: _getBitrefillTooltip(coin), ), ), + if (coin.id.subClass == CoinSubClass.zhtlc) + Container( + margin: const EdgeInsets.only(left: 21), + constraints: const BoxConstraints(maxWidth: 120), + child: ZhtlcConfigButton(coin: coin, isMobile: isMobile), + ), Flexible( flex: 2, child: Align( @@ -238,30 +258,31 @@ class CoinDetailsReceiveButton extends StatelessWidget { if (selectedAddress != null && context.mounted) { showDialog( context: context, - builder: (context) => PubkeyReceiveDialog( - coin: coin, - address: selectedAddress, - ), + builder: (context) => + PubkeyReceiveDialog(coin: coin, address: selectedAddress), ); } } @override Widget build(BuildContext context) { - final hasAddresses = - context.watch().state.addresses.isNotEmpty; + final hasAddresses = context + .watch() + .state + .addresses + .isNotEmpty; final ThemeData themeData = Theme.of(context); return UiPrimaryButton( key: const Key('coin-details-receive-button'), height: isMobile ? 52 : 40, prefix: Container( padding: const EdgeInsets.only(right: 14), - child: SvgPicture.asset( - '$assetsPath/others/receive.svg', - ), + child: SvgPicture.asset('$assetsPath/others/receive.svg'), + ), + textStyle: themeData.textTheme.labelLarge?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w600, ), - textStyle: themeData.textTheme.labelLarge - ?.copyWith(fontSize: 14, fontWeight: FontWeight.w600), backgroundColor: themeData.colorScheme.tertiary, onPressed: coin.isSuspended || !hasAddresses ? null @@ -272,11 +293,7 @@ class CoinDetailsReceiveButton extends StatelessWidget { } class AddressListItem extends StatelessWidget { - const AddressListItem({ - super.key, - required this.address, - required this.coin, - }); + const AddressListItem({super.key, required this.address, required this.coin}); final PubkeyInfo address; final Coin coin; @@ -314,20 +331,19 @@ class AddressListItem extends StatelessWidget { Text( address.formatted, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.w500, - letterSpacing: 0.5, - ), + fontFamily: 'monospace', + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + ), ), const SizedBox(height: 2), Text( - '${address.balance.spendable} ${coin.name} available', + '${address.balance.spendable} ${coin.displayName} available', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurface - .withOpacity(0.7), - ), + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.7), + ), ), ], ), @@ -364,12 +380,12 @@ class CoinDetailsSendButton extends StatelessWidget { height: isMobile ? 52 : 40, prefix: Container( padding: const EdgeInsets.only(right: 14), - child: SvgPicture.asset( - '$assetsPath/others/send.svg', - ), + child: SvgPicture.asset('$assetsPath/others/send.svg'), + ), + textStyle: themeData.textTheme.labelLarge?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w600, ), - textStyle: themeData.textTheme.labelLarge - ?.copyWith(fontSize: 14, fontWeight: FontWeight.w600), backgroundColor: themeData.colorScheme.tertiary, onPressed: !coin.isActive || lastKnownBalance == Decimal.zero ? null @@ -407,15 +423,15 @@ class CoinDetailsSwapButton extends StatelessWidget { return UiPrimaryButton( key: const Key('coin-details-swap-button'), height: isMobile ? 52 : 40, - textStyle: themeData.textTheme.labelLarge - ?.copyWith(fontSize: 14, fontWeight: FontWeight.w600), + textStyle: themeData.textTheme.labelLarge?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w600, + ), backgroundColor: themeData.colorScheme.tertiary, - text: LocaleKeys.swapCoin.tr(), + text: LocaleKeys.swap.tr(), prefix: Padding( padding: const EdgeInsets.only(right: 10), - child: SvgPicture.asset( - '$assetsPath/others/swap.svg', - ), + child: SvgPicture.asset('$assetsPath/others/swap.svg'), ), onPressed: !coin.isActive ? null : onClickSwapButton, ); @@ -431,3 +447,79 @@ String? _getBitrefillTooltip(Coin coin) { // Check if coin has zero balance (this could be enhanced with actual balance check) return null; // Let BitrefillButton handle the zero balance tooltip } + +class ZhtlcConfigButton extends StatelessWidget { + const ZhtlcConfigButton({ + required this.coin, + required this.isMobile, + super.key, + }); + + final Coin coin; + final bool isMobile; + + Future _handleConfigUpdate(BuildContext context) async { + final sdk = RepositoryProvider.of(context); + final arrrService = RepositoryProvider.of(context); + final coinsBloc = context.read(); + + // Get the asset from the SDK + final asset = sdk.assets.available[coin.id]; + if (asset == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Asset ${coin.id.id} not found'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + return; + } + + ZhtlcUserConfig? newConfig; + try { + newConfig = await confirmZhtlcConfiguration(context, asset: asset); + if (newConfig != null && context.mounted) { + coinsBloc.add(CoinsDeactivated({coin.id.id})); + await arrrService.updateZhtlcConfig(asset, newConfig); + + // Forcefully navigate back to wallet page so that the zhtlc status bar + // is visible, rather than allowing periodic balance, pubkey, and tx + // history requests to continue running and failing during activation + routingState.selectedMenu = MainMenuValue.wallet; + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error updating configuration: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + if (newConfig != null) { + coinsBloc.add(CoinsActivated([asset.id.id])); + } + } + } + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + return UiPrimaryButton( + key: const Key('coin-details-zhtlc-config-button'), + height: isMobile ? 52 : 40, + prefix: Container( + padding: const EdgeInsets.only(right: 14), + child: const Icon(Icons.settings, size: 18), + ), + textStyle: themeData.textTheme.labelLarge?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + backgroundColor: themeData.colorScheme.tertiary, + onPressed: coin.isActive ? () => _handleConfigUpdate(context) : null, + text: LocaleKeys.zhtlcConfigButton.tr(), + ); + } +} 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 10d3a6dca4..ead0862bb8 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 @@ -65,7 +65,7 @@ class _CoinDetailsInfoState extends State context.sdk, widget.coin.abbr, context.read(), - )..add(LoadAddressesEvent()); + )..add(const CoinAddressesStarted()); @override void initState() { @@ -73,22 +73,22 @@ class _CoinDetailsInfoState extends State const selectedDurationInitial = Duration(hours: 1); context.read().add( - PortfolioGrowthLoadRequested( - coins: [widget.coin], - fiatCoinId: 'USDT', - selectedPeriod: selectedDurationInitial, - walletId: _walletId!, - ), - ); + PortfolioGrowthLoadRequested( + coins: [widget.coin], + fiatCoinId: 'USDT', + selectedPeriod: selectedDurationInitial, + walletId: _walletId!, + ), + ); context.read().add( - ProfitLossPortfolioChartLoadRequested( - coins: [widget.coin], - selectedPeriod: const Duration(hours: 1), - fiatCoinId: 'USDT', - walletId: _walletId!, - ), - ); + ProfitLossPortfolioChartLoadRequested( + coins: [widget.coin], + selectedPeriod: const Duration(hours: 1), + fiatCoinId: 'USDT', + walletId: _walletId!, + ), + ); } @override @@ -100,14 +100,14 @@ class _CoinDetailsInfoState extends State previous.createAddressStatus != current.createAddressStatus && current.createAddressStatus == FormStatus.success, listener: (context, state) { - context - .read() - .add(CoinsPubkeysRequested(widget.coin.abbr)); + context.read().add( + CoinsPubkeysRequested(widget.coin.abbr), + ); }, child: PageLayout( padding: const EdgeInsets.fromLTRB(15, 32, 15, 20), header: PageHeader( - title: widget.coin.name, + title: widget.coin.displayName, widgetTitle: widget.coin.mode == CoinMode.segwit ? const Padding( padding: EdgeInsets.only(left: 6.0), @@ -118,9 +118,7 @@ class _CoinDetailsInfoState extends State onBackButtonPressed: _onBackButtonPressed, actions: [_buildDisableButton()], ), - content: Expanded( - child: _buildContent(context), - ), + content: Expanded(child: _buildContent(context)), ), ), ); @@ -148,9 +146,13 @@ class _CoinDetailsInfoState extends State return DisableCoinButton( onClick: () { - confirmBeforeDisablingCoin(widget.coin, context, onConfirm: () { - widget.onBackButtonPressed(); - }); + confirmBeforeDisablingCoin( + widget.coin, + context, + onConfirm: () { + widget.onBackButtonPressed(); + }, + ); }, ); } @@ -209,19 +211,12 @@ class _DesktopContent extends StatelessWidget { slivers: [ if (selectedTransaction == null) SliverToBoxAdapter( - child: _DesktopCoinDetails( - coin: coin, - setPageType: setPageType, - ), + child: _DesktopCoinDetails(coin: coin, setPageType: setPageType), ), - const SliverToBoxAdapter( - child: SizedBox(height: 20), - ), + const SliverToBoxAdapter(child: SizedBox(height: 20)), if (selectedTransaction == null) CoinAddresses(coin: coin, setPageType: setPageType), - const SliverToBoxAdapter( - child: SizedBox(height: 20), - ), + const SliverToBoxAdapter(child: SizedBox(height: 20)), TransactionTable( coin: coin, selectedTransaction: selectedTransaction, @@ -234,16 +229,16 @@ class _DesktopContent extends StatelessWidget { } class _DesktopCoinDetails extends StatelessWidget { - const _DesktopCoinDetails({ - required this.coin, - required this.setPageType, - }); + const _DesktopCoinDetails({required this.coin, required this.setPageType}); final Coin coin; final void Function(CoinPageType) setPageType; @override Widget build(BuildContext context) { + final tradingState = context.watch().state; + final canTradeCoin = tradingState.canTradeAssets([coin.id]); + return Padding( padding: const EdgeInsets.only(right: 8.0), child: Column( @@ -254,25 +249,16 @@ class _DesktopCoinDetails extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.fromLTRB(0, 5, 12, 0), - child: AssetLogo.ofId( - coin.id, - size: 50, - ), + child: AssetLogo.ofId(coin.id, size: 50), ), _Balance(coin: coin), const SizedBox(width: 10), Padding( padding: const EdgeInsets.only(top: 18.0), - child: _SpecificButton( - coin: coin, - selectWidget: setPageType, - ), + child: _SpecificButton(coin: coin, selectWidget: setPageType), ), const Spacer(), - CoinDetailsInfoFiat( - coin: coin, - isMobile: false, - ), + CoinDetailsInfoFiat(coin: coin, isMobile: false), ], ), Padding( @@ -280,10 +266,9 @@ class _DesktopCoinDetails extends StatelessWidget { child: CoinDetailsCommonButtons( isMobile: false, selectWidget: setPageType, - onClickSwapButton: - context.watch().state is TradingEnabled - ? () => _goToSwap(context, coin) - : null, + onClickSwapButton: canTradeCoin + ? () => _goToSwap(context, coin) + : null, coin: coin, ), ), @@ -320,17 +305,10 @@ class _MobileContent extends StatelessWidget { context: context, ), ), - const SliverToBoxAdapter( - child: SizedBox(height: 20), - ), + const SliverToBoxAdapter(child: SizedBox(height: 20)), if (selectedTransaction == null) - CoinAddresses( - coin: coin, - setPageType: setPageType, - ), - const SliverToBoxAdapter( - child: SizedBox(height: 20), - ), + CoinAddresses(coin: coin, setPageType: setPageType), + const SliverToBoxAdapter(child: SizedBox(height: 20)), TransactionTable( coin: coin, selectedTransaction: selectedTransaction, @@ -354,6 +332,9 @@ class _CoinDetailsInfoHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final tradingState = context.watch().state; + final canTradeCoin = tradingState.canTradeAssets([coin.id]); + return Container( padding: const EdgeInsets.fromLTRB(15, 18, 15, 16), decoration: BoxDecoration( @@ -362,30 +343,23 @@ class _CoinDetailsInfoHeader extends StatelessWidget { ), child: Column( children: [ - AssetIcon.ofTicker( - coin.abbr, - size: 35, - ), + AssetIcon.ofTicker(coin.abbr, size: 35), const SizedBox(height: 8), _Balance(coin: coin), const SizedBox(height: 12), _SpecificButton(coin: coin, selectWidget: setPageType), Padding( padding: const EdgeInsets.only(top: 15.0), - child: CoinDetailsInfoFiat( - coin: coin, - isMobile: true, - ), + child: CoinDetailsInfoFiat(coin: coin, isMobile: true), ), Padding( padding: const EdgeInsets.only(top: 12.0, bottom: 14.0), child: CoinDetailsCommonButtons( isMobile: true, selectWidget: setPageType, - onClickSwapButton: - context.watch().state is TradingEnabled - ? () => _goToSwap(context, coin) - : null, + onClickSwapButton: canTradeCoin + ? () => _goToSwap(context, coin) + : null, coin: coin, ), ), @@ -432,23 +406,23 @@ class _CoinDetailsMarketMetricsTabBarState if (growthState is PortfolioGrowthChartLoadSuccess) { final period = _formatDuration(growthState.selectedPeriod); context.read().logEvent( - PortfolioGrowthViewedEventData( - period: period, - growthPct: growthState.percentageIncrease, - ), - ); + PortfolioGrowthViewedEventData( + period: period, + growthPct: growthState.percentageIncrease, + ), + ); } } else if (_tabController!.index == 1) { final profitLossState = context.read().state; if (profitLossState is PortfolioProfitLossChartLoadSuccess) { final timeframe = _formatDuration(profitLossState.selectedPeriod); context.read().logEvent( - PortfolioPnlViewedEventData( - timeframe: timeframe, - realizedPnl: profitLossState.totalValue, - unrealizedPnl: 0, - ), - ); + PortfolioPnlViewedEventData( + timeframe: timeframe, + realizedPnl: profitLossState.totalValue, + unrealizedPnl: 0, + ), + ); } } } @@ -566,8 +540,9 @@ class _Balance extends StatelessWidget { final value = balance == null ? null : doubleToString(balance); return Column( - crossAxisAlignment: - isMobile ? CrossAxisAlignment.center : CrossAxisAlignment.start, + crossAxisAlignment: isMobile + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (isMobile) @@ -584,8 +559,9 @@ class _Balance extends StatelessWidget { Flexible( child: Row( mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min, - mainAxisAlignment: - isMobile ? MainAxisAlignment.center : MainAxisAlignment.start, + mainAxisAlignment: isMobile + ? MainAxisAlignment.center + : MainAxisAlignment.start, children: [ Flexible( child: AutoScrollText( @@ -632,9 +608,9 @@ class _FiatBalance extends StatelessWidget { Text( LocaleKeys.fiatBalance.tr(), style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: 14, - fontWeight: FontWeight.w500, - ), + fontSize: 14, + fontWeight: FontWeight.w500, + ), ), Padding( padding: const EdgeInsets.only(left: 6.0), diff --git a/lib/views/wallet/coin_details/coin_details_info/coin_details_info_fiat.dart b/lib/views/wallet/coin_details/coin_details_info/coin_details_info_fiat.dart index deebddea08..9631ca4444 100644 --- a/lib/views/wallet/coin_details/coin_details_info/coin_details_info_fiat.dart +++ b/lib/views/wallet/coin_details/coin_details_info/coin_details_info_fiat.dart @@ -28,10 +28,12 @@ class CoinDetailsInfoFiat extends StatelessWidget { padding: isMobile ? null : const EdgeInsets.fromLTRB(0, 6, 4, 0), child: Flex( direction: isMobile ? Axis.horizontal : Axis.vertical, - mainAxisAlignment: - isMobile ? MainAxisAlignment.spaceBetween : MainAxisAlignment.end, - crossAxisAlignment: - isMobile ? CrossAxisAlignment.center : CrossAxisAlignment.end, + mainAxisAlignment: isMobile + ? MainAxisAlignment.spaceBetween + : MainAxisAlignment.end, + crossAxisAlignment: isMobile + ? CrossAxisAlignment.center + : CrossAxisAlignment.end, mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min, children: [ if (isMobile) _AssetFiatBalance(isMobile: isMobile, coin: coin), @@ -45,10 +47,7 @@ class CoinDetailsInfoFiat extends StatelessWidget { } class _AssetFiatPrice extends StatelessWidget { - const _AssetFiatPrice({ - required this.coin, - required this.isMobile, - }); + const _AssetFiatPrice({required this.coin, required this.isMobile}); final Coin coin; final bool isMobile; @@ -69,13 +68,15 @@ class _AssetFiatPrice extends StatelessWidget { return Flex( direction: isMobile ? Axis.vertical : Axis.horizontal, crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, // Prevent layout constraint violations + mainAxisSize: MainAxisSize.min, children: [ - Text(LocaleKeys.price.tr(), - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(fontSize: 14, fontWeight: FontWeight.w500)), + Text( + LocaleKeys.price.tr(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), isMobile ? const SizedBox(height: 3) : const SizedBox(width: 10), Row( children: [ @@ -119,13 +120,16 @@ class _AssetFiatValuePercentageChange extends StatelessWidget { return Flex( direction: isMobile ? Axis.vertical : Axis.horizontal, crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, // Prevent layout constraint violations + mainAxisSize: + MainAxisSize.min, // Prevent layout constraint violations children: [ - Text(LocaleKeys.change24h.tr(), - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(fontSize: 14, fontWeight: FontWeight.w500)), + Text( + LocaleKeys.change24h.tr(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), isMobile ? const SizedBox(height: 3) : const SizedBox(width: 10), TrendPercentageText( percentage: change24hPercent, @@ -146,10 +150,7 @@ class _AssetFiatValuePercentageChange extends StatelessWidget { } class _AssetFiatBalance extends StatelessWidget { - const _AssetFiatBalance({ - required this.isMobile, - required this.coin, - }); + const _AssetFiatBalance({required this.isMobile, required this.coin}); final bool isMobile; final Coin coin; @@ -164,18 +165,15 @@ class _AssetFiatBalance extends StatelessWidget { Text( LocaleKeys.fiatBalance.tr(), style: Theme.of(context).textTheme.titleSmall!.copyWith( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), + fontSize: 14, + fontWeight: FontWeight.w500, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), ), isMobile ? const SizedBox(height: 3) : const SizedBox(width: 10), CoinFiatBalance( coin, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - ), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), ), ], ); diff --git a/lib/views/wallet/coin_details/transactions/transaction_details.dart b/lib/views/wallet/coin_details/transactions/transaction_details.dart index 120f703d58..43ecc5fda2 100644 --- a/lib/views/wallet/coin_details/transactions/transaction_details.dart +++ b/lib/views/wallet/coin_details/transactions/transaction_details.dart @@ -4,7 +4,6 @@ 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/komodo_ui.dart'; -import 'package:komodo_ui/utils.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/common/screen.dart'; @@ -66,10 +65,9 @@ class TransactionDetails extends StatelessWidget { children: [ Text( LocaleKeys.transactionDetailsTitle.tr(), - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(fontSize: 18), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontSize: 18), ), Padding( padding: const EdgeInsets.only(top: 16), @@ -77,7 +75,7 @@ class TransactionDetails extends StatelessWidget { ), Padding( padding: const EdgeInsets.only(top: 6), - child: SelectableText(coin.name), + child: SelectableText(coin.displayName), ), Padding( padding: const EdgeInsets.only(top: 16), @@ -86,9 +84,7 @@ class TransactionDetails extends StatelessWidget { ], ), ), - const SizedBox( - height: 30, - ), + const SizedBox(height: 30), _buildSimpleData( context, title: LocaleKeys.date.tr(), @@ -119,13 +115,17 @@ class TransactionDetails extends StatelessWidget { _buildSimpleData( context, title: LocaleKeys.from.tr(), - value: transaction.from.first, + value: transaction.from.isEmpty + ? LocaleKeys.zhtlcShieldedAddress.tr() + : transaction.from.first, isCopied: true, ), _buildSimpleData( context, title: LocaleKeys.to.tr(), - value: transaction.to.first, + value: transaction.to.isEmpty + ? LocaleKeys.zhtlcShieldedAddress.tr() + : transaction.to.first, isCopied: true, ), SizedBox(height: 16), @@ -155,8 +155,9 @@ class TransactionDetails extends StatelessWidget { flex: 2, child: Text( title, - style: - Theme.of(context).textTheme.bodyLarge?.copyWith(fontSize: 14), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(fontSize: 14), ), ), // Address and copy button @@ -206,8 +207,10 @@ class TransactionDetails extends StatelessWidget { 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 = coinsBloc.getUsdPriceByAmount( + formatted, + transaction.assetId.id, + ); final String formattedUsd = formatAmt(usd ?? 0); final String value = '$sign $formatted ${Coin.normalizeAbbr(transaction.assetId.id)} (\$$formattedUsd)'; @@ -215,9 +218,9 @@ class TransactionDetails extends StatelessWidget { return SelectableText( value, style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontSize: 22, - color: theme.custom.balanceColor, - ), + fontSize: 22, + color: theme.custom.balanceColor, + ), ); } @@ -233,10 +236,10 @@ class TransactionDetails extends StatelessWidget { width: buttonWidth, height: buttonHeight, textStyle: Theme.of(context).textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w700, - fontSize: fontSize, - color: theme.custom.defaultGradientButtonTextColor, - ), + fontWeight: FontWeight.w700, + fontSize: fontSize, + color: theme.custom.defaultGradientButtonTextColor, + ), onPressed: () { launchURLString(getTxExplorerUrl(coin, transaction.txHash ?? '')); }, @@ -248,9 +251,9 @@ class TransactionDetails extends StatelessWidget { height: buttonHeight, onPressed: onClose, textStyle: Theme.of(context).textTheme.labelLarge?.copyWith( - fontSize: fontSize, - fontWeight: FontWeight.w700, - ), + fontSize: fontSize, + fontWeight: FontWeight.w700, + ), backgroundColor: theme.custom.lightButtonColor, text: LocaleKeys.done.tr(), ), @@ -262,8 +265,10 @@ class TransactionDetails extends StatelessWidget { final coinsRepository = RepositoryProvider.of(context); final String formattedFee = transaction.fee?.formatTotal() ?? ''; - final double? usd = - coinsRepository.getUsdPriceByAmount(formattedFee, _feeCoin); + final double? usd = coinsRepository.getUsdPriceByAmount( + formattedFee, + _feeCoin, + ); final String formattedUsd = formatAmt(usd ?? 0); final String title = LocaleKeys.fees.tr(); @@ -280,8 +285,9 @@ class TransactionDetails extends StatelessWidget { flex: 4, child: Text( title, - style: - Theme.of(context).textTheme.bodyLarge?.copyWith(fontSize: 14), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(fontSize: 14), ), ), Expanded( @@ -292,10 +298,10 @@ class TransactionDetails extends StatelessWidget { child: SelectableText( value, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontSize: 14, - fontWeight: FontWeight.w500, - color: theme.custom.decreaseColor, - ), + fontSize: 14, + fontWeight: FontWeight.w500, + color: theme.custom.decreaseColor, + ), ), ), ), @@ -318,8 +324,9 @@ class TransactionDetails extends StatelessWidget { flex: 4, child: Text( '${LocaleKeys.memo.tr()}: ', - style: - Theme.of(context).textTheme.bodyLarge?.copyWith(fontSize: 14), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(fontSize: 14), ), ), Expanded( @@ -329,10 +336,9 @@ class TransactionDetails extends StatelessWidget { alignment: Alignment.centerLeft, child: SelectableText( memo, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(fontSize: 14), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontSize: 14), ), ), ), @@ -359,8 +365,9 @@ class TransactionDetails extends StatelessWidget { flex: 4, child: Text( title, - style: - Theme.of(context).textTheme.bodyLarge?.copyWith(fontSize: 14), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(fontSize: 14), ), ), Expanded( @@ -382,10 +389,9 @@ class TransactionDetails extends StatelessWidget { ) : SelectableText( value, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(fontSize: 14), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontSize: 14), ), ), ), diff --git a/lib/views/wallet/coin_details/transactions/transaction_table.dart b/lib/views/wallet/coin_details/transactions/transaction_table.dart index a63779c674..09b7f9ef13 100644 --- a/lib/views/wallet/coin_details/transactions/transaction_table.dart +++ b/lib/views/wallet/coin_details/transactions/transaction_table.dart @@ -69,15 +69,15 @@ class TransactionTable extends StatelessWidget { return BlocBuilder( builder: (BuildContext ctx, TransactionHistoryState state) { if (state.transactions.isEmpty && state.loading) { - return const SliverToBoxAdapter( - child: UiSpinnerList(), - ); + return const SliverToBoxAdapter(child: UiSpinnerList()); } if (state.error != null) { return SliverToBoxAdapter( child: _ErrorMessage( - text: LocaleKeys.connectionToServersFailing.tr(args: [coin.name]), + text: LocaleKeys.connectionToServersFailing.tr( + args: [coin.displayName], + ), textColor: theme.currentGlobal.colorScheme.error, ), ); @@ -120,7 +120,7 @@ class _TransactionsListWrapper extends StatelessWidget { class _ErrorMessage extends StatelessWidget { const _ErrorMessage({Key? key, required this.text, this.textColor}) - : super(key: key); + : super(key: key); final String text; final Color? textColor; @@ -143,10 +143,7 @@ class _ErrorMessage extends StatelessWidget { child: Center( child: SelectableText( text, - style: TextStyle( - color: textColor, - fontSize: 13, - ), + style: TextStyle(color: textColor, fontSize: 13), ), ), ), @@ -157,20 +154,15 @@ class _ErrorMessage extends StatelessWidget { } class _IguanaCoinWithoutTxHistorySupport extends StatelessWidget { - const _IguanaCoinWithoutTxHistorySupport({ - Key? key, - required this.coin, - }) : super(key: key); + const _IguanaCoinWithoutTxHistorySupport({Key? key, required this.coin}) + : super(key: key); final Coin coin; @override Widget build(BuildContext context) { return Column( children: [ - Text( - LocaleKeys.noTxSupportHidden.tr(), - textAlign: TextAlign.center, - ), + Text(LocaleKeys.noTxSupportHidden.tr(), textAlign: TextAlign.center), Padding( padding: const EdgeInsets.only(top: 10.0), child: LaunchNativeExplorerButton(coin: coin), 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 2b37bb5c45..717fbf92df 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 @@ -27,9 +27,9 @@ class ToAddressField extends StatelessWidget { textInputAction: TextInputAction.next, enableInteractiveSelection: true, onChanged: (value) { - context - .read() - .add(WithdrawFormRecipientChanged(value ?? '')); + context.read().add( + WithdrawFormRecipientChanged(value ?? ''), + ); }, validator: (value) { if (value?.isEmpty ?? true) { @@ -63,14 +63,15 @@ class AmountField extends StatelessWidget { UiTextFormField( key: const Key('withdraw-amount-input'), enabled: !state.isMaxAmount, - keyboardType: - const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), inputFormatters: currencyInputFormatters, textInputAction: TextInputAction.next, onChanged: (value) { - context - .read() - .add(WithdrawFormAmountChanged(value ?? '')); + context.read().add( + WithdrawFormAmountChanged(value ?? ''), + ); }, validator: (value) { if (state.isMaxAmount) return null; @@ -90,9 +91,9 @@ class AmountField extends StatelessWidget { CheckboxListTile( value: state.isMaxAmount, onChanged: (value) { - context - .read() - .add(WithdrawFormMaxAmountEnabled(value ?? false)); + context.read().add( + WithdrawFormMaxAmountEnabled(value ?? false), + ); }, title: Text(LocaleKeys.amountFieldCheckboxListTile.tr()), ), @@ -114,8 +115,10 @@ class FeeSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(LocaleKeys.networkFee.tr(), - style: Theme.of(context).textTheme.titleMedium), + Text( + LocaleKeys.networkFee.tr(), + style: Theme.of(context).textTheme.titleMedium, + ), const SizedBox(height: 8), const CustomFeeToggle(), if (state.isCustomFee) ...[ @@ -160,8 +163,8 @@ class CustomFeeToggle extends StatelessWidget { value: state.isCustomFee, onChanged: (value) { context.read().add( - WithdrawFormCustomFeeEnabled(value), - ); + WithdrawFormCustomFeeEnabled(value), + ); }, contentPadding: EdgeInsets.zero, ); @@ -190,14 +193,14 @@ class EvmFeeFields extends StatelessWidget { final gasPrice = Decimal.tryParse(value ?? ''); if (gasPrice != null) { context.read().add( - WithdrawFormCustomFeeChanged( - FeeInfoEthGas( - coin: state.asset.id.id, - gasPrice: gasPrice, - gas: evmFee?.gas ?? 21000, - ), - ), - ); + WithdrawFormCustomFeeChanged( + FeeInfoEthGas( + coin: state.asset.id.id, + gasPrice: gasPrice, + gas: evmFee?.gas ?? 21000, + ), + ), + ); } }, helperText: 'Higher gas price = faster confirmation', @@ -211,14 +214,14 @@ class EvmFeeFields extends StatelessWidget { final gas = int.tryParse(value ?? ''); if (gas != null) { context.read().add( - WithdrawFormCustomFeeChanged( - FeeInfoEthGas( - coin: state.asset.id.id, - gasPrice: evmFee?.gasPrice ?? Decimal.one, - gas: gas, - ), - ), - ); + WithdrawFormCustomFeeChanged( + FeeInfoEthGas( + coin: state.asset.id.id, + gasPrice: evmFee?.gasPrice ?? Decimal.one, + gas: gas, + ), + ), + ); } }, helperText: 'Estimated: 21000', @@ -260,19 +263,17 @@ class UtxoFeeFields extends StatelessWidget { label: Text('Urgent (${defaultFee * 5})'), ), ], - selected: { - currentFee?.amount.toBigInt().toInt() ?? defaultFee, - }, + selected: {currentFee?.amount.toBigInt().toInt() ?? defaultFee}, onSelectionChanged: (values) { if (values.isNotEmpty) { context.read().add( - WithdrawFormCustomFeeChanged( - FeeInfoUtxoFixed( - coin: state.asset.id.id, - amount: Decimal.fromInt(values.first), - ), - ), - ); + WithdrawFormCustomFeeChanged( + FeeInfoUtxoFixed( + coin: state.asset.id.id, + amount: Decimal.fromInt(values.first), + ), + ), + ); } }, ), @@ -288,30 +289,6 @@ class UtxoFeeFields extends StatelessWidget { } } -/// Field for entering transaction memo -class MemoField extends StatelessWidget { - const MemoField({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return UiTextFormField( - key: const Key('withdraw-memo-input'), - labelText: 'Memo (Optional)', - maxLines: 2, - onChanged: (value) { - context.read().add( - WithdrawFormMemoChanged(value ?? ''), - ); - }, - helperText: 'Required for some exchanges', - ); - }, - ); - } -} - /// Page for confirming withdrawal details class ConfirmationPage extends StatelessWidget { const ConfirmationPage({super.key}); @@ -334,7 +311,8 @@ class ConfirmationPage extends StatelessWidget { children: [ _ConfirmationItem( label: 'From', - value: state.selectedSourceAddress?.address ?? + value: + state.selectedSourceAddress?.address ?? 'Default Wallet', ), const SizedBox(height: 12), @@ -355,10 +333,7 @@ class ConfirmationPage extends StatelessWidget { ), if (state.memo != null) ...[ const SizedBox(height: 12), - _ConfirmationItem( - label: 'Memo', - value: state.memo!, - ), + _ConfirmationItem(label: 'Memo', value: state.memo!), ], ], ), @@ -370,8 +345,8 @@ class ConfirmationPage extends StatelessWidget { Expanded( child: OutlinedButton( onPressed: () => context.read().add( - const WithdrawFormCancelled(), - ), + const WithdrawFormCancelled(), + ), child: Text(LocaleKeys.back.tr()), ), ), @@ -382,8 +357,8 @@ class ConfirmationPage extends StatelessWidget { onPressed: state.isSending ? null : () => context.read().add( - const WithdrawFormSubmitted(), - ), + const WithdrawFormSubmitted(), + ), //TODO! child: state.submissionInProgress child: state.isSending ? const SizedBox( @@ -408,10 +383,7 @@ class _ConfirmationItem extends StatelessWidget { final String label; final String value; - const _ConfirmationItem({ - required this.label, - required this.value, - }); + const _ConfirmationItem({required this.label, required this.value}); @override Widget build(BuildContext context) { @@ -421,17 +393,13 @@ class _ConfirmationItem extends StatelessWidget { Text( label, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.6), - ), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), ), const SizedBox(height: 4), - Text( - value, - style: Theme.of(context).textTheme.bodyLarge, - ), + Text(value, style: Theme.of(context).textTheme.bodyLarge), ], ); } @@ -497,8 +465,8 @@ class FailurePage extends StatelessWidget { Text( 'Withdrawal Failed', style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), + color: Theme.of(context).colorScheme.error, + ), ), const SizedBox(height: 16), if (state.transactionError != null) @@ -513,8 +481,8 @@ class FailurePage extends StatelessWidget { const SizedBox(height: 24), OutlinedButton( onPressed: () => context.read().add( - const WithdrawFormCancelled(), - ), + const WithdrawFormCancelled(), + ), child: Text(LocaleKeys.tryAgain.tr()), ), ], @@ -536,9 +504,9 @@ class IbcTransferField extends StatelessWidget { subtitle: Text(LocaleKeys.ibcTransferFieldSubtitle.tr()), value: state.isIbcTransfer, onChanged: (value) { - context - .read() - .add(WithdrawFormIbcTransferEnabled(value)); + context.read().add( + WithdrawFormIbcTransferEnabled(value), + ); }, ); }, @@ -560,9 +528,9 @@ class IbcChannelField extends StatelessWidget { keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (value) { - context - .read() - .add(WithdrawFormIbcChannelChanged(value ?? '')); + context.read().add( + WithdrawFormIbcChannelChanged(value ?? ''), + ); }, ); }, 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 f7584d32d1..0f05b5dd57 100644 --- a/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart +++ b/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart @@ -16,10 +16,17 @@ import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/copied_text.dart' show CopiedTextV2; 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/fill_form/fields/fields.dart'; import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/withdraw_form_header.dart'; import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/trezor_withdraw_progress_dialog.dart'; +import 'package:web_dex/shared/utils/extensions/kdf_user_extensions.dart'; + +bool _isMemoSupportedProtocol(Asset asset) { + final protocol = asset.protocol; + return protocol is TendermintProtocol || protocol is ZhtlcProtocol; +} class WithdrawForm extends StatefulWidget { final Asset asset; @@ -70,16 +77,15 @@ class _WithdrawFormState extends State { prev.step != curr.step && curr.step == WithdrawFormStep.success, listener: (context, state) { final authBloc = context.read(); - final walletType = - authBloc.state.currentUser?.wallet.config.type.name ?? ''; + final walletType = authBloc.state.currentUser?.type ?? ''; context.read().logEvent( - SendSucceededEventData( - assetSymbol: state.asset.id.id, - network: state.asset.id.subClass.name, - amount: double.tryParse(state.amount) ?? 0.0, - walletType: walletType, - ), - ); + SendSucceededEventData( + asset: state.asset.id.id, + network: state.asset.id.subClass.name, + amount: double.tryParse(state.amount) ?? 0.0, + hdType: walletType, + ), + ); widget.onSuccess(); }, ), @@ -88,17 +94,16 @@ class _WithdrawFormState extends State { prev.step != curr.step && curr.step == WithdrawFormStep.failed, listener: (context, state) { final authBloc = context.read(); - final walletType = - authBloc.state.currentUser?.wallet.config.type.name ?? ''; + final walletType = authBloc.state.currentUser?.type ?? ''; final reason = state.transactionError?.message ?? 'unknown'; context.read().logEvent( - SendFailedEventData( - assetSymbol: state.asset.id.id, - network: state.asset.protocol.subClass.name, - failReason: reason, - walletType: walletType, - ), - ); + SendFailedEventData( + asset: state.asset.id.id, + network: state.asset.protocol.subClass.name, + failureReason: reason, + hdType: walletType, + ), + ); }, ), BlocListener( @@ -114,7 +119,9 @@ class _WithdrawFormState extends State { message: LocaleKeys.trezorTransactionInProgressMessage.tr(), onCancel: () { Navigator.of(context).pop(); - context.read().add(const WithdrawFormCancelled()); + context.read().add( + const WithdrawFormCancelled(), + ); }, ), ); @@ -138,10 +145,7 @@ class _WithdrawFormState extends State { class WithdrawFormContent extends StatelessWidget { final VoidCallback? onBackButtonPressed; - const WithdrawFormContent({ - this.onBackButtonPressed, - super.key, - }); + const WithdrawFormContent({this.onBackButtonPressed, super.key}); @override Widget build(BuildContext context) { @@ -191,11 +195,7 @@ class NetworkErrorDisplay extends StatelessWidget { final TextError error; final VoidCallback? onRetry; - const NetworkErrorDisplay({ - required this.error, - this.onRetry, - super.key, - }); + const NetworkErrorDisplay({required this.error, this.onRetry, super.key}); @override Widget build(BuildContext context) { @@ -228,10 +228,7 @@ class TransactionErrorDisplay extends StatelessWidget { message: error.message, icon: Icons.warning_amber_rounded, child: onDismiss != null - ? IconButton( - icon: const Icon(Icons.close), - onPressed: onDismiss, - ) + ? IconButton(icon: const Icon(Icons.close), onPressed: onDismiss) : null, ); } @@ -255,10 +252,13 @@ class PreviewWithdrawButton extends StatelessWidget { child: UiPrimaryButton( onPressed: onPressed, child: isSending - ? const SizedBox( + ? SizedBox( width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.onPrimary, + ), ) : Text(LocaleKeys.withdrawPreview.tr()), ), @@ -266,13 +266,44 @@ class PreviewWithdrawButton extends StatelessWidget { } } +class ZhtlcPreviewDelayNote extends StatelessWidget { + const ZhtlcPreviewDelayNote({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final backgroundColor = theme.colorScheme.secondaryContainer; + final foregroundColor = theme.colorScheme.onSecondaryContainer; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: foregroundColor), + const SizedBox(width: 12), + Expanded( + child: Text( + LocaleKeys.withdrawPreviewZhtlcNote.tr(), + style: theme.textTheme.bodyMedium?.copyWith( + color: foregroundColor, + ), + ), + ), + ], + ), + ); + } +} + class WithdrawPreviewDetails extends StatelessWidget { final WithdrawalPreview preview; - const WithdrawPreviewDetails({ - required this.preview, - super.key, - }); + const WithdrawPreviewDetails({required this.preview, super.key}); @override Widget build(BuildContext context) { @@ -282,25 +313,50 @@ class WithdrawPreviewDetails extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildRow( + _buildTextRow( LocaleKeys.amount.tr(), preview.balanceChanges.netChange.toString(), ), const SizedBox(height: 8), - _buildRow(LocaleKeys.fee.tr(), preview.fee.formatTotal()), - // Add more preview details as needed + _buildTextRow(LocaleKeys.fee.tr(), preview.fee.formatTotal()), + const SizedBox(height: 8), + _buildRow( + LocaleKeys.recipientAddress.tr(), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + for (final recipient in preview.to) + CopiedTextV2(copiedValue: recipient, fontSize: 14), + ], + ), + ), + if (preview.memo != null) ...[ + const SizedBox(height: 8), + _buildTextRow(LocaleKeys.memo.tr(), preview.memo!), + ], ], ), ), ); } - Widget _buildRow(String label, String value) { + Widget _buildTextRow(String label, String value) { + return _buildRow( + label, + AutoScrollText(text: value, textAlign: TextAlign.right), + ); + } + + Widget _buildRow(String label, Widget value) { return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label), - Text(value), + const SizedBox(width: 12), + Expanded( + child: Align(alignment: Alignment.centerRight, child: value), + ), ], ); } @@ -309,10 +365,7 @@ class WithdrawPreviewDetails extends StatelessWidget { class WithdrawResultDetails extends StatelessWidget { final WithdrawalResult result; - const WithdrawResultDetails({ - required this.result, - super.key, - }); + const WithdrawResultDetails({required this.result, super.key}); @override Widget build(BuildContext context) { @@ -347,8 +400,8 @@ class WithdrawFormFillSection extends StatelessWidget { // Enabled if the asset has multiple source addresses or if there is // no selected address and pubkeys are available. (state.pubkeys?.keys.length ?? 0) > 1 || - (state.selectedSourceAddress == null && - (state.pubkeys?.isNotEmpty ?? false)); + (state.selectedSourceAddress == null && + (state.pubkeys?.isNotEmpty ?? false)); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -360,22 +413,22 @@ class WithdrawFormFillSection extends StatelessWidget { isLoading: state.pubkeys?.isEmpty ?? true, onChanged: isSourceInputEnabled ? (address) => address == null - ? null - : context - .read() - .add(WithdrawFormSourceChanged(address)) + ? 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)), + onChanged: (value) => context.read().add( + WithdrawFormRecipientChanged(value), + ), + onQrScanned: (value) => context.read().add( + WithdrawFormRecipientChanged(value), + ), errorText: state.recipientAddressError == null ? null : () => state.recipientAddressError?.message, @@ -393,12 +446,12 @@ class WithdrawFormFillSection extends StatelessWidget { asset: state.asset, amount: state.amount, isMaxAmount: state.isMaxAmount, - onChanged: (value) => context - .read() - .add(WithdrawFormAmountChanged(value)), - onMaxToggled: (value) => context - .read() - .add(WithdrawFormMaxAmountEnabled(value)), + onChanged: (value) => context.read().add( + WithdrawFormAmountChanged(value), + ), + onMaxToggled: (value) => context.read().add( + WithdrawFormMaxAmountEnabled(value), + ), amountError: state.amountError?.message, ), if (state.isCustomFeeSupported) ...[ @@ -422,9 +475,9 @@ class WithdrawFormFillSection extends StatelessWidget { selectedFee: state.customFee!, isCustomFee: true, // indicates user can edit it onFeeSelected: (newFee) { - context - .read() - .add(WithdrawFormCustomFeeChanged(newFee!)); + context.read().add( + WithdrawFormCustomFeeChanged(newFee!), + ); }, ), @@ -443,12 +496,14 @@ class WithdrawFormFillSection extends StatelessWidget { ], ], const SizedBox(height: 16), - WithdrawMemoField( - memo: state.memo, - onChanged: (value) => context - .read() - .add(WithdrawFormMemoChanged(value)), - ), + 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. @@ -465,21 +520,26 @@ class WithdrawFormFillSection extends StatelessWidget { final authBloc = context.read(); final walletType = authBloc.state.currentUser?.wallet.config.type.name ?? - ''; + ''; context.read().logEvent( - SendInitiatedEventData( - assetSymbol: state.asset.id.id, - network: state.asset.protocol.subClass.name, - amount: double.tryParse(state.amount) ?? 0.0, - walletType: walletType, - ), - ); - context - .read() - .add(const WithdrawFormPreviewSubmitted()); + SendInitiatedEventData( + asset: state.asset.id.id, + network: state.asset.protocol.subClass.name, + amount: double.tryParse(state.amount) ?? 0.0, + hdType: walletType, + ), + ); + context.read().add( + const WithdrawFormPreviewSubmitted(), + ); }, isSending: state.isSending, ), + if (state.asset.id.subClass == CoinSubClass.zhtlc && + state.isSending) ...[ + const SizedBox(height: 12), + const ZhtlcPreviewDelayNote(), + ], ], ); }, @@ -507,9 +567,9 @@ class WithdrawFormConfirmSection extends StatelessWidget { children: [ Expanded( child: OutlinedButton( - onPressed: () => context - .read() - .add(const WithdrawFormCancelled()), + onPressed: () => context.read().add( + const WithdrawFormCancelled(), + ), child: Text(LocaleKeys.back.tr()), ), ), @@ -519,9 +579,9 @@ class WithdrawFormConfirmSection extends StatelessWidget { onPressed: state.isSending ? null : () { - context - .read() - .add(const WithdrawFormSubmitted()); + context.read().add( + const WithdrawFormSubmitted(), + ); }, child: state.isSending ? const SizedBox( @@ -637,19 +697,13 @@ class WithdrawResultCard extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - LocaleKeys.network.tr(), - style: theme.textTheme.titleMedium, - ), + 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, - ), + Text(asset.id.name, style: theme.textTheme.bodyLarge), ], ), ], @@ -668,11 +722,7 @@ class WithdrawFormFailedSection extends StatelessWidget { builder: (context, state) { return Column( children: [ - Icon( - Icons.error_outline, - size: 64, - color: theme.colorScheme.error, - ), + Icon(Icons.error_outline, size: 64, color: theme.colorScheme.error), const SizedBox(height: 24), Text( LocaleKeys.transactionFailed.tr(), @@ -683,24 +733,22 @@ class WithdrawFormFailedSection extends StatelessWidget { ), const SizedBox(height: 24), if (state.transactionError != null) - WithdrawErrorCard( - error: state.transactionError!, - ), + WithdrawErrorCard(error: state.transactionError!), const SizedBox(height: 24), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ OutlinedButton( - onPressed: () => context - .read() - .add(const WithdrawFormStepReverted()), + onPressed: () => context.read().add( + const WithdrawFormStepReverted(), + ), child: Text(LocaleKeys.back.tr()), ), const SizedBox(width: 16), FilledButton( - onPressed: () => context - .read() - .add(const WithdrawFormReset()), + onPressed: () => context.read().add( + const WithdrawFormReset(), + ), child: Text(LocaleKeys.tryAgain.tr()), ), ], @@ -715,10 +763,7 @@ class WithdrawFormFailedSection extends StatelessWidget { class WithdrawErrorCard extends StatelessWidget { final BaseError error; - const WithdrawErrorCard({ - required this.error, - super.key, - }); + const WithdrawErrorCard({required this.error, super.key}); @override Widget build(BuildContext context) { @@ -735,10 +780,7 @@ class WithdrawErrorCard extends StatelessWidget { style: theme.textTheme.titleMedium, ), const SizedBox(height: 8), - SelectableText( - error.message, - style: theme.textTheme.bodyMedium, - ), + SelectableText(error.message, style: theme.textTheme.bodyMedium), if (error is TextError) ...[ const SizedBox(height: 16), const Divider(), @@ -848,8 +890,10 @@ class _RecipientAddressWithNotificationState opacity: 1.0, child: Container( width: double.infinity, - padding: - const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 12, + ), decoration: BoxDecoration( color: statusColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), @@ -857,8 +901,9 @@ class _RecipientAddressWithNotificationState alignment: Alignment.center, child: Text( LocaleKeys.addressConvertedToMixedCase.tr(), - style: - theme.textTheme.labelMedium?.copyWith(color: statusColor), + style: theme.textTheme.labelMedium?.copyWith( + color: statusColor, + ), ), ), ), diff --git a/lib/views/wallet/coins_manager/coins_manager_controls.dart b/lib/views/wallet/coins_manager/coins_manager_controls.dart index aa00abcc52..0259da5a36 100644 --- a/lib/views/wallet/coins_manager/coins_manager_controls.dart +++ b/lib/views/wallet/coins_manager/coins_manager_controls.dart @@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui/komodo_ui.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/coins_manager/coins_manager_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; @@ -10,14 +11,32 @@ import 'package:web_dex/views/custom_token_import/custom_token_import_button.dar import 'package:web_dex/views/wallet/coins_manager/coins_manager_filters_dropdown.dart'; import 'package:web_dex/views/wallet/coins_manager/coins_manager_select_all_button.dart'; -class CoinsManagerFilters extends StatelessWidget { - const CoinsManagerFilters({Key? key, required this.isMobile}) - : super(key: key); +class CoinsManagerFilters extends StatefulWidget { + const CoinsManagerFilters({super.key, required this.isMobile}); final bool isMobile; + @override + State createState() => _CoinsManagerFiltersState(); +} + +class _CoinsManagerFiltersState extends State { + late final Debouncer _debouncer; + + @override + void initState() { + super.initState(); + _debouncer = Debouncer(duration: const Duration(milliseconds: 100)); + } + + @override + void dispose() { + _debouncer.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - if (isMobile) { + if (widget.isMobile) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -70,7 +89,7 @@ class CoinsManagerFilters extends StatelessWidget { Widget _buildSearchField(BuildContext context) { return UiTextFormField( key: const Key('coins-manager-search-field'), - fillColor: isMobile + fillColor: widget.isMobile ? theme.custom.coinsManagerTheme.searchFieldMobileBackgroundColor : null, autocorrect: false, @@ -80,13 +99,14 @@ class CoinsManagerFilters extends StatelessWidget { prefixIcon: const Icon(Icons.search, size: 18), inputFormatters: [LengthLimitingTextInputFormatter(40)], hintText: LocaleKeys.searchAssets.tr(), - hintTextStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - ), - onChanged: (String? text) => context - .read() - .add(CoinsManagerSearchUpdate(text: text ?? '')), + hintTextStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + onChanged: (String? text) => _debouncer.run(() { + if (mounted) { + context.read().add( + CoinsManagerSearchUpdate(text: text ?? ''), + ); + } + }), ); } } 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 8dd3b346b5..faf4091a06 100644 --- a/lib/views/wallet/coins_manager/coins_manager_list_item.dart +++ b/lib/views/wallet/coins_manager/coins_manager_list_item.dart @@ -2,7 +2,6 @@ import 'package:app_theme/app_theme.dart'; import 'package:flutter/material.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_type.dart'; -import 'package:web_dex/model/coin_utils.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'; @@ -47,7 +46,7 @@ class CoinsManagerListItem extends StatelessWidget { ); } - String get _protocolText => getCoinTypeName(coin.type); + String get _protocolText => coin.typeName; } class _CoinsManagerListItemDesktop extends StatelessWidget { diff --git a/lib/views/wallet/coins_manager/coins_manager_page.dart b/lib/views/wallet/coins_manager/coins_manager_page.dart index ad26acfbd7..00810f0475 100644 --- a/lib/views/wallet/coins_manager/coins_manager_page.dart +++ b/lib/views/wallet/coins_manager/coins_manager_page.dart @@ -3,20 +3,20 @@ 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/auth_bloc/auth_bloc.dart'; -import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; -import 'package:web_dex/bloc/coins_manager/coins_manager_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/router/state/wallet_state.dart'; 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/wallet/coins_manager/coins_manager_list_wrapper.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart' + show ZhtlcConfigurationHandler; class CoinsManagerPage extends StatelessWidget { const CoinsManagerPage({ - Key? key, + super.key, required this.action, required this.closePage, - }) : super(key: key); + }); final CoinsManagerAction action; final void Function() closePage; @@ -31,27 +31,29 @@ class CoinsManagerPage extends StatelessWidget { ? LocaleKeys.addAssets.tr() : LocaleKeys.removeAssets.tr(); - return PageLayout( - header: PageHeader( - title: title, - backText: LocaleKeys.backToWallet.tr(), - onBackButtonPressed: closePage, - ), - content: Flexible( - child: Padding( - padding: const EdgeInsets.only(top: 20.0), - child: BlocBuilder( - builder: (context, state) { - if (!state.isSignedIn) { - return const Center( - child: Padding( - padding: EdgeInsets.fromLTRB(0, 100, 0, 100), - child: UiSpinner(), - ), - ); - } - return const CoinsManagerListWrapper(); - }, + return ZhtlcConfigurationHandler( + child: PageLayout( + header: PageHeader( + title: title, + backText: LocaleKeys.backToWallet.tr(), + onBackButtonPressed: closePage, + ), + content: Flexible( + child: Padding( + padding: const EdgeInsets.only(top: 20.0), + child: BlocBuilder( + builder: (context, state) { + if (!state.isSignedIn) { + return const Center( + child: Padding( + padding: EdgeInsets.fromLTRB(0, 100, 0, 100), + child: UiSpinner(), + ), + ); + } + return const CoinsManagerListWrapper(); + }, + ), ), ), ), diff --git a/lib/views/wallet/common/wallet_helper.dart b/lib/views/wallet/common/wallet_helper.dart index bafab6870b..afd9160453 100644 --- a/lib/views/wallet/common/wallet_helper.dart +++ b/lib/views/wallet/common/wallet_helper.dart @@ -26,6 +26,8 @@ import 'package:web_dex/shared/utils/extensions/legacy_coin_migration_extensions /// print(result); // Output: 0.014 /// ``` /// unit tests: [testGetTotal24Change] +/// TODO: consider removing or migrating to the SDK. This function is unreferenced +/// and the unit tests are skipped due to issues mocking the SDK classes/interfaces. double? getTotal24Change(Iterable? coins, KomodoDefiSdk sdk) { double getTotalUsdBalance(Iterable coins) { return coins.fold(0, (prev, coin) { @@ -36,7 +38,7 @@ double? getTotal24Change(Iterable? coins, KomodoDefiSdk sdk) { // embedded in the coin object for now until backup/fallback price // providers are copied over to the SDK final coinPrice = - coin.lastKnownUsdPrice(sdk) ?? coin.usdPrice?.price ?? 0; + coin.lastKnownUsdPrice(sdk) ?? coin.usdPrice?.price?.toDouble() ?? 0; return prev + balance * coinPrice; }); } @@ -48,12 +50,13 @@ double? getTotal24Change(Iterable? coins, KomodoDefiSdk sdk) { Rational totalChange = Rational.zero; for (Coin coin in coins) { - final double? coin24Change = coin.usdPrice?.change24h; + final double? coin24Change = coin.usdPrice?.change24h?.toDouble(); if (coin24Change == null) continue; final balance = coin.lastKnownBalance(sdk)?.spendable.toDouble() ?? 0; - final Rational coinFraction = Rational.parse(balance.toString()) * + final Rational coinFraction = + Rational.parse(balance.toString()) * Rational.parse((coin.usdPrice?.price ?? 0).toString()) / Rational.parse(totalUsdBalance.toString()); final coin24ChangeRat = Rational.parse(coin24Change.toString()); diff --git a/lib/views/wallet/wallet_page/common/asset_list_item_desktop.dart b/lib/views/wallet/wallet_page/common/asset_list_item_desktop.dart index c9ce40f139..c3e8c2496f 100644 --- a/lib/views/wallet/wallet_page/common/asset_list_item_desktop.dart +++ b/lib/views/wallet/wallet_page/common/asset_list_item_desktop.dart @@ -1,11 +1,14 @@ +import 'package:app_theme/src/dark/theme_custom_dark.dart'; +import 'package:app_theme/src/light/theme_custom_light.dart'; import 'package:flutter/material.dart'; -import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:flutter_bloc/flutter_bloc.dart' show RepositoryProvider; +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart' + show SparklineRepository; +import 'package:komodo_defi_types/komodo_defi_types.dart' show AssetId; import 'package:komodo_ui/komodo_ui.dart'; import 'package:web_dex/shared/widgets/asset_item/asset_item.dart'; import 'package:web_dex/shared/widgets/asset_item/asset_item_size.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/coin_sparkline.dart'; -import 'package:app_theme/src/dark/theme_custom_dark.dart'; -import 'package:app_theme/src/light/theme_custom_light.dart'; /// A widget that displays an asset in a list item format optimized for desktop devices. /// @@ -30,27 +33,24 @@ class AssetListItemDesktop extends StatelessWidget { @override Widget build(BuildContext context) { + final sparklineRepository = RepositoryProvider.of( + context, + ); + return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(10)), clipBehavior: Clip.antiAlias, child: Material( color: backgroundColor, child: InkWell( onTap: () => onTap(assetId), child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 16, - ), + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16), child: Row( children: [ Expanded( child: Container( - constraints: const BoxConstraints( - maxWidth: 200, - ), + constraints: const BoxConstraints(maxWidth: 200), alignment: Alignment.centerLeft, child: AssetItem( assetId: assetId, @@ -69,20 +69,20 @@ class AssetListItemDesktop extends StatelessWidget { child: TrendPercentageText( percentage: 23, upColor: Theme.of(context).brightness == Brightness.dark - ? Theme.of(context) - .extension()! - .increaseColor - : Theme.of(context) - .extension()! - .increaseColor, + ? Theme.of( + context, + ).extension()!.increaseColor + : Theme.of( + context, + ).extension()!.increaseColor, downColor: Theme.of(context).brightness == Brightness.dark - ? Theme.of(context) - .extension()! - .decreaseColor - : Theme.of(context) - .extension()! - .decreaseColor, + ? Theme.of( + context, + ).extension()!.decreaseColor + : Theme.of( + context, + ).extension()!.decreaseColor, value: 50, valueFormatter: (value) => NumberFormat.currency(symbol: '\$').format(value), @@ -93,11 +93,12 @@ class AssetListItemDesktop extends StatelessWidget { Expanded( flex: 2, child: InkWell( - onTap: () => onStatisticsTap?.call( - assetId, - const Duration(days: 7), + onTap: () => + onStatisticsTap?.call(assetId, const Duration(days: 7)), + child: CoinSparkline( + coinId: assetId, + repository: sparklineRepository, ), - child: CoinSparkline(coinId: assetId.id), ), ), ], 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 d65ad8fdd9..b0ac070a09 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 @@ -1,5 +1,7 @@ // lib/src/defi/asset/coin_list_item.dart +import 'package:app_theme/src/dark/theme_custom_dark.dart'; +import 'package:app_theme/src/light/theme_custom_light.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -16,8 +18,6 @@ import 'package:web_dex/shared/widgets/coin_balance.dart'; import 'package:web_dex/shared/widgets/coin_fiat_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'; -import 'package:app_theme/src/dark/theme_custom_dark.dart'; -import 'package:app_theme/src/light/theme_custom_light.dart'; import 'package:web_dex/views/wallet/common/address_icon.dart'; /// Widget for showing an authenticated user's balance and anddresses for a @@ -51,10 +51,11 @@ class _ExpandableCoinListItemState extends State { void initState() { super.initState(); // Attempt to restore state from PageStorage using a unique key - _isExpanded = PageStorage.of(context).readState( - context, - identifier: '${widget.coin.abbr}_expanded', - ) as bool? ?? + _isExpanded = + PageStorage.of( + context, + ).readState(context, identifier: '${widget.coin.abbr}_expanded') + as bool? ?? false; } @@ -74,21 +75,22 @@ class _ExpandableCoinListItemState extends State { Widget build(BuildContext context) { final hasAddresses = widget.pubkeys?.keys.isNotEmpty ?? false; final sortedAddresses = hasAddresses - ? (List.of(widget.pubkeys!.keys) - ..sort((a, b) => b.balance.spendable.compareTo(a.balance.spendable))) + ? (List.of( + widget.pubkeys!.keys, + )..sort((a, b) => b.balance.spendable.compareTo(a.balance.spendable))) : null; final children = sortedAddresses != null ? sortedAddresses - .map( - (pubkey) => _AddressRow( - pubkey: pubkey, - coin: widget.coin, - isSwapAddress: pubkey == sortedAddresses.first, - onTap: widget.onTap, - onCopy: () => copyToClipBoard(context, pubkey.address), - ), - ) - .toList() + .map( + (pubkey) => _AddressRow( + pubkey: pubkey, + coin: widget.coin, + isSwapAddress: pubkey == sortedAddresses.first, + onTap: widget.onTap, + onCopy: () => copyToClipBoard(context, pubkey.address), + ), + ) + .toList() : [SkeletonListTile()]; // Match GroupedAssetTickerItem: 16 horizontal, 16 vertical for both (mobile) @@ -101,10 +103,14 @@ class _ExpandableCoinListItemState extends State { key: PageStorageKey('coin_${widget.coin.abbr}'), borderRadius: BorderRadius.circular(12), headerPadding: EdgeInsets.symmetric( - horizontal: horizontalPadding, vertical: verticalPadding), + horizontal: horizontalPadding, + vertical: verticalPadding, + ), onTap: widget.onTap, childrenMargin: EdgeInsets.symmetric( - horizontal: horizontalPadding, vertical: verticalPadding), + horizontal: horizontalPadding, + vertical: verticalPadding, + ), childrenDecoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(12), @@ -146,7 +152,7 @@ class _ExpandableCoinListItemState extends State { children: [ // Coin name - using headlineMedium for bold 16px text Text( - widget.coin.name, + widget.coin.displayName, style: theme.textTheme.headlineMedium, ), // Crypto balance - using bodySmall for 12px secondary text @@ -162,33 +168,39 @@ class _ExpandableCoinListItemState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ // Current balance in USD - using headlineMedium for bold 16px text - Text( - '\$${widget.coin.lastKnownUsdBalance(context.sdk) != null ? NumberFormat("#,##0.00").format(widget.coin.lastKnownUsdBalance(context.sdk)!) : "0.00"}', - style: theme.textTheme.headlineMedium, - ), + if (widget.coin.lastKnownUsdBalance(context.sdk) != null) + Text( + '\$${NumberFormat("#,##0.00").format(widget.coin.lastKnownUsdBalance(context.sdk)!)}', + style: theme.textTheme.headlineMedium, + ), const SizedBox(height: 2), // Trend percentage BlocBuilder( builder: (context, state) { - final usdBalance = - widget.coin.lastKnownUsdBalance(context.sdk) ?? 0.0; + 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; + ? (change24hPercent * usdBalance / 100) + : 0.0; final themeCustom = Theme.of(context).brightness == Brightness.dark - ? Theme.of(context).extension()! - : Theme.of(context).extension()!; + ? Theme.of(context).extension()! + : Theme.of(context).extension()!; return TrendPercentageText( - percentage: change24hPercent ?? 0.0, + percentage: change24hPercent, + value: change24hValue, upColor: themeCustom.increaseColor, downColor: themeCustom.decreaseColor, - value: change24hValue, valueFormatter: (value) => NumberFormat.currency(symbol: '\$').format(value), iconSize: 12, @@ -219,8 +231,10 @@ class _ExpandableCoinListItemState extends State { CoinBalance(coin: widget.coin), BlocBuilder( builder: (context, state) { - final usdBalance = - widget.coin.lastKnownUsdBalance(context.sdk) ?? 0.0; + final usdBalance = widget.coin.lastKnownUsdBalance(context.sdk); + if (usdBalance == null) { + return const SizedBox.shrink(); + } final change24hPercent = usdBalance == 0.0 ? 0.0 @@ -233,13 +247,13 @@ class _ExpandableCoinListItemState extends State { final themeCustom = Theme.of(context).brightness == Brightness.dark - ? Theme.of(context).extension()! - : Theme.of(context).extension()!; + ? Theme.of(context).extension()! + : Theme.of(context).extension()!; return TrendPercentageText( percentage: change24hPercent, + value: change24hValue, upColor: themeCustom.increaseColor, downColor: themeCustom.decreaseColor, - value: change24hValue, valueFormatter: (value) => NumberFormat.currency(symbol: '\$').format(value), ); @@ -270,13 +284,17 @@ class _AddressRow extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final tradingState = context.watch().state; + final canTradeCoin = tradingState.canTradeAssets([coin.id]); return ClipRRect( borderRadius: BorderRadius.circular(12), child: ListTile( onTap: onTap, - contentPadding: - const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + contentPadding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), leading: AddressIcon(address: pubkey.address), title: Row( children: [ @@ -296,12 +314,24 @@ class _AddressRow extends StatelessWidget { visualDensity: VisualDensity.compact, ), ), - if (isSwapAddress && - context.watch().state is TradingEnabled) ...[ + if (isSwapAddress && canTradeCoin) ...[ const SizedBox(width: 8), - Chip( - label: Text( - LocaleKeys.tradingAddress.tr(), + // TODO: Refactor to use "DexPill" component from the SDK UI library (not yet created) + Padding( + padding: EdgeInsets.only(left: isMobile ? 4 : 8), + child: Container( + padding: EdgeInsets.symmetric( + vertical: isMobile ? 6 : 8, + horizontal: isMobile ? 8 : 12.0, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.tertiary, + borderRadius: BorderRadius.circular(16.0), + ), + child: Text( + LocaleKeys.dexAddress.tr(), + style: TextStyle(fontSize: isMobile ? 9 : 12), + ), ), ), ], @@ -358,6 +388,4 @@ class CoinMoreActionsButton extends StatelessWidget { } } -enum CoinMoreActions { - disable, -} +enum CoinMoreActions { disable } diff --git a/lib/views/wallet/wallet_page/common/grouped_asset_ticker_item.dart b/lib/views/wallet/wallet_page/common/grouped_asset_ticker_item.dart index 74f68c40d6..15ff7ef7de 100644 --- a/lib/views/wallet/wallet_page/common/grouped_asset_ticker_item.dart +++ b/lib/views/wallet/wallet_page/common/grouped_asset_ticker_item.dart @@ -5,6 +5,8 @@ import 'package:app_theme/src/light/theme_custom_light.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart' + show SparklineRepository; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui/komodo_ui.dart'; import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; @@ -79,6 +81,9 @@ class _GroupedAssetTickerItemState extends State { ); final theme = Theme.of(context); + final sparklineRepository = RepositoryProvider.of( + context, + ); return Opacity( opacity: widget.isActivating ? 0.3 : 1, @@ -117,8 +122,11 @@ class _GroupedAssetTickerItemState extends State { flex: 2, child: BlocBuilder( builder: (context, state) { - final formattedPrice = price?.price != null - ? priceFormatter.format(price!.price) + // Double conversion required to fix the + // `noSuchMethod` error in the `format` method. + final priceValue = price?.price?.toDouble(); + final formattedPrice = priceValue != null + ? priceFormatter.format(priceValue) : ''; return Text( formattedPrice, @@ -166,7 +174,9 @@ class _GroupedAssetTickerItemState extends State { .decreaseColor, iconSize: 16, percentagePrecision: 2, - value: isMobile ? price?.price : null, + value: isMobile + ? price?.price?.toDouble() + : null, valueFormatter: (price?.price != null) ? (value) => priceFormatter.format(value) @@ -188,7 +198,10 @@ class _GroupedAssetTickerItemState extends State { _primaryAsset, const Duration(days: 7), ), - child: CoinSparkline(coinId: _primaryAsset.id), + child: CoinSparkline( + coinId: _primaryAsset, + repository: sparklineRepository, + ), ), ), ), 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 new file mode 100644 index 0000000000..a88e70d342 --- /dev/null +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart @@ -0,0 +1,264 @@ +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_ui/komodo_ui.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart' show LocaleKeys; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; +import 'package:web_dex/services/arrr_activation/arrr_config.dart'; + +/// Status bar widget to display ZHTLC activation progress for multiple coins +class ZhtlcActivationStatusBar extends StatefulWidget { + const ZhtlcActivationStatusBar({super.key, required this.activationService}); + + final ArrrActivationService activationService; + + @override + State createState() => + _ZhtlcActivationStatusBarState(); +} + +class _ZhtlcActivationStatusBarState extends State { + Timer? _refreshTimer; + Map _cachedStatuses = {}; + StreamSubscription? _authSubscription; + + @override + void initState() { + super.initState(); + _startPeriodicRefresh(); + _subscribeToAuthChanges(); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + _authSubscription?.cancel(); + super.dispose(); + } + + void _subscribeToAuthChanges() { + _authSubscription = context.read().stream.listen((state) { + if (state.currentUser == null) { + unawaited(_handleSignedOut()); + } + }); + } + + void _startPeriodicRefresh() { + unawaited(_refreshStatuses()); + _refreshTimer = Timer.periodic(const Duration(seconds: 1), (_) { + unawaited(_refreshStatuses()); + }); + } + + Future _refreshStatuses() async { + final newStatuses = await widget.activationService.activationStatuses; + + if (!mounted) { + return; + } + + setState(() { + _cachedStatuses = newStatuses; + }); + } + + Future _handleSignedOut() async { + if (!mounted) { + _cachedStatuses = {}; + return; + } + + final assetIds = _cachedStatuses.keys.toList(); + for (final assetId in assetIds) { + await widget.activationService.clearActivationStatus(assetId); + } + + if (!mounted) { + _cachedStatuses = {}; + return; + } + + setState(() { + _cachedStatuses = {}; + }); + } + + @override + Widget build(BuildContext context) { + // Filter out completed statuses older than 5 seconds + // Keep error statuses for 60 seconds (these are final errors after all retries) + final activeStatuses = _cachedStatuses.entries.where((entry) { + final status = entry.value; + return status.when( + inProgress: + ( + assetId, + startTime, + progressPercentage, + currentStep, + statusMessage, + ) => true, + completed: (coinId, completionTime) => + DateTime.now().difference(completionTime).inSeconds < 5, + error: (coinId, errorMessage, errorTime) => + DateTime.now().difference(errorTime).inSeconds < 60, + ); + }).toList(); + + if (activeStatuses.isEmpty) { + return const SizedBox.shrink(); + } + + final coinCount = activeStatuses.length; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 12.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Row( + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: AutoScrollText( + text: LocaleKeys.zhtlcActivating.plural(coinCount), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + Row( + children: [ + const SizedBox(width: 26), + Expanded( + child: AutoScrollText( + text: LocaleKeys.zhtlcActivationWarning.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + const SizedBox(height: 8), + Column( + children: activeStatuses.map((entry) { + final status = entry.value; + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: status.when( + completed: (_, __) => const SizedBox.shrink(), + error: (assetId, errorMessage, errorTime) => Padding( + padding: const EdgeInsets.only(top: 8.0), + child: ErrorDisplay( + message: + '${assetId.id}: ${LocaleKeys.activationFailedMessage.tr()}', + detailedMessage: errorMessage, + showDetails: false, + showIcon: true, + narrowBreakpoint: 400, + ), + ), + inProgress: + ( + assetId, + startTime, + progressPercentage, + currentStep, + statusMessage, + ) { + return _ActivationStatusDetails( + assetId: assetId, + progressPercentage: + progressPercentage?.toDouble() ?? 0, + currentStep: currentStep!, + statusMessage: + statusMessage ?? LocaleKeys.inProgress.tr(), + ); + }, + ), + ); + }).toList(), + ), + ], + ), + ), + ), + ); + } +} + +class _ActivationStatusDetails extends StatelessWidget { + const _ActivationStatusDetails({ + required this.assetId, + required this.progressPercentage, + required this.currentStep, + required this.statusMessage, + }); + + final AssetId assetId; + final double progressPercentage; + final ActivationStep currentStep; + final String statusMessage; + + @override + Widget build(BuildContext context) { + final statusDetailsText = + '${assetId.id}: $statusMessage ' + '(${progressPercentage.toStringAsFixed(0)}%)'; + + return Padding( + padding: const EdgeInsets.only(left: 24.0), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: AutoScrollText( + text: statusDetailsText, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart new file mode 100644 index 0000000000..c0a8dc207c --- /dev/null +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart @@ -0,0 +1,671 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' + show ZhtlcSyncParams; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart' + show + ZhtlcUserConfig, + ZcashParamsDownloader, + ZcashParamsDownloaderFactory, + DownloadProgress, + DownloadResultSuccess; +import 'package:komodo_defi_types/komodo_defi_types.dart' show Asset; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +enum ZhtlcSyncType { earliest, height, date } + +/// Shows ZHTLC configuration dialog similar to handleZhtlcConfigDialog from SDK example +/// This is bad practice (UI logic in utils), but necessary for now because of +/// auto-coin activations from multiple sources in BLoCs. +Future confirmZhtlcConfiguration( + BuildContext context, { + required Asset asset, +}) async { + String? prefilledZcashPath; + + if (ZcashParamsDownloaderFactory.requiresDownload) { + ZcashParamsDownloader? downloader; + try { + downloader = ZcashParamsDownloaderFactory.create(); + + final areAvailable = await downloader.areParamsAvailable(); + if (!areAvailable) { + final downloadResult = await _showZcashDownloadDialog( + context, + downloader, + ); + + if (downloadResult == false) { + // User cancelled the download + return null; + } + } + + prefilledZcashPath = await downloader.getParamsPath(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(LocaleKeys.zhtlcErrorSettingUpZcash.tr(args: ['$e'])), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + downloader?.dispose(); + } + } + + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ZhtlcConfigurationDialog( + asset: asset, + prefilledZcashPath: prefilledZcashPath, + ), + ); +} + +/// Stateful widget for ZHTLC configuration dialog +class ZhtlcConfigurationDialog extends StatefulWidget { + const ZhtlcConfigurationDialog({ + super.key, + required this.asset, + this.prefilledZcashPath, + }); + + final Asset asset; + final String? prefilledZcashPath; + + @override + State createState() => + _ZhtlcConfigurationDialogState(); +} + +class _ZhtlcConfigurationDialogState extends State { + late final TextEditingController zcashPathController; + late final TextEditingController blocksPerIterController; + late final TextEditingController intervalMsController; + StreamSubscription? _authSubscription; + bool _dismissedDueToAuthChange = false; + bool _showAdvancedConfig = false; + + final GlobalKey<_SyncFormState> _syncFormKey = GlobalKey<_SyncFormState>(); + + @override + void initState() { + super.initState(); + + // On web, use './zcash-params' as default, otherwise use prefilledZcashPath + // TODO: get from config factory constructor, or move to constants + final defaultZcashPath = kIsWeb + ? './zcash-params' + : widget.prefilledZcashPath; + zcashPathController = TextEditingController(text: defaultZcashPath); + blocksPerIterController = TextEditingController(text: '1000'); + intervalMsController = TextEditingController(text: '200'); + + _subscribeToAuthChanges(); + } + + @override + void dispose() { + _authSubscription?.cancel(); + zcashPathController.dispose(); + blocksPerIterController.dispose(); + intervalMsController.dispose(); + super.dispose(); + } + + void _handleSave() { + final path = zcashPathController.text.trim(); + // On web, allow empty path, otherwise require it + if (!kIsWeb && path.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(LocaleKeys.zhtlcZcashParamsRequired.tr())), + ); + return; + } + + // Create sync params based on type + final syncState = _syncFormKey.currentState; + final syncParams = syncState?.buildSyncParams(); + if (syncParams == null) { + return; + } + + final result = ZhtlcUserConfig( + zcashParamsPath: path, + scanBlocksPerIteration: + int.tryParse(blocksPerIterController.text) ?? 1000, + scanIntervalMs: int.tryParse(intervalMsController.text) ?? 0, + syncParams: syncParams, + ); + Navigator.of(context).pop(result); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + LocaleKeys.zhtlcConfigureTitle.tr(args: [widget.asset.id.id]), + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 700, minWidth: 300), + child: IntrinsicWidth( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!kIsWeb) ...[ + TextField( + controller: zcashPathController, + readOnly: widget.prefilledZcashPath != null, + decoration: InputDecoration( + labelText: LocaleKeys.zhtlcZcashParamsPathLabel.tr(), + helperText: widget.prefilledZcashPath != null + ? LocaleKeys.zhtlcPathAutomaticallyDetected.tr() + : LocaleKeys.zhtlcSaplingParamsFolder.tr(), + ), + ), + const SizedBox(height: 12), + ], + _SyncForm(key: _syncFormKey), + const SizedBox(height: 24), + _AdvancedConfigurationSection( + showAdvancedConfig: _showAdvancedConfig, + onToggle: () => setState( + () => _showAdvancedConfig = !_showAdvancedConfig, + ), + blocksPerIterController: blocksPerIterController, + intervalMsController: intervalMsController, + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(LocaleKeys.cancel.tr()), + ), + FilledButton(onPressed: _handleSave, child: Text(LocaleKeys.ok.tr())), + ], + ); + } + + void _subscribeToAuthChanges() { + _authSubscription = context.read().stream.listen((state) { + if (state.currentUser == null) { + _handleAuthSignedOut(); + } + }); + } + + void _handleAuthSignedOut() { + if (_dismissedDueToAuthChange || !mounted) { + return; + } + + _dismissedDueToAuthChange = true; + Navigator.of(context).maybePop(null); + } +} + +class _AdvancedConfigurationSection extends StatelessWidget { + const _AdvancedConfigurationSection({ + required this.showAdvancedConfig, + required this.onToggle, + required this.blocksPerIterController, + required this.intervalMsController, + }); + + final bool showAdvancedConfig; + final VoidCallback onToggle; + final TextEditingController blocksPerIterController; + final TextEditingController intervalMsController; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: onToggle, + child: Row( + children: [ + Icon(showAdvancedConfig ? Icons.expand_less : Icons.expand_more), + const SizedBox(width: 8), + Text( + LocaleKeys.zhtlcAdvancedConfiguration.tr(), + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ), + if (showAdvancedConfig) ...[ + const SizedBox(height: 12), + const _AdvancedConfigurationWarning(), + const SizedBox(height: 12), + TextField( + controller: blocksPerIterController, + decoration: InputDecoration( + labelText: LocaleKeys.zhtlcBlocksPerIterationLabel.tr(), + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + TextField( + controller: intervalMsController, + decoration: InputDecoration( + labelText: LocaleKeys.zhtlcScanIntervalLabel.tr(), + ), + keyboardType: TextInputType.number, + ), + ], + ], + ); + } +} + +class _AdvancedConfigurationWarning extends StatelessWidget { + const _AdvancedConfigurationWarning(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final backgroundColor = theme.colorScheme.secondaryContainer; + final foregroundColor = theme.colorScheme.onSecondaryContainer; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.1), + border: Border.all(color: foregroundColor), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: foregroundColor), + const SizedBox(width: 12), + Expanded( + child: Text( + LocaleKeys.zhtlcAdvancedConfigurationHint.tr(), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: foregroundColor), + softWrap: true, + ), + ), + ], + ), + ); + } +} + +class _SyncForm extends StatefulWidget { + const _SyncForm({super.key}); + + @override + State<_SyncForm> createState() => _SyncFormState(); +} + +class _SyncFormState extends State<_SyncForm> { + late final TextEditingController _syncValueController; + ZhtlcSyncType _syncType = ZhtlcSyncType.date; + DateTime? _selectedDate; + + @override + void initState() { + super.initState(); + _selectedDate = DateTime.now().subtract(const Duration(days: 2)); + _syncValueController = TextEditingController( + text: _formatDate(_selectedDate!), + ); + } + + @override + void dispose() { + _syncValueController.dispose(); + super.dispose(); + } + + ZhtlcSyncParams? buildSyncParams() { + switch (_syncType) { + case ZhtlcSyncType.earliest: + return ZhtlcSyncParams.earliest(); + case ZhtlcSyncType.height: + final rawValue = _syncValueController.text.trim(); + final parsedValue = int.tryParse(rawValue); + if (parsedValue == null) { + _showSnackBar(LocaleKeys.zhtlcInvalidBlockHeight.tr()); + return null; + } + return ZhtlcSyncParams.height(parsedValue); + case ZhtlcSyncType.date: + if (_selectedDate == null) { + return null; + } + final unixTimestamp = _selectedDate!.millisecondsSinceEpoch ~/ 1000; + return ZhtlcSyncParams.date(unixTimestamp); + } + } + + void _showSnackBar(String message) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + } + + Future _selectDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDate ?? DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime.now(), + builder: (context, child) { + return Theme( + data: _createMaterial3DatePickerTheme(), + child: child ?? const SizedBox(), + ); + }, + ); + + if (picked != null) { + setState(() { + _selectedDate = DateTime(picked.year, picked.month, picked.day); + _syncValueController.text = _formatDate(_selectedDate!); + }); + } + } + + void _onSyncTypeChanged(ZhtlcSyncType? newType) { + if (newType == null) { + return; + } + + setState(() { + _syncType = newType; + if (_syncType == ZhtlcSyncType.date) { + _selectedDate = DateTime.now().subtract(const Duration(days: 2)); + _syncValueController.text = _formatDate(_selectedDate!); + } else { + _selectedDate = null; + _syncValueController.clear(); + } + }); + } + + String _formatDate(DateTime dateTime) { + return dateTime.toIso8601String().split('T')[0]; + } + + ThemeData _createMaterial3DatePickerTheme() { + final currentTheme = Theme.of(context); + final currentColorScheme = currentTheme.colorScheme; + + final material3ColorScheme = ColorScheme.fromSeed( + seedColor: currentColorScheme.primary, + brightness: currentColorScheme.brightness, + ); + + return ThemeData( + useMaterial3: true, + colorScheme: material3ColorScheme, + fontFamily: currentTheme.textTheme.bodyMedium?.fontFamily, + ); + } + + String _syncTypeLabel(ZhtlcSyncType type) { + switch (type) { + case ZhtlcSyncType.earliest: + return LocaleKeys.zhtlcEarliestSaplingOption.tr(); + case ZhtlcSyncType.height: + return LocaleKeys.zhtlcBlockHeightOption.tr(); + case ZhtlcSyncType.date: + return LocaleKeys.zhtlcDateTimeOption.tr(); + } + } + + bool get _shouldShowValueField => _syncType != ZhtlcSyncType.earliest; + + bool get _isDate => _syncType == ZhtlcSyncType.date; + + bool get _isHeight => _syncType == ZhtlcSyncType.height; + + @override + Widget build(BuildContext context) { + final dropdownItems = ZhtlcSyncType.values + .map( + (type) => DropdownMenuItem( + value: type, + alignment: Alignment.centerLeft, + child: Text(_syncTypeLabel(type)), + ), + ) + .toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(LocaleKeys.zhtlcStartSyncFromLabel.tr()), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: DropdownButtonFormField( + initialValue: _syncType, + items: dropdownItems, + onChanged: _onSyncTypeChanged, + ), + ), + if (_shouldShowValueField) ...[ + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _syncValueController, + decoration: InputDecoration( + labelText: _isHeight + ? LocaleKeys.zhtlcBlockHeightOption.tr() + : LocaleKeys.zhtlcSelectDateTimeLabel.tr(), + suffixIcon: _isDate + ? IconButton( + icon: const Icon(Icons.calendar_today), + onPressed: _selectDate, + ) + : null, + ), + keyboardType: _isHeight + ? TextInputType.number + : TextInputType.none, + readOnly: _isDate, + onTap: _isDate ? () => _selectDate() : null, + ), + ), + ], + ], + ), + if (_shouldShowValueField) ...[ + const SizedBox(height: 24), + if (_isDate) ...[const _SyncTimeWarning()], + ], + ], + ); + } +} + +class _SyncTimeWarning extends StatelessWidget { + const _SyncTimeWarning(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final backgroundColor = theme.colorScheme.secondaryContainer; + final foregroundColor = theme.colorScheme.onSecondaryContainer; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.1), + border: Border.all(color: foregroundColor), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: foregroundColor), + const SizedBox(width: 12), + Expanded( + child: Text( + LocaleKeys.zhtlcDateSyncHint.tr(), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: foregroundColor), + ), + ), + ], + ), + ); + } +} + +/// Shows a download progress dialog for Zcash parameters +Future _showZcashDownloadDialog( + BuildContext context, + ZcashParamsDownloader downloader, +) async { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ZcashDownloadProgressDialog(downloader: downloader), + ); +} + +/// Stateful widget for Zcash download progress dialog +class ZcashDownloadProgressDialog extends StatefulWidget { + const ZcashDownloadProgressDialog({required this.downloader, super.key}); + + final ZcashParamsDownloader downloader; + + @override + State createState() => + _ZcashDownloadProgressDialogState(); +} + +class _ZcashDownloadProgressDialogState + extends State { + static const downloadTimeout = Duration(minutes: 10); + bool downloadComplete = false; + bool downloadSuccess = false; + bool dialogClosed = false; + late Future downloadFuture; + + @override + void initState() { + super.initState(); + _startDownload(); + } + + void _startDownload() { + downloadFuture = widget.downloader + .downloadParams() + .timeout( + downloadTimeout, + onTimeout: () => throw TimeoutException( + 'Download timed out after ${downloadTimeout.inMinutes} minutes', + downloadTimeout, + ), + ) + .then((result) { + if (!downloadComplete && !dialogClosed && mounted) { + downloadComplete = true; + downloadSuccess = result is DownloadResultSuccess; + + // Close the dialog with the result + dialogClosed = true; + Navigator.of(context).pop(downloadSuccess); + } + }) + .catchError((Object e, StackTrace? stackTrace) { + if (!downloadComplete && !dialogClosed && mounted) { + downloadComplete = true; + downloadSuccess = false; + + debugPrint('Zcash parameters download failed: $e'); + if (stackTrace != null) { + debugPrint('Stack trace: $stackTrace'); + } + + // Indicate download failed (null result) + dialogClosed = true; + Navigator.of(context).pop(); + } + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(LocaleKeys.zhtlcDownloadingZcashParams.tr()), + content: SizedBox( + height: 120, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + StreamBuilder( + stream: widget.downloader.downloadProgress, + builder: (context, snapshot) { + if (snapshot.hasData) { + final progress = snapshot.data; + return Column( + children: [ + Text( + progress?.displayText ?? '', + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: (progress?.percentage ?? 0) / 100, + ), + Text( + '${(progress?.percentage ?? 0).toStringAsFixed(1)}%', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ); + } + return Text(LocaleKeys.zhtlcPreparingDownload.tr()); + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () async { + if (!dialogClosed) { + dialogClosed = true; + await widget.downloader.cancelDownload(); + Navigator.of(context).pop(false); // Cancelled + } + }, + child: Text(LocaleKeys.cancel.tr()), + ), + ], + ); + } +} diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart new file mode 100644 index 0000000000..ff581f2d6c --- /dev/null +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart @@ -0,0 +1,165 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:logging/logging.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart' show AssetId; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart' + show confirmZhtlcConfiguration; + +/// Widget that handles ZHTLC configuration dialogs automatically +/// by listening to ArrrActivationService for configuration requests +class ZhtlcConfigurationHandler extends StatefulWidget { + const ZhtlcConfigurationHandler({super.key, required this.child}); + + final Widget child; + + @override + State createState() => + _ZhtlcConfigurationHandlerState(); +} + +class _ZhtlcConfigurationHandlerState extends State { + late StreamSubscription _configRequestSubscription; + late final ArrrActivationService _arrrActivationService; + StreamSubscription? _authSubscription; + final Logger _log = Logger('ZhtlcConfigurationHandler'); + + @override + void initState() { + super.initState(); + _arrrActivationService = RepositoryProvider.of( + context, + ); + _listenToConfigurationRequests(); + _subscribeToAuthChanges(); + } + + @override + void dispose() { + _configRequestSubscription.cancel(); + _authSubscription?.cancel(); + super.dispose(); + } + + void _listenToConfigurationRequests() { + // Listen to configuration requests from the ArrrActivationService + _log.info('Setting up configuration request listener'); + _configRequestSubscription = _arrrActivationService.configurationRequests + .listen( + (configRequest) { + _log.info( + 'Received config request for ${configRequest.asset.id.id}', + ); + if (mounted && + !_handlingConfigurations.contains(configRequest.asset.id)) { + _log.info( + 'Showing configuration dialog for ${configRequest.asset.id.id}', + ); + _showConfigurationDialog(context, configRequest); + } else { + _log.warning( + 'Skipping config request for ${configRequest.asset.id.id} ' + '(mounted: $mounted, already handling: ${_handlingConfigurations.contains(configRequest.asset.id)})', + ); + } + }, + onError: (error, stackTrace) { + _log.severe( + 'Error in configuration request stream', + error, + stackTrace, + ); + }, + onDone: () { + _log.warning('Configuration request stream closed unexpectedly'); + }, + ); + } + + // Track which configuration requests are already being handled to prevent duplicates + static final Set _handlingConfigurations = {}; + + @override + Widget build(BuildContext context) { + return widget.child; + } + + Future _showConfigurationDialog( + BuildContext context, + ZhtlcConfigurationRequest configRequest, + ) async { + _handlingConfigurations.add(configRequest.asset.id); + _log.info('Starting configuration dialog for ${configRequest.asset.id.id}'); + + try { + if (!mounted || !context.mounted) { + _log.warning( + 'Context not mounted, cancelling configuration for ${configRequest.asset.id.id}', + ); + _arrrActivationService.cancelConfiguration(configRequest.asset.id); + return; + } + + final config = await confirmZhtlcConfiguration( + context, + asset: configRequest.asset, + ); + + if (config != null) { + _log.info( + 'User provided configuration for ${configRequest.asset.id.id}', + ); + _arrrActivationService.submitConfiguration( + configRequest.asset.id, + config, + ); + } else { + _log.info( + 'User cancelled configuration for ${configRequest.asset.id.id}', + ); + _arrrActivationService.cancelConfiguration(configRequest.asset.id); + } + } catch (e, stackTrace) { + _log.severe( + 'Error in configuration dialog for ${configRequest.asset.id.id}', + e, + stackTrace, + ); + _arrrActivationService.cancelConfiguration(configRequest.asset.id); + } finally { + _handlingConfigurations.remove(configRequest.asset.id); + _log.info( + 'Finished handling configuration for ${configRequest.asset.id.id}', + ); + } + } + + /// Check if the configuration request listener is active + bool get isListeningToConfigurationRequests => + !_configRequestSubscription.isPaused; + + void _subscribeToAuthChanges() { + _authSubscription = context.read().stream.listen((state) { + if (state.currentUser == null) { + _handleSignedOut(); + } + }); + } + + void _handleSignedOut() { + if (_handlingConfigurations.isEmpty) { + return; + } + + _log.info('Auth signed out - clearing pending ZHTLC configuration state'); + final pendingAssetIds = List.of(_handlingConfigurations); + _handlingConfigurations.clear(); + + for (final assetId in pendingAssetIds) { + _arrrActivationService.cancelConfiguration(assetId); + } + } +} 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 f7ec73a731..18d4303b35 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 @@ -7,17 +7,20 @@ import 'package:komodo_defi_types/komodo_defi_types.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/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/utils/utils.dart'; import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_addresses.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:web_dex/views/wallet/wallet_page/common/expandable_coin_list_item.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart'; class ActiveCoinsList extends StatelessWidget { const ActiveCoinsList({ @@ -25,11 +28,13 @@ class ActiveCoinsList extends StatelessWidget { required this.searchPhrase, required this.withBalance, required this.onCoinItemTap, + this.arrrActivationService, }); final String searchPhrase; final bool withBalance; final Function(Coin) onCoinItemTap; + final ArrrActivationService? arrrActivationService; @override Widget build(BuildContext context) { @@ -61,29 +66,44 @@ class ActiveCoinsList extends StatelessWidget { sorted = removeTestCoins(sorted); } - return SliverList.builder( - itemCount: sorted.length, - itemBuilder: (context, index) { - final coin = sorted[index]; - - // Fetch pubkeys if not already loaded - if (!state.pubkeys.containsKey(coin.abbr)) { - // TODO: Investigate if this is causing performance issues - context.read().add(CoinsPubkeysRequested(coin.abbr)); - } - - return Padding( - padding: EdgeInsets.only(bottom: 10), - child: ExpandableCoinListItem( - // Changed from ExpandableCoinListItem - key: Key('coin-list-item-${coin.abbr.toLowerCase()}'), - coin: coin, - pubkeys: state.pubkeys[coin.abbr], - isSelected: false, - onTap: () => onCoinItemTap(coin), + return SliverMainAxisGroup( + slivers: [ + // ZHTLC Activation Status Bar + if (arrrActivationService != null) + SliverToBoxAdapter( + child: ZhtlcActivationStatusBar( + activationService: arrrActivationService!, + ), ), - ); - }, + + // Coin List + SliverList.builder( + itemCount: sorted.length, + itemBuilder: (context, index) { + final coin = sorted[index]; + + // Fetch pubkeys if not already loaded + if (!state.pubkeys.containsKey(coin.abbr)) { + // TODO: Investigate if this is causing performance issues + context.read().add( + CoinsPubkeysRequested(coin.abbr), + ); + } + + return Padding( + padding: EdgeInsets.only(bottom: 10), + child: ExpandableCoinListItem( + // Changed from ExpandableCoinListItem + key: Key('coin-list-item-${coin.abbr.toLowerCase()}'), + coin: coin, + pubkeys: state.pubkeys[coin.abbr], + isSelected: false, + onTap: () => onCoinItemTap(coin), + ), + ); + }, + ), + ], ); }, ); @@ -219,11 +239,23 @@ class AddressBalanceCard extends StatelessWidget { coinAbbr: coin.abbr, ), if (pubkey.isActiveForSwap) - Chip( - label: Text(LocaleKeys.tradingAddress.tr()), - backgroundColor: Theme.of( - context, - ).primaryColor.withOpacity(0.1), + // TODO: Refactor to use "DexPill" component from the SDK UI library (not yet created) + Padding( + padding: EdgeInsets.only(left: isMobile ? 4 : 8), + child: Container( + padding: EdgeInsets.symmetric( + vertical: isMobile ? 6 : 8, + horizontal: isMobile ? 8 : 12.0, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.tertiary, + borderRadius: BorderRadius.circular(16.0), + ), + child: Text( + LocaleKeys.dexAddress.tr(), + style: TextStyle(fontSize: isMobile ? 9 : 12), + ), + ), ), ], ), 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 6f638c5fa8..6e7b3ec45d 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 @@ -1,20 +1,20 @@ +import 'package:app_theme/src/dark/theme_custom_dark.dart'; +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:app_theme/src/dark/theme_custom_dark.dart'; -import 'package:app_theme/src/light/theme_custom_light.dart'; /// Balance Summary Widget for mobile view class BalanceSummaryWidget extends StatelessWidget { - final double totalBalance; - final double changeAmount; - final double changePercentage; + final double? totalBalance; + final double? changeAmount; + final double? changePercentage; final VoidCallback? onTap; final VoidCallback? onLongPress; const BalanceSummaryWidget({ super.key, - required this.totalBalance, + this.totalBalance, required this.changeAmount, required this.changePercentage, this.onTap, @@ -24,6 +24,9 @@ class BalanceSummaryWidget extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final themeCustom = Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).extension()! + : Theme.of(context).extension()!; return GestureDetector( onTap: onTap, @@ -37,37 +40,64 @@ class BalanceSummaryWidget extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Total balance - Text( - '\$${NumberFormat("#,##0.00").format(totalBalance)}', - style: theme.textTheme.headlineSmall, - ), + // 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 - TrendPercentageText( - percentage: changePercentage, - upColor: Theme.of(context).brightness == Brightness.dark - ? Theme.of(context) - .extension()! - .increaseColor - : Theme.of(context) - .extension()! - .increaseColor, - downColor: Theme.of(context).brightness == Brightness.dark - ? Theme.of(context) - .extension()! - .decreaseColor - : Theme.of(context) - .extension()! - .decreaseColor, - value: changeAmount, - valueFormatter: (value) => - NumberFormat.currency(symbol: '\$').format(value), - ), + // 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(), ], ), ), ); } } + +class _BalancePlaceholder extends StatelessWidget { + const _BalancePlaceholder(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + height: 32, + width: 160, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(6), + ), + ); + } +} + +class _ChangePlaceholder extends StatelessWidget { + const _ChangePlaceholder(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + height: 20, + width: 100, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(4), + ), + ); + } +} 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 9c4f375e7e..3d48937738 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart @@ -9,7 +9,6 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/analytics/events.dart'; -import 'package:web_dex/analytics/events/misc_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/assets_overview/bloc/asset_overview_bloc.dart'; @@ -34,6 +33,7 @@ import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/router/state/routing_state.dart'; import 'package:web_dex/router/state/wallet_state.dart'; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; 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/dex/dex_helpers.dart'; @@ -41,6 +41,8 @@ import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/portf import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart'; import 'package:web_dex/views/wallet/wallet_page/charts/coin_prices_chart.dart'; import 'package:web_dex/views/wallet/wallet_page/common/assets_list.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart' + show ZhtlcConfigurationHandler; import 'package:web_dex/views/wallet/wallet_page/wallet_main/active_coins_list.dart'; import 'package:web_dex/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart'; import 'package:web_dex/views/wallet/wallet_page/wallet_main/wallet_overview.dart'; @@ -129,65 +131,70 @@ class _WalletMainState extends State with TickerProviderStateMixin { : AuthorizeMode.logIn; final isLoggedIn = authStateMode == AuthorizeMode.logIn; - return 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), + 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), + ), ), - ), - const SliverToBoxAdapter(child: Gap(24)), - ], - 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()), - ], + const SliverToBoxAdapter(child: Gap(24)), + ], + 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, walletCoinsFiltered), + ], + ), ), ), - ), - ); - }, + ); + }, + ), ); }, ); @@ -226,20 +233,14 @@ class _WalletMainState extends State with TickerProviderStateMixin { ), ); - assetOverviewBloc - ..add( - PortfolioAssetsOverviewLoadRequested( - coins: walletCoins, - walletId: walletId, - ), - ) - ..add( - PortfolioAssetsOverviewSubscriptionRequested( - coins: walletCoins, - walletId: walletId, - updateFrequency: const Duration(minutes: 1), - ), - ); + // Subscribe fires an immediate load event, so no need to also call load + assetOverviewBloc.add( + PortfolioAssetsOverviewSubscriptionRequested( + coins: walletCoins, + walletId: walletId, + updateFrequency: const Duration(minutes: 1), + ), + ); } void _clearWalletData() { @@ -253,9 +254,9 @@ class _WalletMainState extends State with TickerProviderStateMixin { } void _onShowCoinsWithBalanceClick(bool value) { - context - .read() - .add(HideZeroBalanceAssetsChanged(hideZeroBalanceAssets: value)); + context.read().add( + HideZeroBalanceAssetsChanged(hideZeroBalanceAssets: value), + ); } void _onSearchChange(String searchKey) { @@ -276,11 +277,8 @@ class _WalletMainState extends State with TickerProviderStateMixin { void _onAssetStatisticsTap(AssetId assetId, Duration period) { context.read().add( - PriceChartStarted( - symbols: [assetId.symbol.configSymbol], - period: period, - ), - ); + PriceChartStarted(symbols: [assetId.symbol.configSymbol], period: period), + ); _tabController.animateTo(1); } @@ -291,8 +289,10 @@ class _WalletMainState extends State with TickerProviderStateMixin { SliverPersistentHeader( pinned: true, delegate: _SliverSearchBarDelegate( - withBalance: - context.watch().state.hideZeroBalanceAssets, + withBalance: context + .watch() + .state + .hideZeroBalanceAssets, onSearchChange: _onSearchChange, onWithBalanceChange: _onShowCoinsWithBalanceClick, mode: mode, @@ -302,8 +302,10 @@ class _WalletMainState extends State with TickerProviderStateMixin { CoinListView( mode: mode, searchPhrase: _searchKey, - withBalance: - context.watch().state.hideZeroBalanceAssets, + withBalance: context + .watch() + .state + .hideZeroBalanceAssets, onActiveCoinItemTap: _onActiveCoinItemTap, onAssetItemTap: _onAssetItemTap, onAssetStatisticsTap: _onAssetStatisticsTap, @@ -345,11 +347,11 @@ class _WalletMainState extends State with TickerProviderStateMixin { _walletHalfLogged = true; final coinsCount = context.read().state.walletCoins.length; context.read().logEvent( - WalletListHalfViewportReachedEventData( - timeToHalfMs: _walletListStopwatch.elapsedMilliseconds, - walletSize: coinsCount, - ), - ); + WalletListHalfViewportReachedEventData( + timeToHalfMs: _walletListStopwatch.elapsedMilliseconds, + walletSize: coinsCount, + ), + ); } } @@ -361,12 +363,6 @@ class _WalletMainState extends State with TickerProviderStateMixin { .clamp(position.minScrollExtent, position.maxScrollExtent); if (newOffset == _scrollController.offset) { - context.read().logEvent( - ScrollAttemptOutsideContentEventData( - screenContext: 'wallet_page', - scrollDelta: event.scrollDelta.dy, - ), - ); return; } @@ -421,6 +417,9 @@ class CoinListView extends StatelessWidget { searchPhrase: searchPhrase, withBalance: withBalance, onCoinItemTap: onActiveCoinItemTap, + arrrActivationService: RepositoryProvider.of( + context, + ), ); case AuthorizeMode.hiddenLogin: case AuthorizeMode.noLogin: @@ -437,8 +436,8 @@ class CoinListView extends StatelessWidget { searchPhrase: searchPhrase, onAssetItemTap: (assetId) => onAssetItemTap( context.read().state.coins.values.firstWhere( - (coin) => coin.assetId == assetId, - ), + (coin) => coin.assetId == assetId, + ), ), onStatisticsTap: onAssetStatisticsTap, ); @@ -471,8 +470,10 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { bool overlapsContent, ) { // Apply collapse progress on both mobile and desktop - final collapseProgress = - (shrinkOffset / (maxExtent - minExtent)).clamp(0.0, 1.0); + final collapseProgress = (shrinkOffset / (maxExtent - minExtent)).clamp( + 0.0, + 1.0, + ); return SizedBox( height: (maxExtent - shrinkOffset).clamp(minExtent, maxExtent), 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 88f8150973..2cd16cfcaa 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_overview.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_overview.dart @@ -5,12 +5,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_ui/komodo_ui.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +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/analytics/analytics_bloc.dart'; -import 'package:web_dex/analytics/events/portfolio_events.dart'; import 'package:web_dex/common/screen.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/coin.dart'; @@ -23,7 +23,7 @@ import 'package:web_dex/views/wallet/wallet_page/wallet_main/balance_summary_wid // that bloc is primarily focused on chart data. // // IMPLEMENTATION NOTES: -// - Current Balance: Uses PortfolioGrowthBloc.totalBalance with fallback to calculated balance +// - Current Balance: Uses calculated total balance from the SDK // - 24h Change: Uses PortfolioGrowthBloc.percentageChange24h and totalChange24h // - All-time Investment: Uses AssetOverviewBloc.totalInvestment // - All-time Profit: Uses AssetOverviewBloc.profitAmount and profitIncreasePercentage @@ -49,37 +49,39 @@ class _WalletOverviewState extends State { @override Widget build(BuildContext context) { + final themeCustom = Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).extension()! + : Theme.of(context).extension()!; + return BlocBuilder( builder: (context, state) { if (state.coins.isEmpty) return _buildSpinner(); final portfolioAssetsOverviewBloc = context.watch(); final int assetCount = state.walletCoins.length; - // Get the portfolio growth bloc to access balance and 24h change - final portfolioGrowthBloc = context.watch(); - final portfolioGrowthState = portfolioGrowthBloc.state; - // Get asset overview state data - final stateWithData = portfolioAssetsOverviewBloc.state + final stateWithData = + portfolioAssetsOverviewBloc.state is PortfolioAssetsOverviewLoadSuccess ? portfolioAssetsOverviewBloc.state - as PortfolioAssetsOverviewLoadSuccess + as PortfolioAssetsOverviewLoadSuccess : null; - // Get total balance from the PortfolioGrowthBloc if available, otherwise calculate - final double totalBalance = - portfolioGrowthState is PortfolioGrowthChartLoadSuccess - ? portfolioGrowthState.totalBalance - : stateWithData?.totalValue.value ?? - _getTotalBalance(state.walletCoins.values, context); + // 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, + ); - if (!_logged && stateWithData != null) { + if (!_logged && stateWithData != null && totalBalance != null) { context.read().logEvent( - PortfolioViewedEventData( - totalCoins: assetCount, - totalValueUsd: stateWithData.totalValue.value, - ), - ); + PortfolioViewedEventData( + totalCoins: assetCount, + totalValueUsd: stateWithData.totalValue.value, + ), + ); _logged = true; } @@ -92,23 +94,26 @@ class _WalletOverviewState extends State { builder: (context, state) { final double totalChange24h = state is PortfolioGrowthChartLoadSuccess - ? state.totalChange24h - : 0.0; + ? state.totalChange24h + : 0.0; final double percentageChange24h = state is PortfolioGrowthChartLoadSuccess - ? state.percentageChange24h - : 0.0; + ? state.percentageChange24h + : 0.0; return BalanceSummaryWidget( totalBalance: totalBalance, changeAmount: totalChange24h, changePercentage: percentageChange24h, onTap: widget.onAssetsPressed, - onLongPress: () { - final formattedValue = NumberFormat.currency(symbol: '\$') - .format(totalBalance); - copyToClipBoard(context, formattedValue); - }, + onLongPress: totalBalance != null + ? () { + final formattedValue = NumberFormat.currency( + symbol: '\$', + ).format(totalBalance); + copyToClipBoard(context, formattedValue); + } + : null, ); }, ), @@ -118,61 +123,57 @@ class _WalletOverviewState extends State { caption: Text(LocaleKeys.yourBalance.tr()), value: totalBalance, onTap: widget.onAssetsPressed, - onLongPress: () { - final formattedValue = - NumberFormat.currency(symbol: '\$').format(totalBalance); - copyToClipBoard(context, formattedValue); - }, - trendWidget: - BlocBuilder( - builder: (context, state) { - final double totalChange = - state is PortfolioGrowthChartLoadSuccess - ? state.percentageChange24h - : 0.0; - final double totalChange24h = - state is PortfolioGrowthChartLoadSuccess - ? state.totalChange24h - : 0.0; + onLongPress: totalBalance != null + ? () { + final formattedValue = NumberFormat.currency( + symbol: '\$', + ).format(totalBalance); + copyToClipBoard(context, formattedValue); + } + : null, + trendWidget: totalBalance != null + ? BlocBuilder( + builder: (context, state) { + final double totalChange = + state is PortfolioGrowthChartLoadSuccess + ? state.percentageChange24h + : 0.0; + final double totalChange24h = + state is PortfolioGrowthChartLoadSuccess + ? state.totalChange24h + : 0.0; - return TrendPercentageText( - percentage: totalChange, - upColor: Theme.of(context).brightness == Brightness.dark - ? Theme.of(context) - .extension()! - .increaseColor - : Theme.of(context) - .extension()! - .increaseColor, - downColor: Theme.of(context).brightness == Brightness.dark - ? Theme.of(context) - .extension()! - .decreaseColor - : Theme.of(context) - .extension()! - .decreaseColor, - value: totalChange24h, - valueFormatter: NumberFormat.currency(symbol: '\$').format, - ); - }, - ), + return TrendPercentageText( + percentage: totalChange, + upColor: themeCustom.increaseColor, + downColor: themeCustom.decreaseColor, + value: totalChange24h, + valueFormatter: NumberFormat.currency( + symbol: '\$', + ).format, + ); + }, + ) + : null, ), ], StatisticCard( key: const Key('overview-all-time-investment'), caption: Text(LocaleKeys.allTimeInvestment.tr()), - value: stateWithData?.totalInvestment.value ?? 0, + value: totalBalance != null + ? (stateWithData?.totalInvestment.value) + : null, onTap: widget.onPortfolioGrowthPressed, - onLongPress: () { - final formattedValue = NumberFormat.currency(symbol: '\$') - .format(stateWithData?.totalInvestment.value ?? 0); - copyToClipBoard(context, formattedValue); - }, + onLongPress: totalBalance != null && stateWithData != null + ? () { + final formattedValue = NumberFormat.currency( + symbol: '\$', + ).format(stateWithData.totalInvestment.value); + copyToClipBoard(context, formattedValue); + } + : null, trendWidget: ActionChip( - avatar: Icon( - Icons.pie_chart, - size: 16, - ), + avatar: Icon(Icons.pie_chart, size: 16), onPressed: widget.onAssetsPressed, visualDensity: const VisualDensity(vertical: -4), label: Text( @@ -187,35 +188,28 @@ class _WalletOverviewState extends State { StatisticCard( key: const Key('overview-all-time-profit'), caption: Text(LocaleKeys.allTimeProfit.tr()), - value: stateWithData?.profitAmount.value ?? 0, + value: totalBalance != null + ? (stateWithData?.profitAmount.value) + : null, onTap: widget.onPortfolioProfitLossPressed, - onLongPress: () { - final formattedValue = NumberFormat.currency(symbol: '\$') - .format(stateWithData?.profitAmount.value ?? 0); - copyToClipBoard(context, formattedValue); - }, - trendWidget: stateWithData != null + onLongPress: totalBalance != null && stateWithData != null + ? () { + final formattedValue = NumberFormat.currency( + symbol: '\$', + ).format(stateWithData.profitAmount.value); + copyToClipBoard(context, formattedValue); + } + : null, + trendWidget: totalBalance != null && stateWithData != null ? TrendPercentageText( percentage: stateWithData.profitIncreasePercentage, - upColor: Theme.of(context).brightness == Brightness.dark - ? Theme.of(context) - .extension()! - .increaseColor - : Theme.of(context) - .extension()! - .increaseColor, - downColor: Theme.of(context).brightness == Brightness.dark - ? Theme.of(context) - .extension()! - .decreaseColor - : Theme.of(context) - .extension()! - .decreaseColor, + upColor: themeCustom.increaseColor, + downColor: themeCustom.decreaseColor, // Show the total profit amount as the value value: stateWithData.profitAmount.value, valueFormatter: NumberFormat.currency(symbol: '\$').format, ) - : const SizedBox.shrink(), + : null, ), ]; @@ -225,9 +219,7 @@ class _WalletOverviewState extends State { return Row( spacing: 24, children: statisticCards.map((card) { - return Expanded( - child: card, - ); + return Expanded(child: card); }).toList(), ); } @@ -238,19 +230,26 @@ class _WalletOverviewState extends State { Widget _buildSpinner() { return const Row( mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: EdgeInsets.all(20.0), - child: UiSpinner(), - ), - ], + 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) { + 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, + ); + + // If no USD balance data is available, return null to show placeholder + if (!hasAnyUsdBalance) { + return null; + } + double total = coins.fold( - 0, (prev, coin) => prev + (coin.usdBalance(context.sdk) ?? 0)); + 0, + (prev, coin) => prev + (coin.usdBalance(context.sdk) ?? 0), + ); if (total > 0.01) { return total; @@ -264,10 +263,7 @@ class _WalletOverviewState extends State { class StatisticsCarousel extends StatefulWidget { final List cards; - const StatisticsCarousel({ - super.key, - required this.cards, - }); + const StatisticsCarousel({super.key, required this.cards}); @override State createState() => _StatisticsCarouselState(); diff --git a/lib/views/wallets_manager/wallets_manager_wrapper.dart b/lib/views/wallets_manager/wallets_manager_wrapper.dart index 9b2949fff1..d7ff865464 100644 --- a/lib/views/wallets_manager/wallets_manager_wrapper.dart +++ b/lib/views/wallets_manager/wallets_manager_wrapper.dart @@ -12,6 +12,7 @@ class WalletsManagerWrapper extends StatefulWidget { this.onSuccess, this.selectedWallet, this.initialHdMode = false, + this.rememberMe = false, super.key = const Key('wallets-manager-wrapper'), }); @@ -19,6 +20,7 @@ class WalletsManagerWrapper extends StatefulWidget { final WalletsManagerEventType eventType; final Wallet? selectedWallet; final bool initialHdMode; + final bool rememberMe; @override State createState() => _WalletsManagerWrapperState(); @@ -40,14 +42,13 @@ class _WalletsManagerWrapperState extends State { children: [ Text( LocaleKeys.walletsTypeListTitle.tr(), - style: - Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: 16), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontSize: 16), ), Padding( padding: const EdgeInsets.only(top: 30.0), - child: WalletsTypeList( - onWalletTypeClick: _onWalletTypeClick, - ), + child: WalletsTypeList(onWalletTypeClick: _onWalletTypeClick), ), ], ); @@ -62,6 +63,7 @@ class _WalletsManagerWrapperState extends State { initialHdMode: widget.selectedWallet?.config.type == WalletType.hdwallet ? true : widget.initialHdMode, + rememberMe: widget.rememberMe, ); } diff --git a/lib/views/wallets_manager/widgets/creation_password_fields.dart b/lib/views/wallets_manager/widgets/creation_password_fields.dart index f4c608d515..bf3a414d95 100644 --- a/lib/views/wallets_manager/widgets/creation_password_fields.dart +++ b/lib/views/wallets_manager/widgets/creation_password_fields.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:web_dex/shared/constants.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:web_dex/bloc/settings/settings_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; @@ -12,10 +13,12 @@ class CreationPasswordFields extends StatefulWidget { const CreationPasswordFields({ Key? key, required this.passwordController, + this.onValidityChanged, this.onFieldSubmitted, }) : super(key: key); final TextEditingController passwordController; + final void Function(bool isValid)? onValidityChanged; final void Function(String)? onFieldSubmitted; @override @@ -61,10 +64,12 @@ class _CreationPasswordFieldsState extends State { autocorrect: false, obscureText: _isObscured, enableInteractiveSelection: true, - validationMode: InputValidationMode.eager, - inputFormatters: [LengthLimitingTextInputFormatter(40)], + validationMode: InputValidationMode.passive, + maxLength: passwordMaxLength, validator: _validateConfirmPasswordField, + onChanged: (_) => _notifyValidityChanged(), onFieldSubmitted: widget.onFieldSubmitted, + counterText: '', errorMaxLines: 6, autofillHints: const [AutofillHints.newPassword], hintText: LocaleKeys.walletCreationConfirmPasswordHint.tr(), @@ -79,8 +84,11 @@ class _CreationPasswordFieldsState extends State { autocorrect: false, enableInteractiveSelection: true, obscureText: _isObscured, - inputFormatters: [LengthLimitingTextInputFormatter(40)], + maxLength: passwordMaxLength, + counterText: '', validator: _validatePasswordField, + validationMode: InputValidationMode.passive, + onChanged: (_) => _notifyValidityChanged(), errorMaxLines: 6, autofillHints: const [AutofillHints.newPassword], hintText: LocaleKeys.walletCreationPasswordHint.tr(), @@ -114,4 +122,20 @@ class _CreationPasswordFieldsState extends State { confirmPasswordFieldInput ?? '', ); } + + void _notifyValidityChanged() { + if (widget.onValidityChanged == null) return; + + final settingsBlocState = context.read().state; + final allowWeakPassword = settingsBlocState.weakPasswordsAllowed; + + final password = widget.passwordController.text; + final confirm = _confirmPasswordController.text; + + final isPasswordValid = + allowWeakPassword || validatePassword(password) == null; + final isConfirmValid = validateConfirmPassword(password, confirm) == null; + + widget.onValidityChanged!.call(isPasswordValid && isConfirmValid); + } } diff --git a/lib/views/wallets_manager/widgets/hdwallet_mode_switch.dart b/lib/views/wallets_manager/widgets/hdwallet_mode_switch.dart index 3cab7d3405..e039d19cae 100644 --- a/lib/views/wallets_manager/widgets/hdwallet_mode_switch.dart +++ b/lib/views/wallets_manager/widgets/hdwallet_mode_switch.dart @@ -21,15 +21,13 @@ class HDWalletModeSwitch extends StatelessWidget { const SizedBox(width: 8), Tooltip( message: LocaleKeys.hdWalletModeSwitchTooltip.tr(), - child: const Icon(Icons.info, size: 16), + child: Icon(Icons.info, size: 16), ), ], ), subtitle: Text( LocaleKeys.hdWalletModeSwitchSubtitle.tr(), - style: const TextStyle( - fontSize: 12, - ), + style: Theme.of(context).textTheme.bodySmall, ), value: value, onChanged: onChanged, diff --git a/lib/views/wallets_manager/widgets/iguana_wallets_manager.dart b/lib/views/wallets_manager/widgets/iguana_wallets_manager.dart index 8affdc2c56..0f936f3bab 100644 --- a/lib/views/wallets_manager/widgets/iguana_wallets_manager.dart +++ b/lib/views/wallets_manager/widgets/iguana_wallets_manager.dart @@ -1,24 +1,31 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.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/analytics/events/user_acquisition_events.dart'; import 'package:web_dex/bloc/analytics/analytics_bloc.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/blocs/wallets_repository.dart'; import 'package:web_dex/common/screen.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/authorize_mode.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/model/wallets_manager_models.dart'; +import 'package:web_dex/services/storage/get_storage.dart'; +import 'package:web_dex/shared/constants.dart'; import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; import 'package:web_dex/views/wallets_manager/widgets/wallet_creation.dart'; import 'package:web_dex/views/wallets_manager/widgets/wallet_deleting.dart'; import 'package:web_dex/views/wallets_manager/widgets/wallet_import_wrapper.dart'; import 'package:web_dex/views/wallets_manager/widgets/wallet_login.dart'; +import 'package:web_dex/views/wallets_manager/widgets/wallet_rename_dialog.dart'; import 'package:web_dex/views/wallets_manager/widgets/wallets_list.dart'; import 'package:web_dex/views/wallets_manager/widgets/wallets_manager_controls.dart'; -import 'package:web_dex/blocs/wallets_repository.dart'; class IguanaWalletsManager extends StatefulWidget { const IguanaWalletsManager({ @@ -27,6 +34,7 @@ class IguanaWalletsManager extends StatefulWidget { required this.onSuccess, this.initialWallet, this.initialHdMode = false, + this.rememberMe = false, super.key, }); @@ -35,6 +43,7 @@ class IguanaWalletsManager extends StatefulWidget { final void Function(Wallet) onSuccess; final Wallet? initialWallet; final bool initialHdMode; + final bool rememberMe; @override State createState() => _IguanaWalletsManagerState(); @@ -47,6 +56,7 @@ class _IguanaWalletsManagerState extends State { WalletsManagerExistWalletAction _existWalletAction = WalletsManagerExistWalletAction.none; bool _initialHdMode = false; + bool _rememberMe = false; @override void initState() { @@ -55,6 +65,7 @@ class _IguanaWalletsManagerState extends State { _initialHdMode = widget.initialWallet?.config.type == WalletType.hdwallet ? true : widget.initialHdMode; + _rememberMe = widget.rememberMe; if (_selectedWallet != null) { _existWalletAction = WalletsManagerExistWalletAction.logIn; } @@ -101,15 +112,16 @@ class _IguanaWalletsManagerState extends State { children: [ WalletsList( walletType: WalletType.iguana, - onWalletClick: ( - Wallet wallet, - WalletsManagerExistWalletAction existWalletAction, - ) { - setState(() { - _selectedWallet = wallet; - _existWalletAction = existWalletAction; - }); - }, + onWalletClick: + ( + Wallet wallet, + WalletsManagerExistWalletAction existWalletAction, + ) { + setState(() { + _selectedWallet = wallet; + _existWalletAction = existWalletAction; + }); + }, ), if (context.read().wallets?.isNotEmpty ?? false) @@ -124,11 +136,11 @@ class _IguanaWalletsManagerState extends State { ? 'create' : 'import'; context.read().logEvent( - OnboardingStartedEventData( - method: method, - referralSource: widget.eventType.name, - ), - ); + OnboardingStartedEventData( + method: method, + referralSource: widget.eventType.name, + ), + ); }, ), Padding( @@ -165,6 +177,7 @@ class _IguanaWalletsManagerState extends State { onLogin: _logInToWallet, onCancel: _cancel, initialHdMode: _initialHdMode, + initialQuickLogin: _rememberMe, ); } } @@ -238,45 +251,137 @@ class _IguanaWalletsManagerState extends State { required String name, required String password, WalletType? walletType, - }) { - setState(() => _isLoading = true); + required bool rememberMe, + }) async { + setState(() { + _isLoading = true; + _rememberMe = rememberMe; + }); + + // Async uniqueness check prior to dispatch + final repo = context.read(); + final uniquenessError = await repo.validateWalletNameUniqueness(name); + if (uniquenessError != null) { + if (mounted) setState(() => _isLoading = false); + final theme = Theme.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + uniquenessError, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + return; + } final Wallet newWallet = Wallet.fromName( name: name, walletType: walletType ?? WalletType.iguana, ); context.read().add( - AuthRegisterRequested(wallet: newWallet, password: password), - ); + AuthRegisterRequested(wallet: newWallet, password: password), + ); } void _importWallet({ required String name, required String password, required WalletConfig walletConfig, - }) { + required bool rememberMe, + }) async { setState(() { _isLoading = true; + _rememberMe = rememberMe; }); + + final authBloc = context.read(); + + // Async uniqueness check prior to dispatch + final repo = context.read(); + final uniquenessError = await repo.validateWalletNameUniqueness(name); + if (uniquenessError != null) { + if (mounted) setState(() => _isLoading = false); + final theme = Theme.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + uniquenessError, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + return; + } final Wallet newWallet = Wallet.fromConfig( name: name, config: walletConfig, ); - context.read().add( - AuthRestoreRequested( - wallet: newWallet, - password: password, - seed: walletConfig.seedPhrase, - ), - ); + authBloc.add( + AuthRestoreRequested( + wallet: newWallet, + password: password, + seed: walletConfig.seedPhrase, + ), + ); } - Future _logInToWallet(String password, Wallet wallet) async { + Future _logInToWallet( + String password, + Wallet wallet, + bool rememberMe, + ) async { + // Use a local variable to avoid mutating the original wallet reference + Wallet walletToUse = wallet.copy(); setState(() { _isLoading = true; + _rememberMe = rememberMe; }); + final walletsRepository = RepositoryProvider.of(context); + if (wallet.isLegacyWallet) { + final String? error = walletsRepository.validateWalletName(wallet.name); + if (error != null) { + final newName = await walletRenameDialog( + context, + initialName: wallet.name, + ); + if (newName == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + // Re-validate after dialog to prevent TOCTOU conflicts + final postError = walletsRepository.validateWalletName(newName); + if (postError != null) { + if (mounted) setState(() => _isLoading = false); + return; + } + // Persist legacy rename and update local instance + await walletsRepository.renameLegacyWallet( + walletId: wallet.id, + newName: newName, + ); + final String trimmed = newName.trim(); + final Wallet updatedWallet = wallet.copyWith(name: trimmed); + // Update selected wallet for UI consistency without mutating the original instance + if (mounted) { + setState(() { + _selectedWallet = updatedWallet; + }); + } + walletToUse = updatedWallet; + } + } + + if (!mounted) return; + final AnalyticsBloc analyticsBloc = context.read(); final analyticsEvent = walletsManagerEventsFactory.createEvent( widget.eventType, @@ -285,8 +390,8 @@ class _IguanaWalletsManagerState extends State { analyticsBloc.logEvent(analyticsEvent); context.read().add( - AuthSignInRequested(wallet: wallet, password: password), - ); + AuthSignInRequested(wallet: walletToUse, password: password), + ); if (mounted) { setState(() { @@ -306,18 +411,24 @@ class _IguanaWalletsManagerState extends State { final walletType = currentWallet.config.type.name; if (action == WalletsManagerAction.create) { analyticsBloc.add( - AnalyticsWalletCreatedEvent(source: source, walletType: walletType), + AnalyticsWalletCreatedEvent(source: source, hdType: walletType), ); } else if (action == WalletsManagerAction.import) { analyticsBloc.add( AnalyticsWalletImportedEvent( source: source, importType: 'seed_phrase', - walletType: walletType, + hdType: walletType, ), ); } context.read().add(CoinsSessionStarted(currentUser)); + // Update remembered wallet before closing the dialog to avoid using + // the context after the widget is disposed. + unawaited(_updateRememberedWallet(currentUser)); + // Complete autofill session only after a successful login so that + // password managers can save validated credentials. + TextInput.finishAutofillContext(shouldSave: true); widget.onSuccess(currentWallet); } @@ -327,4 +438,14 @@ class _IguanaWalletsManagerState extends State { }); } } + + Future _updateRememberedWallet(KdfUser currentUser) async { + final storage = getStorage(); + if (_rememberMe) { + // Store the full WalletId JSON instead of just the name + await storage.write(lastLoggedInWalletKey, currentUser.walletId.toJson()); + } else { + await storage.delete(lastLoggedInWalletKey); + } + } } diff --git a/lib/views/wallets_manager/widgets/wallet_creation.dart b/lib/views/wallets_manager/widgets/wallet_creation.dart index 20713dbfde..3d732d1bea 100644 --- a/lib/views/wallets_manager/widgets/wallet_creation.dart +++ b/lib/views/wallets_manager/widgets/wallet_creation.dart @@ -10,8 +10,10 @@ import 'package:web_dex/model/wallets_manager_models.dart'; 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 { const WalletCreation({ @@ -26,7 +28,9 @@ class WalletCreation extends StatefulWidget { required String name, required String password, WalletType? walletType, - }) onCreate; + required bool rememberMe, + }) + onCreate; final void Function() onCancel; @override @@ -42,6 +46,8 @@ class _WalletCreationState extends State { bool _eulaAndTosChecked = false; bool _inProgress = false; bool _isHdMode = true; + bool _rememberMe = false; + bool _arePasswordsValid = false; late final WalletsRepository _walletsRepository; @@ -87,50 +93,52 @@ class _WalletCreationState extends State { } }, child: AutofillGroup( - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.action == WalletsManagerAction.create - ? LocaleKeys.walletCreationTitle.tr() - : LocaleKeys.walletImportTitle.tr(), - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontSize: 18), - ), - const SizedBox(height: 24), - _buildFields(), - const SizedBox(height: 22), - EulaTosCheckboxes( - key: const Key('create-wallet-eula-checks'), - isChecked: _eulaAndTosChecked, - onCheck: (isChecked) { - setState(() { - _eulaAndTosChecked = isChecked; - }); - }, - ), - const SizedBox(height: 32), - UiPrimaryButton( - key: const Key('confirm-password-button'), - height: 50, - text: _inProgress - ? '${LocaleKeys.pleaseWait.tr()}...' - : LocaleKeys.create.tr(), - textStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, + child: ScreenshotSensitive( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.action == WalletsManagerAction.create + ? LocaleKeys.walletCreationTitle.tr() + : LocaleKeys.walletImportTitle.tr(), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontSize: 18), ), - onPressed: _isCreateButtonEnabled ? _onCreate : null, - ), - const SizedBox(height: 20), - UiUnderlineTextButton( - onPressed: widget.onCancel, - text: LocaleKeys.cancel.tr(), - ), - ], + const SizedBox(height: 24), + _buildFields(), + const SizedBox(height: 22), + EulaTosCheckboxes( + key: const Key('create-wallet-eula-checks'), + isChecked: _eulaAndTosChecked, + onCheck: (isChecked) { + setState(() { + _eulaAndTosChecked = isChecked; + }); + }, + ), + const SizedBox(height: 32), + UiPrimaryButton( + key: const Key('confirm-password-button'), + height: 50, + text: _inProgress + ? '${LocaleKeys.pleaseWait.tr()}...' + : LocaleKeys.create.tr(), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + onPressed: _isCreateButtonEnabled ? _onCreate : null, + ), + const SizedBox(height: 20), + UiUnderlineTextButton( + onPressed: widget.onCancel, + text: LocaleKeys.cancel.tr(), + ), + ], + ), ), ), ), @@ -154,6 +162,9 @@ class _WalletCreationState extends State { const SizedBox(height: 20), CreationPasswordFields( passwordController: _passwordController, + onValidityChanged: (isValid) { + if (mounted) setState(() => _arePasswordsValid = isValid); + }, onFieldSubmitted: (_) { if (_isCreateButtonEnabled) _onCreate(); }, @@ -165,6 +176,14 @@ class _WalletCreationState extends State { setState(() => _isHdMode = value); }, ), + const SizedBox(height: 20), + QuickLoginSwitch( + key: const Key('checkbox-one-click-login-signup'), + value: _rememberMe, + onChanged: (value) { + setState(() => _rememberMe = value); + }, + ), ], ); } @@ -186,24 +205,52 @@ class _WalletCreationState extends State { ); } - void _onCreate() { + void _onCreate() async { if (!_eulaAndTosChecked) return; if (!(_formKey.currentState?.validate() ?? false)) return; setState(() => _inProgress = true); + // Async uniqueness check before proceeding + final uniquenessError = await _walletsRepository + .validateWalletNameUniqueness(_nameController.text); + if (uniquenessError != null) { + if (mounted) { + setState(() => _inProgress = false); + final theme = Theme.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + uniquenessError, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } + return; + } WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + // Complete autofill session so password managers can save new credentials + TextInput.finishAutofillContext(shouldSave: true); widget.onCreate( name: _nameController.text.trim(), password: _passwordController.text, walletType: _isHdMode ? WalletType.hdwallet : WalletType.iguana, + rememberMe: _rememberMe, ); }); } bool get _isCreateButtonEnabled { - final nameError = - _walletsRepository.validateWalletName(_nameController.text); + final nameError = _walletsRepository.validateWalletName( + _nameController.text, + ); final isNameValid = nameError == null; - return _eulaAndTosChecked && !_inProgress && isNameValid; + return _eulaAndTosChecked && + !_inProgress && + isNameValid && + _arePasswordsValid; } } diff --git a/lib/views/wallets_manager/widgets/wallet_deleting.dart b/lib/views/wallets_manager/widgets/wallet_deleting.dart index fb54d88573..bff0e672aa 100644 --- a/lib/views/wallets_manager/widgets/wallet_deleting.dart +++ b/lib/views/wallets_manager/widgets/wallet_deleting.dart @@ -2,13 +2,14 @@ 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_defi_sdk/komodo_defi_sdk.dart'; +import 'package:web_dex/shared/constants.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/blocs/wallets_repository.dart'; 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/screenshot/screenshot_sensitivity.dart'; class WalletDeleting extends StatefulWidget { const WalletDeleting({ @@ -37,7 +38,7 @@ class _WalletDeletingState extends State { @override Widget build(BuildContext context) { - return Form( + return ScreenshotSensitive(child: Form( key: _formKey, child: Column( children: [ @@ -83,7 +84,7 @@ class _WalletDeletingState extends State { ), ], ), - ); + )); } Widget _buildHeader() { @@ -221,6 +222,8 @@ class _PasswordFieldState extends State<_PasswordField> { errorText: widget.errorText, validator: widget.validator, validationMode: InputValidationMode.eager, + maxLength: passwordMaxLength, + counterText: '', hintText: LocaleKeys.walletCreationPasswordHint.tr(), onChanged: widget.onChanged, suffixIcon: PasswordVisibilityControl( 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 52368ed926..458c733751 100644 --- a/lib/views/wallets_manager/widgets/wallet_import_by_file.dart +++ b/lib/views/wallets_manager/widgets/wallet_import_by_file.dart @@ -1,19 +1,24 @@ import 'dart:convert'; -import 'package:collection/collection.dart'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.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/blocs/wallets_repository.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; import 'package:web_dex/shared/ui/ui_gradient_icon.dart'; import 'package:web_dex/shared/utils/encryption_tool.dart'; 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_rename_dialog.dart'; class WalletFileData { const WalletFileData({required this.content, required this.name}); @@ -34,7 +39,9 @@ class WalletImportByFile extends StatefulWidget { required String name, required String password, required WalletConfig walletConfig, - }) onImport; + required bool rememberMe, + }) + onImport; final void Function() onCancel; @override @@ -42,17 +49,16 @@ class WalletImportByFile extends StatefulWidget { } class _WalletImportByFileState extends State { - final TextEditingController _filePasswordController = - TextEditingController(text: ''); + final TextEditingController _filePasswordController = TextEditingController( + text: '', + ); final GlobalKey _formKey = GlobalKey(); bool _isObscured = true; bool _isHdMode = false; bool _eulaAndTosChecked = false; + bool _rememberMe = false; bool _allowCustomSeed = false; - // Whether the selected file name contains characters that are not allowed - late final bool _hasInvalidFileName; - String? _filePasswordError; String? _commonError; @@ -60,144 +66,135 @@ class _WalletImportByFileState extends State { return _filePasswordError == null; } - bool get _isButtonEnabled => _eulaAndTosChecked && !_hasInvalidFileName; - - @override - void initState() { - super.initState(); - - // Detect illegal characters in the filename (anything other than letters, numbers, underscore, hyphen, dot and space) - _hasInvalidFileName = _containsIllegalChars(widget.fileData.name); - - if (_hasInvalidFileName) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - setState(() { - _commonError = LocaleKeys.invalidWalletFileNameError.tr(); - }); - _formKey.currentState?.validate(); - }); - } - } - - bool _containsIllegalChars(String fileName) { - // Allow alphanumerics, underscore, hyphen, dot and space in the filename - return RegExp(r'[^\w.\- ]').hasMatch(fileName); - } + // Intentionally do not check wallet name here, because it is done on button + // click and a dialog is shown to rename the wallet if there are issues. + bool get _isButtonEnabled => _eulaAndTosChecked; @override Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - LocaleKeys.walletImportByFileTitle.tr(), - style: Theme.of(context).textTheme.titleLarge!.copyWith( - fontSize: 24, - ), - ), - const SizedBox(height: 20), - Text(LocaleKeys.walletImportByFileDescription.tr(), - style: Theme.of(context).textTheme.bodyLarge), - const SizedBox(height: 20), - AutofillGroup( - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - UiTextFormField( - key: const Key('file-password-field'), - controller: _filePasswordController, - autofocus: true, - textInputAction: TextInputAction.next, - autocorrect: false, - enableInteractiveSelection: true, - obscureText: _isObscured, - autofillHints: const [AutofillHints.password], - validator: (_) { - return _filePasswordError; - }, - errorMaxLines: 6, - hintText: LocaleKeys.walletCreationPasswordHint.tr(), - suffixIcon: PasswordVisibilityControl( - onVisibilityChange: (bool isPasswordObscured) { - setState(() { - _isObscured = isPasswordObscured; - }); - }, - ), - ), - const SizedBox(height: 30), - Row(children: [ - const UiGradientIcon( - icon: Icons.folder, - size: 32, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - widget.fileData.name, - maxLines: 3, - overflow: TextOverflow.ellipsis, - )), - ]), - if (_commonError != null) - Align( - alignment: const Alignment(-1, 0), - child: SelectableText( - _commonError ?? '', - style: Theme.of(context) - .textTheme - .bodyLarge! - .copyWith(color: Theme.of(context).colorScheme.error), + return ScreenshotSensitive( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + LocaleKeys.walletImportByFileTitle.tr(), + style: Theme.of( + context, + ).textTheme.titleLarge!.copyWith(fontSize: 24), + ), + const SizedBox(height: 20), + Text( + LocaleKeys.walletImportByFileDescription.tr(), + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 20), + AutofillGroup( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiTextFormField( + key: const Key('file-password-field'), + controller: _filePasswordController, + autofocus: true, + textInputAction: TextInputAction.next, + autocorrect: false, + enableInteractiveSelection: true, + obscureText: _isObscured, + maxLength: passwordMaxLength, + counterText: '', + autofillHints: const [AutofillHints.password], + validator: (_) { + return _filePasswordError; + }, + errorMaxLines: 6, + hintText: LocaleKeys.walletCreationPasswordHint.tr(), + suffixIcon: PasswordVisibilityControl( + onVisibilityChange: (bool isPasswordObscured) { + setState(() { + _isObscured = isPasswordObscured; + }); + }, + ), ), - ), - const SizedBox(height: 30), - HDWalletModeSwitch( - value: _isHdMode, - onChanged: (value) { - setState(() => _isHdMode = value); - }, - ), - const SizedBox(height: 15), - if (!_isHdMode) - CustomSeedCheckbox( - value: _allowCustomSeed, - onChanged: (value) { - setState(() { - _allowCustomSeed = value; - }); - }, - ), - const SizedBox(height: 15), - EulaTosCheckboxes( - key: const Key('import-wallet-eula-checks'), - isChecked: _eulaAndTosChecked, - onCheck: (isChecked) { - setState(() { - _eulaAndTosChecked = isChecked; - }); - }, - ), - const SizedBox(height: 30), - UiPrimaryButton( - key: const Key('confirm-password-button'), - height: 50, - text: LocaleKeys.import.tr(), - onPressed: _isButtonEnabled ? _onImport : null, - ), - const SizedBox(height: 20), - UiUnderlineTextButton( - onPressed: widget.onCancel, - text: LocaleKeys.back.tr(), + const SizedBox(height: 30), + Row( + children: [ + const UiGradientIcon(icon: Icons.folder, size: 32), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.fileData.name, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + if (_commonError != null) + Align( + alignment: const Alignment(-1, 0), + child: SelectableText( + _commonError ?? '', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + const SizedBox(height: 30), + HDWalletModeSwitch( + value: _isHdMode, + onChanged: (value) { + setState(() => _isHdMode = value); + }, + ), + const SizedBox(height: 15), + if (!_isHdMode) + CustomSeedCheckbox( + value: _allowCustomSeed, + onChanged: (value) { + setState(() { + _allowCustomSeed = value; + }); + }, + ), + const SizedBox(height: 15), + EulaTosCheckboxes( + key: const Key('import-wallet-eula-checks'), + isChecked: _eulaAndTosChecked, + onCheck: (isChecked) { + setState(() { + _eulaAndTosChecked = isChecked; + }); + }, + ), + const SizedBox(height: 20), + QuickLoginSwitch( + value: _rememberMe, + onChanged: (value) { + setState(() => _rememberMe = value); + }, + ), + const SizedBox(height: 30), + UiPrimaryButton( + key: const Key('confirm-password-button'), + height: 50, + text: LocaleKeys.import.tr(), + onPressed: _isButtonEnabled ? _onImport : null, + ), + const SizedBox(height: 20), + UiUnderlineTextButton( + onPressed: widget.onCancel, + text: LocaleKeys.back.tr(), + ), + ], ), - ], + ), ), - ), - ), - ], + ], + ), ); } @@ -213,10 +210,6 @@ class _WalletImportByFileState extends State { late final KomodoDefiSdk _sdk = context.read(); Future _onImport() async { - if (_hasInvalidFileName) { - // Early return if filename is invalid; button should already be disabled - return; - } final EncryptionTool encryptionTool = EncryptionTool(); final String? fileData = await encryptionTool.decryptData( _filePasswordController.text, @@ -235,12 +228,15 @@ class _WalletImportByFileState extends State { } _formKey.currentState?.validate(); try { - final WalletConfig walletConfig = - WalletConfig.fromJson(json.decode(fileData)); + final WalletConfig walletConfig = WalletConfig.fromJson( + json.decode(fileData), + ); walletConfig.type = _isHdMode ? WalletType.hdwallet : WalletType.iguana; final String? decryptedSeed = await encryptionTool.decryptData( - _filePasswordController.text, walletConfig.seedPhrase); + _filePasswordController.text, + walletConfig.seedPhrase, + ); if (decryptedSeed == null) return; if (!_isValidData) return; @@ -253,21 +249,43 @@ class _WalletImportByFileState extends State { } walletConfig.seedPhrase = decryptedSeed; - final String name = widget.fileData.name.split('.').first; - // ignore: use_build_context_synchronously - final walletsBloc = RepositoryProvider.of(context); - final bool isNameExisted = - walletsBloc.wallets!.firstWhereOrNull((w) => w.name == name) != null; - if (isNameExisted) { - setState(() { - _commonError = LocaleKeys.walletCreationExistNameError.tr(); - }); - return; + String name = widget.fileData.name.replaceFirst(RegExp(r'\.[^.]+$'), ''); + final walletsRepository = RepositoryProvider.of( + context, + ); + + // Check both validation and uniqueness + String? validationError = walletsRepository.validateWalletName(name); + String? uniquenessError = await walletsRepository + .validateWalletNameUniqueness(name); + + // If either validation or uniqueness fails, prompt for renaming + if (validationError != null || uniquenessError != null) { + if (!mounted) return; + final newName = await walletRenameDialog(context, initialName: name); + if (newName == null) { + return; + } + // Re-validate to protect against TOCTOU (name taken while dialog open) + final postValidation = walletsRepository.validateWalletName(newName); + if (postValidation != null) { + return; + } + // Async uniqueness check before proceeding with renamed value + final postUniquenessError = await walletsRepository + .validateWalletNameUniqueness(newName); + if (postUniquenessError != null) { + return; + } + name = newName.trim(); } + // Close autofill context after successfully validating password & before import + TextInput.finishAutofillContext(shouldSave: false); widget.onImport( name: name, password: _filePasswordController.text, walletConfig: walletConfig, + rememberMe: _rememberMe, ); } catch (_) { setState(() { diff --git a/lib/views/wallets_manager/widgets/wallet_import_wrapper.dart b/lib/views/wallets_manager/widgets/wallet_import_wrapper.dart index 7a4b8864d4..f4e3cf4873 100644 --- a/lib/views/wallets_manager/widgets/wallet_import_wrapper.dart +++ b/lib/views/wallets_manager/widgets/wallet_import_wrapper.dart @@ -14,7 +14,9 @@ class WalletImportWrapper extends StatefulWidget { required String name, required String password, required WalletConfig walletConfig, - }) onImport; + required bool rememberMe, + }) + onImport; final void Function() onCancel; @override @@ -73,7 +75,4 @@ class _WalletImportWrapperState extends State { } } -enum WalletImportTypes { - simple, - file, -} +enum WalletImportTypes { simple, file } diff --git a/lib/views/wallets_manager/widgets/wallet_login.dart b/lib/views/wallets_manager/widgets/wallet_login.dart index e094945e49..21fbc275b3 100644 --- a/lib/views/wallets_manager/widgets/wallet_login.dart +++ b/lib/views/wallets_manager/widgets/wallet_login.dart @@ -13,7 +13,10 @@ import 'package:web_dex/common/screen.dart'; 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/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'; class WalletLogIn extends StatefulWidget { const WalletLogIn({ @@ -21,13 +24,15 @@ class WalletLogIn extends StatefulWidget { required this.onLogin, required this.onCancel, this.initialHdMode = false, + this.initialQuickLogin = false, super.key, }); final Wallet wallet; - final void Function(String, Wallet) onLogin; + final void Function(String, Wallet, bool) onLogin; final void Function() onCancel; final bool initialHdMode; + final bool initialQuickLogin; @override State createState() => _WalletLogInState(); @@ -37,20 +42,23 @@ class _WalletLogInState extends State { final _backKeyButton = GlobalKey(); final TextEditingController _passwordController = TextEditingController(); late bool _isHdMode; + bool _isQuickLoginEnabled = false; KdfUser? _user; @override void initState() { super.initState(); _isHdMode = widget.initialHdMode; + _isQuickLoginEnabled = widget.initialQuickLogin; unawaited(_fetchKdfUser()); } Future _fetchKdfUser() async { final kdfSdk = RepositoryProvider.of(context); final users = await kdfSdk.auth.getUsers(); - final user = users - .firstWhereOrNull((user) => user.walletId.name == widget.wallet.name); + final user = users.firstWhereOrNull( + (user) => user.walletId.name == widget.wallet.name, + ); if (user != null) { setState(() { @@ -73,14 +81,19 @@ class _WalletLogInState extends State { } WidgetsBinding.instance.addPostFrameCallback((_) { - widget.wallet.config.type = + final WalletType derivedType = _isHdMode && _user != null && _user!.isBip39Seed == true - ? WalletType.hdwallet - : WalletType.iguana; + ? WalletType.hdwallet + : WalletType.iguana; + + final Wallet walletToUse = widget.wallet.copyWith( + config: widget.wallet.config.copyWith(type: derivedType), + ); widget.onLogin( _passwordController.text, - widget.wallet, + walletToUse, + _isQuickLoginEnabled, ); }); } @@ -91,67 +104,75 @@ class _WalletLogInState extends State { builder: (context, state) { final errorMessage = state.authError?.type == AuthExceptionType.incorrectPassword - ? LocaleKeys.incorrectPassword.tr() - : state.authError?.message; + ? LocaleKeys.incorrectPassword.tr() + : state.authError?.message; return AutofillGroup( - child: Column( - mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min, - children: [ - Text( - LocaleKeys.walletLogInTitle.tr(), - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(fontSize: 18), - ), - const SizedBox(height: 40), - UiTextFormField( - key: const Key('wallet-field'), - initialValue: widget.wallet.name, - readOnly: true, - autocorrect: false, - autofillHints: const [AutofillHints.username], - ), - const SizedBox( - height: 20, - ), - PasswordTextField( - onFieldSubmitted: state.isLoading ? null : _submitLogin, - controller: _passwordController, - errorText: errorMessage, - autofillHints: const [AutofillHints.password], - ), - const SizedBox(height: 20), - if (_user != null && _user!.isBip39Seed == true) - HDWalletModeSwitch( - value: _isHdMode, + child: ScreenshotSensitive( + child: Column( + mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min, + children: [ + Text( + LocaleKeys.walletLogInTitle.tr(), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontSize: 18), + ), + const SizedBox(height: 24), + UiTextFormField( + key: const Key('wallet-field'), + initialValue: widget.wallet.name, + readOnly: true, + autocorrect: false, + autofillHints: const [AutofillHints.username], + ), + const SizedBox(height: 16), + PasswordTextField( + onFieldSubmitted: state.isLoading ? null : _submitLogin, + controller: _passwordController, + errorText: errorMessage, + autofillHints: const [AutofillHints.password], + isQuickLoginEnabled: _isQuickLoginEnabled, + ), + const SizedBox(height: 32), + QuickLoginSwitch( + value: _isQuickLoginEnabled, onChanged: (value) { - setState(() => _isHdMode = value); + setState(() => _isQuickLoginEnabled = value); }, ), - const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 2.0), - child: UiPrimaryButton( - height: 50, - text: state.isLoading - ? '${LocaleKeys.pleaseWait.tr()}...' - : LocaleKeys.logIn.tr(), - textStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, + const SizedBox(height: 16), + if (_user != null && _user!.isBip39Seed == true) ...[ + HDWalletModeSwitch( + value: _isHdMode, + onChanged: (value) { + setState(() => _isHdMode = value); + }, + ), + const SizedBox(height: 24), + ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2.0), + child: UiPrimaryButton( + height: 50, + text: state.isLoading + ? '${LocaleKeys.pleaseWait.tr()}...' + : LocaleKeys.logIn.tr(), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + onPressed: state.isLoading ? null : _submitLogin, ), - onPressed: state.isLoading ? null : _submitLogin, ), - ), - const SizedBox(height: 20), - UiUnderlineTextButton( - key: _backKeyButton, - onPressed: widget.onCancel, - text: LocaleKeys.cancel.tr(), - ), - ], + const SizedBox(height: 8), + UiUnderlineTextButton( + key: _backKeyButton, + onPressed: widget.onCancel, + text: LocaleKeys.cancel.tr(), + ), + ], + ), ), ); }, @@ -166,12 +187,14 @@ class PasswordTextField extends StatefulWidget { super.key, this.errorText, this.autofillHints, + this.isQuickLoginEnabled = false, }); final String? errorText; final TextEditingController controller; final void Function()? onFieldSubmitted; final Iterable? autofillHints; + final bool isQuickLoginEnabled; @override State createState() => _PasswordTextFieldState(); @@ -179,6 +202,104 @@ class PasswordTextField extends StatefulWidget { class _PasswordTextFieldState extends State { bool _isPasswordObscured = true; + Timer? _autoSubmitTimer; + String _previousValue = ''; + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + widget.controller.addListener(_onPasswordChanged); + } + + @override + void dispose() { + _autoSubmitTimer?.cancel(); + widget.controller.removeListener(_onPasswordChanged); + _focusNode.dispose(); + super.dispose(); + } + + void _onPasswordChanged() { + if (!widget.isQuickLoginEnabled) return; + + final currentValue = widget.controller.text; + final previousValue = _previousValue; + final lengthDifference = (currentValue.length - previousValue.length).abs(); + + // Detect multi-character input; avoid blindly assuming password manager + if (lengthDifference >= 3 && currentValue.isNotEmpty) { + // Cancel any existing timer + _autoSubmitTimer?.cancel(); + + // Capture values at the time of scheduling to compare later + final scheduledBeforeValue = previousValue; + final scheduledAfterValue = currentValue; + + // Set a short delay to allow for potential additional input + _autoSubmitTimer = Timer(const Duration(milliseconds: 300), () async { + if (!mounted) return; + + // Ensure quick login is still enabled and callback available + if (!widget.isQuickLoginEnabled || widget.onFieldSubmitted == null) { + return; + } + + // If user manually pasted, skip auto-submit. Heuristic: clipboard text + // matches the inserted chunk and field currently has focus. + try { + // Only attempt paste-detection if focused; autofill may occur without explicit paste + if (_focusNode.hasFocus) { + final clipboardData = await Clipboard.getData('text/plain'); + final clipboardText = clipboardData?.text ?? ''; + if (clipboardText.isNotEmpty) { + final insertedText = _deriveInsertedText( + before: scheduledBeforeValue, + after: scheduledAfterValue, + ); + if (insertedText.isNotEmpty && insertedText == clipboardText) { + return; // Looks like a paste; do not auto-submit + } + } + } + } catch (_) { + // Ignore clipboard errors and proceed with normal checks + } + + // Double-check that the field still has the same content we scheduled on + // and still has content + final latestText = widget.controller.text; + if (latestText.isNotEmpty && latestText == scheduledAfterValue) { + widget.onFieldSubmitted!.call(); + } + }); + } + + _previousValue = currentValue; + } + + // Compute the inserted substring between before and after values + String _deriveInsertedText({required String before, required String after}) { + // If text replaced entirely + if (before.isEmpty) return after; + + // Find common prefix + int start = 0; + 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]) { + endBefore--; + endAfter--; + } + + return after.substring(start, endAfter + 1); + } @override Widget build(BuildContext context) { @@ -187,17 +308,20 @@ class _PasswordTextFieldState extends State { UiTextFormField( key: const Key('create-password-field'), autofocus: true, - textInputAction: TextInputAction.next, + textInputAction: TextInputAction.done, autocorrect: false, controller: widget.controller, obscureText: _isPasswordObscured, errorText: widget.errorText, autofillHints: widget.autofillHints ?? const [AutofillHints.password], + maxLength: passwordMaxLength, + counterText: '', hintText: LocaleKeys.walletCreationPasswordHint.tr(), suffixIcon: PasswordVisibilityControl( onVisibilityChange: onVisibilityChange, ), onFieldSubmitted: (_) => widget.onFieldSubmitted?.call(), + focusNode: _focusNode, ), ], ); diff --git a/lib/views/wallets_manager/widgets/wallet_rename_dialog.dart b/lib/views/wallets_manager/widgets/wallet_rename_dialog.dart new file mode 100644 index 0000000000..1b326a07f0 --- /dev/null +++ b/lib/views/wallets_manager/widgets/wallet_rename_dialog.dart @@ -0,0 +1,116 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/blocs/wallets_repository.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/widgets/app_dialog.dart'; + +Future walletRenameDialog( + BuildContext context, { + required String initialName, +}) async { + final TextEditingController controller = TextEditingController( + text: initialName, + ); + final walletsRepository = RepositoryProvider.of(context); + + final result = await AppDialog.show( + context: context, + width: isMobile ? null : 360, + child: _WalletRenameContent( + controller: controller, + walletsRepository: walletsRepository, + ), + ); + + return result; +} + +class _WalletRenameContent extends StatefulWidget { + const _WalletRenameContent({ + required this.controller, + required this.walletsRepository, + }); + + final TextEditingController controller; + final WalletsRepository walletsRepository; + + @override + State<_WalletRenameContent> createState() => _WalletRenameContentState(); +} + +class _WalletRenameContentState extends State<_WalletRenameContent> { + String? error; + + @override + void initState() { + super.initState(); + // Validate initial name + error = widget.walletsRepository.validateWalletName(widget.controller.text); + } + + void _handleTextChange(String? text) { + setState(() { + error = widget.walletsRepository.validateWalletName(text ?? ''); + }); + } + + void _handleCancel() { + Navigator.of(context).pop(null); + } + + void _handleConfirm() { + final text = widget.controller.text.trim(); + if (text.isNotEmpty && error == null) { + Navigator.of(context).pop(text); + } + } + + @override + Widget build(BuildContext context) { + return Container( + constraints: isMobile ? null : const BoxConstraints(maxWidth: 360), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + LocaleKeys.renameWalletDescription.tr(), + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 20), + UiTextFormField( + controller: widget.controller, + autofocus: true, + autocorrect: false, + inputFormatters: [LengthLimitingTextInputFormatter(40)], + errorText: error, + onChanged: _handleTextChange, + onFieldSubmitted: (_) => _handleConfirm(), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: UiUnderlineTextButton( + text: LocaleKeys.cancel.tr(), + onPressed: _handleCancel, + ), + ), + const SizedBox(width: 12), + Flexible( + child: UiPrimaryButton( + text: LocaleKeys.renameWalletConfirm.tr(), + onPressed: error != null ? null : _handleConfirm, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/views/wallets_manager/widgets/wallet_simple_import.dart b/lib/views/wallets_manager/widgets/wallet_simple_import.dart index 757ceebdae..42939ed59d 100644 --- a/lib/views/wallets_manager/widgets/wallet_simple_import.dart +++ b/lib/views/wallets_manager/widgets/wallet_simple_import.dart @@ -15,9 +15,11 @@ import 'package:web_dex/services/file_loader/file_loader.dart'; import 'package:web_dex/shared/utils/utils.dart'; 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/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/shared/screenshot/screenshot_sensitivity.dart'; class WalletSimpleImport extends StatefulWidget { const WalletSimpleImport({ @@ -31,12 +33,14 @@ class WalletSimpleImport extends StatefulWidget { required String name, required String password, required WalletConfig walletConfig, - }) onImport; + required bool rememberMe, + }) + onImport; final void Function() onCancel; final void Function({required String fileName, required String fileData}) - onUploadFiles; + onUploadFiles; @override State createState() => _WalletImportWrapperState(); @@ -57,6 +61,7 @@ class _WalletImportWrapperState extends State { bool _inProgress = false; bool _allowCustomSeed = false; bool _isHdMode = false; + bool _rememberMe = false; bool get _isButtonEnabled { final isFormValid = _refreshFormValidationState(); @@ -91,51 +96,53 @@ class _WalletImportWrapperState extends State { } }, child: AutofillGroup( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SelectableText( - _step == WalletSimpleImportSteps.nameAndSeed - ? LocaleKeys.walletImportTitle.tr() - : LocaleKeys.walletImportCreatePasswordTitle.tr( - args: [_nameController.text.trim()], + child: ScreenshotSensitive( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SelectableText( + _step == WalletSimpleImportSteps.nameAndSeed + ? LocaleKeys.walletImportTitle.tr() + : LocaleKeys.walletImportCreatePasswordTitle.tr( + args: [_nameController.text.trim()], + ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontSize: 20), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildFields(), + const SizedBox(height: 20), + UiPrimaryButton( + key: const Key('confirm-seed-button'), + text: _inProgress + ? '${LocaleKeys.pleaseWait.tr()}...' + : LocaleKeys.import.tr(), + height: 50, + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + onPressed: _isButtonEnabled ? _onImport : null, ), - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontSize: 20), - textAlign: TextAlign.center, - ), - const SizedBox(height: 20), - Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildFields(), - const SizedBox(height: 20), - UiPrimaryButton( - key: const Key('confirm-seed-button'), - text: _inProgress - ? '${LocaleKeys.pleaseWait.tr()}...' - : LocaleKeys.import.tr(), - height: 50, - textStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, + const SizedBox(height: 20), + UiUnderlineTextButton( + onPressed: _onCancel, + text: _step == WalletSimpleImportSteps.nameAndSeed + ? LocaleKeys.cancel.tr() + : LocaleKeys.back.tr(), ), - onPressed: _isButtonEnabled ? _onImport : null, - ), - const SizedBox(height: 20), - UiUnderlineTextButton( - onPressed: _onCancel, - text: _step == WalletSimpleImportSteps.nameAndSeed - ? LocaleKeys.cancel.tr() - : LocaleKeys.back.tr(), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), ); @@ -178,15 +185,30 @@ class _WalletImportWrapperState extends State { case WalletSimpleImportSteps.nameAndSeed: return _buildNameAndSeed(); case WalletSimpleImportSteps.password: - return CreationPasswordFields( + return _buildPasswordStep(); + } + } + + Widget _buildPasswordStep() { + return Column( + children: [ + CreationPasswordFields( passwordController: _passwordController, onFieldSubmitted: !_isButtonEnabled ? null : (text) { _onImport(); }, - ); - } + ), + const SizedBox(height: 20), + QuickLoginSwitch( + value: _rememberMe, + onChanged: (value) { + setState(() => _rememberMe = value); + }, + ), + ], + ); } Widget _buildImportFileButton() { @@ -305,7 +327,7 @@ class _WalletImportWrapperState extends State { widget.onCancel(); } - void _onImport() { + void _onImport() async { if (!(_formKey.currentState?.validate() ?? false)) { return; } @@ -326,11 +348,36 @@ class _WalletImportWrapperState extends State { setState(() => _inProgress = true); + // Async uniqueness check before proceeding + final repo = context.read(); + final uniquenessError = await repo.validateWalletNameUniqueness( + _nameController.text, + ); + if (uniquenessError != null) { + if (mounted) { + setState(() => _inProgress = false); + final theme = Theme.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + uniquenessError, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { widget.onImport( name: _nameController.text.trim(), password: _passwordController.text, walletConfig: config, + rememberMe: _rememberMe, ); }); } @@ -340,14 +387,16 @@ class _WalletImportWrapperState extends State { return null; } - final maybeFailedReason = - context.read().mnemonicValidator.validateMnemonic( - seed ?? '', - minWordCount: 12, - maxWordCount: 24, - isHd: _isHdMode, - allowCustomSeed: _allowCustomSeed, - ); + final maybeFailedReason = context + .read() + .mnemonicValidator + .validateMnemonic( + seed ?? '', + minWordCount: 12, + maxWordCount: 24, + isHd: _isHdMode, + allowCustomSeed: _allowCustomSeed, + ); if (maybeFailedReason == null) { return null; @@ -356,18 +405,20 @@ class _WalletImportWrapperState extends State { return switch (maybeFailedReason) { MnemonicFailedReason.empty => LocaleKeys.walletCreationEmptySeedError.tr(), - MnemonicFailedReason.customNotSupportedForHd => _isHdMode - ? LocaleKeys.walletCreationHdBip39SeedError.tr() - : LocaleKeys.walletCreationBip39SeedError.tr(), - MnemonicFailedReason.customNotAllowed => - LocaleKeys.customSeedWarningText.tr(), - MnemonicFailedReason.invalidLength => - // TODO: Add this string has placeholders for min/max counts, which we - // specify as "12" and "24" - // LocaleKeys.seedPhraseCheckingEnterWord.tr(args: ['12', '24']), + MnemonicFailedReason.customNotSupportedForHd => _isHdMode ? LocaleKeys.walletCreationHdBip39SeedError.tr() : LocaleKeys.walletCreationBip39SeedError.tr(), + MnemonicFailedReason.customNotAllowed => + LocaleKeys.customSeedWarningText.tr(), + MnemonicFailedReason.invalidWord => + LocaleKeys.mnemonicInvalidWordError.tr(), + 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']), }; } } diff --git a/lib/views/wallets_manager/widgets/wallets_manager.dart b/lib/views/wallets_manager/widgets/wallets_manager.dart index e483396308..4f2b9d2188 100644 --- a/lib/views/wallets_manager/widgets/wallets_manager.dart +++ b/lib/views/wallets_manager/widgets/wallets_manager.dart @@ -13,6 +13,7 @@ class WalletsManager extends StatelessWidget { required this.onSuccess, this.selectedWallet, this.initialHdMode = false, + this.rememberMe = false, }) : super(key: key); final WalletsManagerEventType eventType; final WalletType walletType; @@ -20,6 +21,7 @@ class WalletsManager extends StatelessWidget { final Function(Wallet) onSuccess; final Wallet? selectedWallet; final bool initialHdMode; + final bool rememberMe; @override Widget build(BuildContext context) { @@ -32,13 +34,11 @@ class WalletsManager extends StatelessWidget { eventType: eventType, initialWallet: selectedWallet, initialHdMode: initialHdMode, + rememberMe: rememberMe, ); case WalletType.trezor: - return HardwareWalletsManager( - close: close, - eventType: eventType, - ); + return HardwareWalletsManager(close: close, eventType: eventType); case WalletType.keplr: case WalletType.metamask: return const SizedBox(); diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 7e8e52fddf..c66919892f 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -112,6 +112,9 @@ set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" COMPONENT Runtime) +install(FILES "KomodoWallet.png" DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) diff --git a/linux/KomodoWallet.desktop b/linux/KomodoWallet.desktop index 7bf01979b6..36b001ceed 100644 --- a/linux/KomodoWallet.desktop +++ b/linux/KomodoWallet.desktop @@ -3,7 +3,7 @@ Version=1.0 Type=Application Name=Komodo Wallet Exec=./KomodoWallet -Icon=./KomodoWallet.svg +Icon=./KomodoWallet.png Categories=Office;Finance; Terminal=false -StartupNotify=true \ No newline at end of file +StartupNotify=true diff --git a/linux/KomodoWallet.png b/linux/KomodoWallet.png new file mode 100644 index 0000000000..23b7d83a5d Binary files /dev/null and b/linux/KomodoWallet.png differ diff --git a/linux/KomodoWallet.svg b/linux/KomodoWallet.svg deleted file mode 100644 index 947ab6afe3..0000000000 --- a/linux/KomodoWallet.svg +++ /dev/null @@ -1 +0,0 @@ -komodo-sign_gradient \ No newline at end of file diff --git a/linux/my_application.cc b/linux/my_application.cc index d7f5d5e7d6..be94af558c 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -4,25 +4,44 @@ #ifdef GDK_WINDOWING_X11 #include #endif +#include #include "flutter/generated_plugin_registrant.h" +#if GLIB_CHECK_VERSION(2,74,0) + #define MY_APP_DEFAULT_FLAGS G_APPLICATION_DEFAULT_FLAGS +#else + #define MY_APP_DEFAULT_FLAGS G_APPLICATION_FLAGS_NONE +#endif + struct _MyApplication { GtkApplication parent_instance; char** dart_entrypoint_arguments; + GtkWindow* main_window; }; G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) +static void on_window_destroy(GtkWidget* widget, gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + self->main_window = nullptr; +} + // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); + + if (self->main_window != nullptr) { + gtk_window_present(self->main_window); + return; + } + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); // Set window icon GError* error = NULL; - gtk_window_set_icon_from_file(window, "KomodoWallet.svg", &error); + gtk_window_set_icon_from_file(window, "KomodoWallet.png", &error); if (error) { g_warning("Failed to set window icon: %s", error->message); g_error_free(error); @@ -68,6 +87,9 @@ static void my_application_activate(GApplication* application) { fl_register_plugins(FL_PLUGIN_REGISTRY(view)); gtk_widget_grab_focus(GTK_WIDGET(view)); + + self->main_window = window; + g_signal_connect(window, "destroy", G_CALLBACK(on_window_destroy), self); } // Implements GApplication::local_command_line. @@ -93,6 +115,12 @@ static gboolean my_application_local_command_line(GApplication* application, gch static void my_application_dispose(GObject* object) { MyApplication* self = MY_APPLICATION(object); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + + if (self->main_window != nullptr) { + g_signal_handlers_disconnect_by_data(self->main_window, self); + self->main_window = nullptr; + } + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); } @@ -102,11 +130,13 @@ static void my_application_class_init(MyApplicationClass* klass) { G_OBJECT_CLASS(klass)->dispose = my_application_dispose; } -static void my_application_init(MyApplication* self) {} +static void my_application_init(MyApplication* self) { + self->main_window = nullptr; +} MyApplication* my_application_new() { return MY_APPLICATION(g_object_new(my_application_get_type(), "application-id", APPLICATION_ID, - "flags", G_APPLICATION_NON_UNIQUE, + "flags", MY_APP_DEFAULT_FLAGS, nullptr)); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 7d3d19726c..acf5262322 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import device_info_plus import file_picker import firebase_analytics import firebase_core @@ -22,8 +23,9 @@ import video_player_avfoundation import window_size func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) - FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin")) + FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) diff --git a/macos/Podfile b/macos/Podfile index fb9b4184a4..c42286ed18 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -16,6 +16,7 @@ project 'Runner', { 'Debug' => :debug, 'Profile' => :release, 'Release' => :release, + 'Release-production'=> :release, } def flutter_root @@ -52,4 +53,17 @@ post_install do |installer| config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = deployment_target end end + + team_id = '8HPBYKKKQP' + installer.pods_project.targets.each do |t| + t.build_configurations.each do |cfg| + next unless cfg.name == 'Release-production' + cfg.build_settings['DEVELOPMENT_TEAM'] = team_id + cfg.build_settings['CODE_SIGN_STYLE'] = 'Manual' + cfg.build_settings['CODE_SIGN_IDENTITY[sdk=macosx*]'] = 'Developer ID Application' + cfg.build_settings['CODE_SIGNING_ALLOWED'] = 'YES' + cfg.build_settings['CODE_SIGNING_REQUIRED'] = 'YES' + cfg.build_settings['OTHER_CODE_SIGN_FLAGS[sdk=macosx*]'] = '--options=runtime --timestamp' + end + end end diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 5e2f2dfe49..0e481369a3 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,48 +1,50 @@ PODS: + - device_info_plus (0.0.1): + - FlutterMacOS - file_picker (0.0.1): - FlutterMacOS - - Firebase/Analytics (11.10.0): + - Firebase/Analytics (11.15.0): - Firebase/Core - - Firebase/Core (11.10.0): + - Firebase/Core (11.15.0): - Firebase/CoreOnly - - FirebaseAnalytics (~> 11.10.0) - - Firebase/CoreOnly (11.10.0): - - FirebaseCore (~> 11.10.0) - - firebase_analytics (11.4.5): - - Firebase/Analytics (= 11.10.0) + - FirebaseAnalytics (~> 11.15.0) + - Firebase/CoreOnly (11.15.0): + - FirebaseCore (~> 11.15.0) + - firebase_analytics (11.6.0): + - Firebase/Analytics (= 11.15.0) - firebase_core - FlutterMacOS - - firebase_core (3.13.0): - - Firebase/CoreOnly (~> 11.10.0) + - firebase_core (3.15.2): + - Firebase/CoreOnly (~> 11.15.0) - FlutterMacOS - - FirebaseAnalytics (11.10.0): - - FirebaseAnalytics/AdIdSupport (= 11.10.0) - - FirebaseCore (~> 11.10.0) + - FirebaseAnalytics (11.15.0): + - FirebaseAnalytics/Default (= 11.15.0) + - FirebaseCore (~> 11.15.0) - FirebaseInstallations (~> 11.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/MethodSwizzler (~> 8.0) - - GoogleUtilities/Network (~> 8.0) - - "GoogleUtilities/NSData+zlib (~> 8.0)" + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - FirebaseAnalytics/AdIdSupport (11.10.0): - - FirebaseCore (~> 11.10.0) + - FirebaseAnalytics/Default (11.15.0): + - FirebaseCore (~> 11.15.0) - FirebaseInstallations (~> 11.0) - - GoogleAppMeasurement (= 11.10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/MethodSwizzler (~> 8.0) - - GoogleUtilities/Network (~> 8.0) - - "GoogleUtilities/NSData+zlib (~> 8.0)" + - GoogleAppMeasurement/Default (= 11.15.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - FirebaseCore (11.10.0): - - FirebaseCoreInternal (~> 11.10.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/Logger (~> 8.0) - - FirebaseCoreInternal (11.10.0): - - "GoogleUtilities/NSData+zlib (~> 8.0)" - - FirebaseInstallations (11.10.0): - - FirebaseCore (~> 11.10.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - FirebaseCore (11.15.0): + - FirebaseCoreInternal (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (11.15.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (11.15.0): + - FirebaseCore (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - flutter_inappwebview_macos (0.0.1): - FlutterMacOS @@ -53,51 +55,53 @@ PODS: - flutter_window_close (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) - - GoogleAppMeasurement (11.10.0): - - GoogleAppMeasurement/AdIdSupport (= 11.10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/MethodSwizzler (~> 8.0) - - GoogleUtilities/Network (~> 8.0) - - "GoogleUtilities/NSData+zlib (~> 8.0)" + - GoogleAppMeasurement/Core (11.15.0): + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/AdIdSupport (11.10.0): - - GoogleAppMeasurement/WithoutAdIdSupport (= 11.10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/MethodSwizzler (~> 8.0) - - GoogleUtilities/Network (~> 8.0) - - "GoogleUtilities/NSData+zlib (~> 8.0)" + - GoogleAppMeasurement/Default (11.15.0): + - GoogleAdsOnDeviceConversion (= 2.1.0) + - GoogleAppMeasurement/Core (= 11.15.0) + - GoogleAppMeasurement/IdentitySupport (= 11.15.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/WithoutAdIdSupport (11.10.0): - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/MethodSwizzler (~> 8.0) - - GoogleUtilities/Network (~> 8.0) - - "GoogleUtilities/NSData+zlib (~> 8.0)" + - GoogleAppMeasurement/IdentitySupport (11.15.0): + - GoogleAppMeasurement/Core (= 11.15.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleUtilities/AppDelegateSwizzler (8.0.2): + - GoogleUtilities/AppDelegateSwizzler (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - GoogleUtilities/Privacy - - GoogleUtilities/Environment (8.0.2): + - GoogleUtilities/Environment (8.1.0): - GoogleUtilities/Privacy - - GoogleUtilities/Logger (8.0.2): + - GoogleUtilities/Logger (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Privacy - - GoogleUtilities/MethodSwizzler (8.0.2): + - GoogleUtilities/MethodSwizzler (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/Network (8.0.2): + - GoogleUtilities/Network (8.1.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Privacy - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (8.0.2)": + - "GoogleUtilities/NSData+zlib (8.1.0)": - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (8.0.2) - - GoogleUtilities/Reachability (8.0.2): + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (8.0.2): + - GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - komodo_defi_framework (0.0.1): @@ -134,6 +138,7 @@ PODS: - FlutterMacOS DEPENDENCIES: + - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - firebase_analytics (from `Flutter/ephemeral/.symlinks/plugins/firebase_analytics/macos`) - firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`) @@ -166,6 +171,8 @@ SPEC REPOS: - PromisesObjC EXTERNAL SOURCES: + device_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_picker: :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos firebase_analytics: @@ -202,21 +209,22 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_size/macos SPEC CHECKSUMS: + device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a - Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2 - firebase_analytics: 5f4b20b5f700bcae2f800c69a63e79d937d0daa9 - firebase_core: efd50ad8177dc489af1b9163a560359cf1b30597 - FirebaseAnalytics: 4e42333f02cf78ed93703a5c36f36dd518aebdef - FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7 - FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679 - FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3 + Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e + firebase_analytics: 3091f96bd17636f6da5092a4701ffacf67c6e455 + firebase_core: 7667f880631ae8ad10e3d6567ab7582fe0682326 + FirebaseAnalytics: 6433dfd311ba78084fc93bdfc145e8cb75740eae + FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e + FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4 + FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843 flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d flutter_secure_storage_darwin: ce237a8775b39723566dc72571190a3769d70468 flutter_window_close: bd408414cbbf0d39f0d3076c4da0cdbf1c527168 - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - GoogleAppMeasurement: 36684bfb3ee034e2b42b4321eb19da3a1b81e65d - GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d - komodo_defi_framework: 725599127b357521f4567b16192bf07d7ad1d4b0 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + GoogleAppMeasurement: 700dce7541804bec33db590a5c496b663fbe2539 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + komodo_defi_framework: 2e2b89505f158840822ed30ffc7589ff8035e248 local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19 mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 @@ -230,6 +238,6 @@ SPEC CHECKSUMS: video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b window_size: 4bd15034e6e3d0720fd77928a7c42e5492cfece9 -PODFILE CHECKSUM: d064900e78ded0efef7fcc0db57cbf4bc2487624 +PODFILE CHECKSUM: a890bc27443c296eb8ca4510f54c35d2e0f66ed0 COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 5d75a1e9ce..35beafb641 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ /* Begin PBXBuildFile section */ 0FECB4522D11D6AF00F11CB7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 1E230071C7D8F340D13AB3D8 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 378AA469DFC01E26ECFD0219 /* Pods_Runner.framework */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; @@ -33,7 +34,6 @@ D6F2739D2710691C005CC4F3 /* libc++.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D6F2739C2710690C005CC4F3 /* libc++.tbd */; platformFilter = maccatalyst; }; D6F2739F27106934005CC4F3 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D6F2739E2710692B005CC4F3 /* libresolv.tbd */; platformFilter = maccatalyst; }; D6F273A12710694D005CC4F3 /* libSystem.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D6F273A027106944005CC4F3 /* libSystem.tbd */; platformFilter = maccatalyst; }; - F0C41ACB9674358D4A6C7838 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CBED5C6C4A1CA4CE9B9F2193 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -74,19 +74,22 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 366D2EBDC3400ABABE59124C /* 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 = ""; }; + 378AA469DFC01E26ECFD0219 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 38B3D86EA829F8A1CBE530C1 /* 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 = ""; }; + 42C68977A49A0D4061E4C701 /* Pods-Runner.debug-production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug-production.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug-production.xcconfig"; sourceTree = ""; }; + 4D54E611D842457DB7F47DA6 /* Pods-Runner.release-production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release-production.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release-production.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - AC3362C2E5FD3245C1DF46DE /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 9AEFD27D4CEB10A80A08C41F /* Pods-Runner.profile-production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile-production.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile-production.xcconfig"; sourceTree = ""; }; BC509CF635E02824A671F07F /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; - CBED5C6C4A1CA4CE9B9F2193 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C8AFAF26E542485ADE0EC368 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; D60A10D42711A1B300EB58E3 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; D60A10D62711A1D000EB58E3 /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; D6B034EF2711A360007FC221 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; D6F2739C2710690C005CC4F3 /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; }; D6F2739E2710692B005CC4F3 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; }; D6F273A027106944005CC4F3 /* libSystem.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libSystem.tbd; path = usr/lib/libSystem.tbd; sourceTree = SDKROOT; }; - EB312012C5DDB0C61F2B00DD /* 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 = ""; }; - F3467061AB9E6B9F454F9E55 /* 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -98,9 +101,9 @@ D6F2739D2710691C005CC4F3 /* libc++.tbd in Frameworks */, D60A10D72711A1D000EB58E3 /* SystemConfiguration.framework in Frameworks */, D60A10D52711A1B300EB58E3 /* CoreFoundation.framework in Frameworks */, - F0C41ACB9674358D4A6C7838 /* Pods_Runner.framework in Frameworks */, D6F273A12710694D005CC4F3 /* libSystem.tbd in Frameworks */, D6F2739F27106934005CC4F3 /* libresolv.tbd in Frameworks */, + 1E230071C7D8F340D13AB3D8 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -176,9 +179,12 @@ 3931C7C5835EE32D50936E8A /* Pods */ = { isa = PBXGroup; children = ( - AC3362C2E5FD3245C1DF46DE /* Pods-Runner.debug.xcconfig */, - EB312012C5DDB0C61F2B00DD /* Pods-Runner.release.xcconfig */, - F3467061AB9E6B9F454F9E55 /* Pods-Runner.profile.xcconfig */, + C8AFAF26E542485ADE0EC368 /* Pods-Runner.debug.xcconfig */, + 42C68977A49A0D4061E4C701 /* Pods-Runner.debug-production.xcconfig */, + 366D2EBDC3400ABABE59124C /* Pods-Runner.release.xcconfig */, + 4D54E611D842457DB7F47DA6 /* Pods-Runner.release-production.xcconfig */, + 38B3D86EA829F8A1CBE530C1 /* Pods-Runner.profile.xcconfig */, + 9AEFD27D4CEB10A80A08C41F /* Pods-Runner.profile-production.xcconfig */, ); path = Pods; sourceTree = ""; @@ -192,7 +198,7 @@ D6F273A027106944005CC4F3 /* libSystem.tbd */, D6F2739E2710692B005CC4F3 /* libresolv.tbd */, D6F2739C2710690C005CC4F3 /* libc++.tbd */, - CBED5C6C4A1CA4CE9B9F2193 /* Pods_Runner.framework */, + 378AA469DFC01E26ECFD0219 /* Pods_Runner.framework */, ); name = Frameworks; sourceTree = ""; @@ -204,13 +210,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - A4BFE8B57517ABC4F933089B /* [CP] Check Pods Manifest.lock */, + 3069B72540C3B7C1D094DAEB /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - CC7BBD7412CA68EC0C78C62C /* [CP] Embed Pods Frameworks */, + 4F2BCCFAF7F3B06A5F612111 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -235,7 +241,6 @@ 33CC10EC2044A3C60003C045 = { CreatedOnToolsVersion = 9.2; LastSwiftMigration = 1300; - ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.Sandbox = { enabled = 1; @@ -281,67 +286,67 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 3399D490228B24CF009A79C7 /* ShellScript */ = { + 3069B72540C3B7C1D094DAEB /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( ); outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; }; - 33CC111E2044C6BF0003C045 /* ShellScript */ = { + 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - Flutter/ephemeral/FlutterInputs.xcfilelist, ); inputPaths = ( - Flutter/ephemeral/tripwire, ); outputFileListPaths = ( - Flutter/ephemeral/FlutterOutputs.xcfilelist, ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire\n"; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; }; - A4BFE8B57517ABC4F933089B /* [CP] Check Pods Manifest.lock */ = { + 33CC111E2044C6BF0003C045 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, ); inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", + Flutter/ephemeral/tripwire, ); - name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire\n"; }; - CC7BBD7412CA68EC0C78C62C /* [CP] Embed Pods Frameworks */ = { + 4F2BCCFAF7F3B06A5F612111 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -354,6 +359,7 @@ "${BUILT_PRODUCTS_DIR}/GoogleUtilities/GoogleUtilities.framework", "${BUILT_PRODUCTS_DIR}/OrderedSet/OrderedSet.framework", "${BUILT_PRODUCTS_DIR}/PromisesObjC/FBLPromises.framework", + "${BUILT_PRODUCTS_DIR}/device_info_plus/device_info_plus.framework", "${BUILT_PRODUCTS_DIR}/file_picker/file_picker.framework", "${BUILT_PRODUCTS_DIR}/flutter_inappwebview_macos/flutter_inappwebview_macos.framework", "${BUILT_PRODUCTS_DIR}/flutter_secure_storage_darwin/flutter_secure_storage_darwin.framework", @@ -378,6 +384,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleUtilities.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OrderedSet.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBLPromises.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/device_info_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_picker.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_inappwebview_macos.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_secure_storage_darwin.framework", @@ -492,7 +499,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = WDS9WYN969; + DEVELOPMENT_TEAM = 8HPBYKKKQP; EXCLUDED_ARCHS = ""; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -508,7 +515,7 @@ "$(PROJECT_DIR)", ); MACOSX_DEPLOYMENT_TARGET = 13.5; - PRODUCT_BUNDLE_IDENTIFIER = com.komodo.komodowallet; + PRODUCT_BUNDLE_IDENTIFIER = com.komodo.wallet; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -632,7 +639,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = WDS9WYN969; + DEVELOPMENT_TEAM = 8HPBYKKKQP; EXCLUDED_ARCHS = ""; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -648,7 +655,7 @@ "$(PROJECT_DIR)", ); MACOSX_DEPLOYMENT_TARGET = 13.5; - PRODUCT_BUNDLE_IDENTIFIER = com.komodo.komodowallet; + PRODUCT_BUNDLE_IDENTIFIER = com.komodo.wallet; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -667,7 +674,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = WDS9WYN969; + DEVELOPMENT_TEAM = 8HPBYKKKQP; EXCLUDED_ARCHS = ""; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -683,7 +690,7 @@ "$(PROJECT_DIR)", ); MACOSX_DEPLOYMENT_TARGET = 13.5; - PRODUCT_BUNDLE_IDENTIFIER = com.komodo.komodowallet; + PRODUCT_BUNDLE_IDENTIFIER = com.komodo.wallet; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -705,6 +712,280 @@ }; name = Release; }; + ECCB76212E9EF2E90062B2E2 /* Debug-production */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.5; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = "Debug-production"; + }; + ECCB76222E9EF2E90062B2E2 /* Debug-production */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_IDENTITY = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 8HPBYKKKQP; + EXCLUDED_ARCHS = ""; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter/ephemeral", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)", + ); + MACOSX_DEPLOYMENT_TARGET = 13.5; + PRODUCT_BUNDLE_IDENTIFIER = com.komodo.wallet; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = "Debug-production"; + }; + ECCB76232E9EF2E90062B2E2 /* Debug-production */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = "Debug-production"; + }; + ECCB76242E9EF2FF0062B2E2 /* Release-production */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.5; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = "Release-production"; + }; + ECCB76252E9EF2FF0062B2E2 /* Release-production */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_IDENTITY = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 8HPBYKKKQP; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 8HPBYKKKQP; + EXCLUDED_ARCHS = ""; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter/ephemeral", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)", + ); + MACOSX_DEPLOYMENT_TARGET = 13.5; + "OTHER_CODE_SIGN_FLAGS[sdk=*]" = "--options=runtime --timestamp"; + PRODUCT_BUNDLE_IDENTIFIER = com.komodo.wallet; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "[decker] Komodo(8HPBYKKKQP.com.komodo.wallet)"; + SWIFT_VERSION = 5.0; + }; + name = "Release-production"; + }; + ECCB76262E9EF2FF0062B2E2 /* Release-production */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = "Release-production"; + }; + ECCB76272E9EF3100062B2E2 /* Profile-production */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.5; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = "Profile-production"; + }; + ECCB76282E9EF3100062B2E2 /* Profile-production */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 8HPBYKKKQP; + EXCLUDED_ARCHS = ""; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter/ephemeral", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)", + ); + MACOSX_DEPLOYMENT_TARGET = 13.5; + PRODUCT_BUNDLE_IDENTIFIER = com.komodo.wallet; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = "Profile-production"; + }; + ECCB76292E9EF3100062B2E2 /* Profile-production */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = "Profile-production"; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -712,8 +993,11 @@ isa = XCConfigurationList; buildConfigurations = ( 33CC10F92044A3C60003C045 /* Debug */, + ECCB76212E9EF2E90062B2E2 /* Debug-production */, 33CC10FA2044A3C60003C045 /* Release */, + ECCB76242E9EF2FF0062B2E2 /* Release-production */, 338D0CE9231458BD00FA5F75 /* Profile */, + ECCB76272E9EF3100062B2E2 /* Profile-production */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -722,8 +1006,11 @@ isa = XCConfigurationList; buildConfigurations = ( 33CC10FC2044A3C60003C045 /* Debug */, + ECCB76222E9EF2E90062B2E2 /* Debug-production */, 33CC10FD2044A3C60003C045 /* Release */, + ECCB76252E9EF2FF0062B2E2 /* Release-production */, 338D0CEA231458BD00FA5F75 /* Profile */, + ECCB76282E9EF3100062B2E2 /* Profile-production */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -732,8 +1019,11 @@ isa = XCConfigurationList; buildConfigurations = ( 33CC111C2044C6BA0003C045 /* Debug */, + ECCB76232E9EF2E90062B2E2 /* Debug-production */, 33CC111D2044C6BA0003C045 /* Release */, + ECCB76262E9EF2FF0062B2E2 /* Release-production */, 338D0CEB231458BD00FA5F75 /* Profile */, + ECCB76292E9EF3100062B2E2 /* Profile-production */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/production.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/production.xcscheme new file mode 100644 index 0000000000..c01bf6fd35 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/production.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index b3c1761412..9364363caf 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -10,4 +10,15 @@ class AppDelegate: FlutterAppDelegate { override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } + + override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if !flag { + for window in sender.windows { + window.makeKeyAndOrderFront(self) + } + } else { + sender.activate(ignoringOtherApps: true) + } + return true + } } diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 7fec80eb9c..af9777819d 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index 193092d58b..ac3c91ada6 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 34cc1220ad..c20605bcda 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index d6c9de43a1..e261c21277 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index dade7c16d5..2d111e212f 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index a6614de14c..d42984244f 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 7622d56e96..928b301e85 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index 0a9f2dd7b7..cbd566d2de 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -13,7 +13,7 @@ PRODUCT_NAME = Komodo Wallet // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.komodo.komodowallet +PRODUCT_BUNDLE_IDENTIFIER = com.komodo.wallet // The copyright displayed in application information PRODUCT_COPYRIGHT = Copyright © 2020 com.komodo. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig index d39a920e57..6cf5b29ee6 100644 --- a/macos/Runner/Configs/Debug.xcconfig +++ b/macos/Runner/Configs/Debug.xcconfig @@ -1,3 +1,3 @@ -#include "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "../../Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "../../Flutter/Flutter-Debug.xcconfig" #include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig index 41fa75b0f2..1178ce59d8 100644 --- a/macos/Runner/Configs/Release.xcconfig +++ b/macos/Runner/Configs/Release.xcconfig @@ -1,3 +1,3 @@ -#include "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "../../Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "../../Flutter/Flutter-Release.xcconfig" #include "Warnings.xcconfig" diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index d2f442ec99..df98bf8993 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -12,6 +12,8 @@ com.apple.security.network.server + com.apple.security.files.user-selected.read-write + keychain-access-groups diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 225aa48bc8..4541d4b114 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -6,6 +6,8 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-write + keychain-access-groups diff --git a/packages/komodo_persistence_layer/pubspec.yaml b/packages/komodo_persistence_layer/pubspec.yaml index 8ce33eb5b1..d7fa3c348d 100644 --- a/packages/komodo_persistence_layer/pubspec.yaml +++ b/packages/komodo_persistence_layer/pubspec.yaml @@ -3,8 +3,10 @@ description: Persistence layer abstractions for Flutter/Dart. version: 0.0.1 publish_to: none +resolution: workspace + environment: - sdk: ">=3.6.0 <4.0.0" + sdk: ">=3.8.1 <4.0.0" # Add regular dependencies here. dependencies: 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 32dadd3593..ea733565e5 100644 --- a/packages/komodo_ui_kit/lib/src/buttons/ui_dropdown.dart +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_dropdown.dart @@ -22,7 +22,7 @@ class UiDropdown extends StatefulWidget { } class _UiDropdownState extends State with WidgetsBindingObserver { - late OverlayEntry _tooltipWrapper; + OverlayEntry? _tooltipWrapper; GlobalKey _switcherKey = GlobalKey(); Size? _switcherSize; Offset? _switcherOffset; @@ -43,10 +43,10 @@ class _UiDropdownState extends State with WidgetsBindingObserver { _switcherOffset = renderObject.localToGlobal(Offset.zero); } _tooltipWrapper = _buildTooltipWrapper(); + + if (widget.isOpen) _open(); }); - if (widget.isOpen) _open(); - super.initState(); } @@ -54,7 +54,7 @@ class _UiDropdownState extends State with WidgetsBindingObserver { void didUpdateWidget(covariant UiDropdown oldWidget) { if (widget.isOpen == oldWidget.isOpen) return; - if (widget.isOpen != _tooltipWrapper.mounted) _switch(); + if (widget.isOpen != (_tooltipWrapper?.mounted ?? false)) _switch(); super.didUpdateWidget(oldWidget); } @@ -68,14 +68,17 @@ class _UiDropdownState extends State with WidgetsBindingObserver { _switcherOffset = renderObject.localToGlobal(Offset.zero); }); } + if (_tooltipWrapper?.mounted == true) { + _close(); + } _tooltipWrapper = _buildTooltipWrapper(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); - if (_tooltipWrapper.mounted) { - _tooltipWrapper.remove(); + if (_tooltipWrapper?.mounted == true) { + _tooltipWrapper!.remove(); } super.dispose(); } @@ -126,7 +129,7 @@ class _UiDropdownState extends State with WidgetsBindingObserver { } void _switch() { - if (_tooltipWrapper.mounted) { + if (_tooltipWrapper?.mounted == true) { _close(); } else { _open(); @@ -134,15 +137,19 @@ class _UiDropdownState extends State with WidgetsBindingObserver { } void _open() { - Overlay.of(context).insert(_tooltipWrapper); - final onSwitch = widget.onSwitch; - if (onSwitch != null) onSwitch(true); + if (_tooltipWrapper != null) { + Overlay.of(context).insert(_tooltipWrapper!); + final onSwitch = widget.onSwitch; + if (onSwitch != null) onSwitch(true); + } } void _close() { - _tooltipWrapper.remove(); - final onSwitch = widget.onSwitch; - if (onSwitch != null) onSwitch(false); + if (_tooltipWrapper != null) { + _tooltipWrapper!.remove(); + final onSwitch = widget.onSwitch; + if (onSwitch != null) onSwitch(false); + } } double? get _top { 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 2c147c9d22..0557c13ba8 100644 --- a/packages/komodo_ui_kit/lib/src/display/statistic_card.dart +++ b/packages/komodo_ui_kit/lib/src/display/statistic_card.dart @@ -7,8 +7,8 @@ class StatisticCard extends StatelessWidget { // Text shown under the stat value title. Uses default of bodySmall style. final Widget caption; - // The value of the stat used for the title - final double value; + // The value of the stat used for the title. If null, shows a skeleton placeholder + final double? value; // The formatter used to format the value for the title final NumberFormat _valueFormatter; @@ -25,7 +25,7 @@ class StatisticCard extends StatelessWidget { StatisticCard({ super.key, - required this.value, + this.value, required this.caption, this.trendWidget, this.actionWidget, @@ -43,10 +43,7 @@ class StatisticCard extends StatelessWidget { end: Alignment.bottomLeft, colors: (theme.brightness == Brightness.light) ? [cardColor, cardColor] - : [ - Color.fromRGBO(23, 24, 28, 1), - theme.cardColor, - ], + : [Color.fromRGBO(23, 24, 28, 1), theme.cardColor], stops: const [0.0, 1.0], ); } @@ -59,10 +56,7 @@ class StatisticCard extends StatelessWidget { decoration: BoxDecoration( gradient: containerGradient(Theme.of(context)), borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Theme.of(context).dividerColor, - width: 1, - ), + border: Border.all(color: Theme.of(context).dividerColor, width: 1), ), child: InkWell( onTap: onTap, @@ -79,14 +73,17 @@ class StatisticCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Value - AutoScrollText( - text: _valueFormatter.format(value), - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : null, - ), - ), + // Value or skeleton placeholder + value != null + ? AutoScrollText( + text: _valueFormatter.format(value!), + style: Theme.of(context).textTheme.titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.white : null, + ), + ) + : _ValuePlaceholder(), const SizedBox(height: 4), // Caption DefaultTextStyle( @@ -112,3 +109,22 @@ class StatisticCard extends StatelessWidget { ); } } + +class _ValuePlaceholder extends StatelessWidget { + const _ValuePlaceholder(); + + @override + Widget build(BuildContext context) { + return Container( + height: 24, + width: 120, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(4), + ), + child: const SizedBox.shrink(), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/inputs/percentage_range_slider.dart b/packages/komodo_ui_kit/lib/src/inputs/percentage_range_slider.dart index b7214f44d1..e86e079c6e 100644 --- a/packages/komodo_ui_kit/lib/src/inputs/percentage_range_slider.dart +++ b/packages/komodo_ui_kit/lib/src/inputs/percentage_range_slider.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:komodo_ui/komodo_ui.dart' show Debouncer; import 'package:komodo_ui_kit/src/inputs/range_slider_labelled.dart'; -class PercentageRangeSlider extends StatelessWidget { +class PercentageRangeSlider extends StatefulWidget { const PercentageRangeSlider({ super.key, required this.values, @@ -21,20 +22,62 @@ class PercentageRangeSlider extends StatelessWidget { final RangeValues values; final Function(RangeValues)? onChanged; + @override + State createState() => _PercentageRangeSliderState(); +} + +class _PercentageRangeSliderState extends State { + late RangeValues _currentValues; + late final Debouncer _debouncer; + + @override + void initState() { + super.initState(); + _currentValues = widget.values; + _debouncer = Debouncer(duration: const Duration(milliseconds: 300)); + } + + @override + void didUpdateWidget(PercentageRangeSlider oldWidget) { + super.didUpdateWidget(oldWidget); + // Update local state when widget values change externally + if (oldWidget.values != widget.values) { + _currentValues = widget.values; + } + } + + @override + void dispose() { + _debouncer.dispose(); + super.dispose(); + } + + void _handleRangeChanged(RangeValues newValues) { + setState(() { + _currentValues = newValues; + }); + + _debouncer.run(() { + if (mounted && widget.onChanged != null) { + widget.onChanged!(newValues); + } + }); + } + @override Widget build(BuildContext context) { return Padding( - padding: padding, + padding: widget.padding, child: Column( children: [ - if (title != null) title!, + if (widget.title != null) widget.title!, const SizedBox(height: 8), RangeSliderLabelled( - values: values, - divisions: divisions, - min: min, - max: max, - onChanged: onChanged, + values: _currentValues, + divisions: widget.divisions, + min: widget.min, + max: widget.max, + onChanged: widget.onChanged != null ? _handleRangeChanged : null, ), ], ), diff --git a/packages/komodo_ui_kit/lib/src/inputs/ui_text_form_field.dart b/packages/komodo_ui_kit/lib/src/inputs/ui_text_form_field.dart index 38a4fd5f50..9628d1342b 100644 --- a/packages/komodo_ui_kit/lib/src/inputs/ui_text_form_field.dart +++ b/packages/komodo_ui_kit/lib/src/inputs/ui_text_form_field.dart @@ -103,10 +103,7 @@ class UiTextFormField extends StatefulWidget { } class _UiTextFormFieldState extends State { - String? _errorText; - String? _displayedErrorText; late FocusNode _focusNode; - bool _hasFocusExitedOnce = false; bool _shouldValidate = false; TextEditingController? _controller; @@ -115,12 +112,7 @@ class _UiTextFormFieldState extends State { super.initState(); _controller = widget.controller ?? TextEditingController(text: widget.initialValue); - _errorText = widget.errorText; - _displayedErrorText = widget.errorText; - - if (_errorText?.isNotEmpty == true || - widget.validationMode == InputValidationMode.aggressive) { - _hasFocusExitedOnce = true; + if (widget.validationMode == InputValidationMode.aggressive) { _shouldValidate = true; } @@ -132,16 +124,6 @@ class _UiTextFormFieldState extends State { void didUpdateWidget(covariant UiTextFormField oldWidget) { super.didUpdateWidget(oldWidget); - final error = widget.validator?.call(_controller?.text) ?? widget.errorText; - if (error != oldWidget.errorText) { - _errorText = widget.errorText; - _displayedErrorText = widget.errorText; - if (_errorText?.isNotEmpty == true) { - _hasFocusExitedOnce = true; - _shouldValidate = true; - } - } - if (widget.initialValue != oldWidget.initialValue && widget.controller == null) { _controller?.text = widget.initialValue ?? ''; @@ -151,21 +133,13 @@ class _UiTextFormFieldState extends State { void _handleFocusChange() { if (!mounted) return; - final shouldUpdate = !_focusNode.hasFocus && + final shouldUpdate = + !_focusNode.hasFocus && (widget.validationMode == InputValidationMode.eager || widget.validationMode == InputValidationMode.passive); if (shouldUpdate) { - _hasFocusExitedOnce = true; _shouldValidate = true; - // Schedule validation for the next frame - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _validateAndUpdateError(_controller?.text); - }); - } - }); } setState(() { @@ -180,15 +154,6 @@ class _UiTextFormFieldState extends State { }); } - // Separate validation logic from state updates - String? _validateAndUpdateError(String? value) { - final error = widget.validator?.call(value) ?? widget.errorText; - _errorText = error; - _displayedErrorText = - _hasFocusExitedOnce || _focusNode.hasFocus ? _errorText : null; - return error; - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -200,7 +165,8 @@ class _UiTextFormFieldState extends State { ); final style = widget.style?.merge(defaultStyle) ?? defaultStyle; - final defaultLabelStyle = theme.inputDecorationTheme.labelStyle ?? + final defaultLabelStyle = + theme.inputDecorationTheme.labelStyle ?? TextStyle( fontSize: 14, fontWeight: FontWeight.w500, @@ -209,7 +175,8 @@ class _UiTextFormFieldState extends State { final labelStyle = widget.labelStyle?.merge(defaultLabelStyle) ?? defaultLabelStyle; - final defaultHintStyle = theme.inputDecorationTheme.hintStyle ?? + final defaultHintStyle = + theme.inputDecorationTheme.hintStyle ?? TextStyle( fontSize: 14, fontWeight: FontWeight.w400, @@ -218,16 +185,30 @@ class _UiTextFormFieldState extends State { final hintStyle = widget.hintTextStyle?.merge(defaultHintStyle) ?? defaultHintStyle; - final defaultErrorStyle = theme.inputDecorationTheme.errorStyle ?? - TextStyle( - fontSize: 12, - color: theme.colorScheme.error, - ); + final defaultErrorStyle = + theme.inputDecorationTheme.errorStyle ?? + TextStyle(fontSize: 12, color: theme.colorScheme.error); final errorStyle = widget.errorStyle?.merge(defaultErrorStyle) ?? defaultErrorStyle; final fillColor = widget.fillColor ?? theme.inputDecorationTheme.fillColor; + final AutovalidateMode autovalidateMode; + switch (widget.validationMode) { + case InputValidationMode.aggressive: + autovalidateMode = AutovalidateMode.always; + break; + case InputValidationMode.eager: + case InputValidationMode.passive: + autovalidateMode = _shouldValidate + ? AutovalidateMode.always + : AutovalidateMode.disabled; + break; + case InputValidationMode.lazy: + autovalidateMode = AutovalidateMode.disabled; + break; + } + return TextFormField( controller: _controller, maxLength: widget.maxLength, @@ -235,30 +216,18 @@ class _UiTextFormFieldState extends State { inputFormatters: widget.inputFormatters, autofillHints: widget.autofillHints, validator: (value) { - // Don't update state during build, just return the validation result + // Always return the validator result so FormState.validate() reflects correctness final error = widget.validator?.call(value) ?? widget.errorText; - return _shouldValidate ? error : null; + return error; }, onChanged: (value) { widget.onChanged?.call(value); - if (_shouldValidate) { - // Schedule state update for the next frame - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _validateAndUpdateError(value); - }); - } - }); - } }, onFieldSubmitted: widget.onFieldSubmitted, enableInteractiveSelection: widget.enableInteractiveSelection, textInputAction: widget.textInputAction, style: style, - autovalidateMode: _shouldValidate - ? AutovalidateMode.onUserInteraction - : AutovalidateMode.disabled, + autovalidateMode: autovalidateMode, keyboardType: widget.keyboardType, obscureText: widget.obscureText, autocorrect: widget.autocorrect, @@ -272,13 +241,15 @@ class _UiTextFormFieldState extends State { filled: fillColor != null, hintText: widget.hintText, hintStyle: hintStyle, - contentPadding: widget.inputContentPadding ?? + contentPadding: + widget.inputContentPadding ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 12), counterText: widget.counterText, labelText: widget.labelText ?? widget.hintText, labelStyle: labelStyle, helperText: widget.helperText, - errorText: _displayedErrorText, + // If an external errorText provided, show it; otherwise let Form/validator drive error display + errorText: widget.errorText, errorStyle: errorStyle, prefixIcon: widget.prefixIcon, suffixIcon: widget.suffixIcon, diff --git a/packages/komodo_ui_kit/pubspec.lock b/packages/komodo_ui_kit/pubspec.lock deleted file mode 100644 index a85ea3306c..0000000000 --- a/packages/komodo_ui_kit/pubspec.lock +++ /dev/null @@ -1,199 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - app_theme: - dependency: "direct main" - description: - path: "../../app_theme" - relative: true - source: path - version: "0.0.1" - characters: - dependency: transitive - description: - name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - decimal: - dependency: transitive - description: - name: decimal - sha256: fc706a5618b81e5b367b01dd62621def37abc096f2b46a9bd9068b64c1fa36d0 - url: "https://pub.dev" - source: hosted - version: "3.2.4" - equatable: - dependency: transitive - description: - name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" - url: "https://pub.dev" - source: hosted - version: "2.0.7" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" - url: "https://pub.dev" - source: hosted - version: "5.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - freezed_annotation: - dependency: transitive - description: - name: freezed_annotation - sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - intl: - dependency: "direct main" - description: - name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" - url: "https://pub.dev" - source: hosted - version: "0.20.2" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.dev" - source: hosted - version: "4.9.0" - komodo_defi_rpc_methods: - dependency: transitive - description: - path: "packages/komodo_defi_rpc_methods" - ref: dev - resolved-ref: "68429b23dac43eddd53434dda1bd23296523f27d" - url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" - source: git - version: "0.3.0+0" - komodo_defi_types: - dependency: "direct main" - description: - path: "packages/komodo_defi_types" - ref: dev - resolved-ref: "68429b23dac43eddd53434dda1bd23296523f27d" - url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" - source: git - version: "0.3.0+0" - komodo_ui: - dependency: "direct main" - description: - path: "packages/komodo_ui" - ref: dev - resolved-ref: "68429b23dac43eddd53434dda1bd23296523f27d" - url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" - source: git - version: "0.3.0+0" - lints: - dependency: transitive - description: - name: lints - sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 - url: "https://pub.dev" - source: hosted - version: "5.1.1" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.dev" - source: hosted - version: "0.11.1" - meta: - dependency: transitive - description: - name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c - url: "https://pub.dev" - source: hosted - version: "1.16.0" - mobile_scanner: - dependency: transitive - description: - name: mobile_scanner - sha256: "54005bdea7052d792d35b4fef0f84ec5ddc3a844b250ecd48dc192fb9b4ebc95" - url: "https://pub.dev" - source: hosted - version: "7.0.1" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - rational: - dependency: transitive - description: - name: rational - sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336 - url: "https://pub.dev" - source: hosted - version: "2.2.3" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" -sdks: - dart: ">=3.7.0 <4.0.0" - flutter: ">=3.32.5" diff --git a/packages/komodo_ui_kit/pubspec.yaml b/packages/komodo_ui_kit/pubspec.yaml index 4f3031a926..eca3b99ebf 100644 --- a/packages/komodo_ui_kit/pubspec.yaml +++ b/packages/komodo_ui_kit/pubspec.yaml @@ -3,29 +3,32 @@ description: Komodo Wallet's UI Kit Flutter package. publish_to: none environment: - sdk: ">=3.6.0 <4.0.0" - flutter: ^3.32.5 + sdk: ">=3.8.1 <4.0.0" + flutter: ">=3.35.3 <4.0.0" + +resolution: workspace dependencies: flutter: sdk: flutter - intl: ^0.20.2 # flutter.dev app_theme: path: ../../app_theme/ komodo_defi_types: - # path: ../../sdk/packages/komodo_defi_types # Requires symlink to the SDK in the root of the project - git: - url: https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git - path: packages/komodo_defi_types - ref: dev + path: ../../sdk/packages/komodo_defi_types # Option 1: Local path via sdk submodule (pinned). See docs/SDK_SUBMODULE_MANAGEMENT.md + # git: # Option 2: Git dependency + # url: https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git + # path: packages/komodo_defi_types + # ref: dev + # ^0.3.2+1 # Option 3: Pub.dev dependency komodo_ui: - # path: ../../sdk/packages/komodo_ui # Requires symlink to the SDK in the root of the project - git: - url: https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git - path: packages/komodo_ui - ref: dev + path: ../../sdk/packages/komodo_ui # Option 1: Local path via sdk submodule (pinned). See docs/SDK_SUBMODULE_MANAGEMENT.md + # git: # Option 2: Git dependency + # url: https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git + # path: packages/komodo_ui + # ref: dev + # ^0.3.0+3 # Option 3: Pub.dev dependency dev_dependencies: flutter_lints: ^5.0.0 # flutter.dev diff --git a/pubspec.lock b/pubspec.lock index 188b474b0f..e3a079fec9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,33 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57 + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f url: "https://pub.dev" source: hosted - version: "80.0.0" + version: "85.0.0" _flutterfire_internals: dependency: transitive description: name: _flutterfire_internals - sha256: de9ecbb3ddafd446095f7e833c853aff2fa1682b017921fe63a833f9d6f0e422 + sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 url: "https://pub.dev" source: hosted - version: "1.3.54" + version: "1.3.59" analyzer: dependency: transitive description: name: analyzer - sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e" + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" url: "https://pub.dev" source: hosted - version: "7.3.0" - app_theme: - dependency: "direct main" - description: - path: app_theme - relative: true - source: path - version: "0.0.1" + version: "7.7.1" args: dependency: "direct main" description: @@ -44,10 +37,10 @@ packages: dependency: transitive description: name: asn1lib - sha256: e02d018628c870ef2d7f03e33f9ad179d89ff6ec52ca6c56bcb80bcef979867f + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" url: "https://pub.dev" source: hosted - version: "1.6.2" + version: "1.6.5" async: dependency: transitive description: @@ -80,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.0" + bloc_lint: + dependency: "direct dev" + description: + name: bloc_lint + sha256: "116bfd31fd1fa050212a7cd48fbf148661107edebab2f93264fb8ae213c64ae3" + url: "https://pub.dev" + source: hosted + version: "0.2.1" boolean_selector: dependency: transitive description: @@ -104,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" cli_config: dependency: transitive description: @@ -140,10 +149,10 @@ packages: dependency: transitive description: name: coverage - sha256: "9086475ef2da7102a0c0a4e37e1e30707e7fb7b6d28c209f559a9c5f8ce42016" + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" cross_file: dependency: "direct main" description: @@ -168,6 +177,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" decimal: dependency: "direct main" description: @@ -176,22 +201,36 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" - dragon_charts_flutter: - dependency: "direct main" + device_info_plus: + dependency: transitive description: - name: dragon_charts_flutter - sha256: "663e73aeae425ec503942bde4ea40caa665c82250e760d20a1df2b89a16ffb3c" + name: device_info_plus + sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" url: "https://pub.dev" source: hosted - version: "0.1.1-dev.1" - dragon_logs: - dependency: "direct main" + version: "11.5.0" + device_info_plus_platform_interface: + dependency: transitive description: - name: dragon_logs - sha256: e697f25bd0f27b0b85af42aff7f55f003fe045c0e3eeda6164bf97aab1525804 + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "7.0.3" + dragon_charts_flutter: + dependency: "direct main" + description: + path: "sdk/packages/dragon_charts_flutter" + relative: true + source: path + version: "0.1.1-dev.3" + dragon_logs: + dependency: "direct main" + description: + path: "sdk/packages/dragon_logs" + relative: true + source: path + version: "2.0.0" easy_localization: dependency: "direct main" description: @@ -236,10 +275,10 @@ packages: dependency: "direct main" description: name: feedback - sha256: "26769f73de6215add72074d24e4a23542e4c02a8fd1a873e7c93da5dc9c1d362" + sha256: "55edce4f8f0ec01a5ff023e29c5c57df86a7391c831d5647e3b3fdff05e4b01e" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.2.0" ffi: dependency: transitive description: @@ -260,66 +299,58 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "8986dec4581b4bcd4b6df5d75a2ea0bede3db802f500635d05fa8be298f9467f" - url: "https://pub.dev" - source: hosted - version: "10.1.2" - file_system_access_api: - dependency: transitive - description: - name: file_system_access_api - sha256: c961c5020ab4e5f05200dbdd9809c5256c3dc4a1fe5746ca7d8cf8e8cc11c47d + sha256: e7e16c9d15c36330b94ca0e2ad8cb61f93cd5282d0158c09805aed13b5452f22 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "10.3.2" firebase_analytics: dependency: "direct main" description: name: firebase_analytics - sha256: "2416b9d864412ab7b571dafded801bbcc7e29b5824623c055002d4d0819bea2b" + sha256: "4f85b161772e1d54a66893ef131c0a44bd9e552efa78b33d5f4f60d2caa5c8a3" url: "https://pub.dev" source: hosted - version: "11.4.5" + version: "11.6.0" firebase_analytics_platform_interface: dependency: transitive description: name: firebase_analytics_platform_interface - sha256: "3ccf5c876a8bea186016de4bcf53fc1bc6fa01236d740fb501d7ef9be356c58e" + sha256: a44b6d1155ed5cae7641e3de7163111cfd9f6f6c954ca916dc6a3bdfa86bf845 url: "https://pub.dev" source: hosted - version: "4.3.5" + version: "4.4.3" firebase_analytics_web: dependency: transitive description: name: firebase_analytics_web - sha256: "5e4e3f001b67c2034b76cb2a42a0eed330fb3a8fb41ad13eceb04e8d9a74f662" + sha256: c7d1ed1f86ae64215757518af5576ff88341c8ce5741988c05cc3b2e07b0b273 url: "https://pub.dev" source: hosted - version: "0.5.10+11" + version: "0.5.10+16" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: "017d17d9915670e6117497e640b2859e0b868026ea36bf3a57feb28c3b97debe" + sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" url: "https://pub.dev" source: hosted - version: "3.13.0" + version: "3.15.2" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: d7253d255ff10f85cfd2adaba9ac17bae878fa3ba577462451163bd9f1d1f0bf + sha256: "5dbc900677dcbe5873d22ad7fbd64b047750124f1f9b7ebe2a33b9ddccc838eb" url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "6.0.0" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: "129a34d1e0fb62e2b488d988a1fc26cc15636357e50944ffee2862efe8929b23" + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" url: "https://pub.dev" source: hosted - version: "2.22.0" + version: "2.24.1" fixnum: dependency: transitive description: @@ -427,18 +458,18 @@ packages: dependency: "direct main" description: name: flutter_markdown - sha256: "634622a3a826d67cb05c0e3e576d1812c430fa98404e95b60b131775c73d76ec" + sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.7+1" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e + sha256: b0694b7fb1689b0e6cc193b3f1fcac6423c4f93c74fb20b806c6b6f196db0c31 url: "https://pub.dev" source: hosted - version: "2.0.28" + version: "2.0.30" flutter_secure_storage: dependency: transitive description: @@ -517,10 +548,10 @@ packages: dependency: "direct main" description: name: flutter_window_close - sha256: bbdd1ec57259cbffc3f978c1709a3314a0991f042490d9d0a02c5fd70ac8dff6 + sha256: "7fecc628c6f6e751d279f2a988a22b5ffcc0d4c5da0bfe9b41a388803025819f" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" formz: dependency: "direct main" description: @@ -554,10 +585,10 @@ packages: dependency: "direct main" description: name: get_it - sha256: e87cd1d108e472a0580348a543a0c49ed3d70c8a5c809c6d418583e595d0a389 + sha256: a4292e7cf67193f8e7c1258203104eb2a51ec8b3a04baa14695f4064c144297b url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "8.2.0" glob: dependency: transitive description: @@ -574,6 +605,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.3" + hive_ce: + dependency: transitive + description: + name: hive_ce + sha256: "708bb39050998707c5d422752159f91944d3c81ab42d80e1bd0ee37d8e130658" + url: "https://pub.dev" + source: hosted + version: "2.11.3" + hive_ce_flutter: + dependency: transitive + description: + name: hive_ce_flutter + sha256: f5bd57fda84402bca7557fedb8c629c96c8ea10fab4a542968d7b60864ca02cc + url: "https://pub.dev" + source: hosted + version: "2.3.2" hive_flutter: dependency: "direct main" description: @@ -615,7 +662,7 @@ packages: source: hosted version: "4.1.2" integration_test: - dependency: "direct dev" + dependency: "direct main" description: flutter source: sdk version: "0.0.0" @@ -635,6 +682,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + isolate_channel: + dependency: transitive + description: + name: isolate_channel + sha256: f3d36f783b301e6b312c3450eeb2656b0e7d1db81331af2a151d9083a3f6b18d + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" js: dependency: "direct main" description: @@ -654,131 +709,97 @@ packages: komodo_cex_market_data: dependency: "direct main" description: - path: "packages/komodo_cex_market_data" - ref: dev - resolved-ref: "68429b23dac43eddd53434dda1bd23296523f27d" - url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" - source: git - version: "0.0.1" + path: "sdk/packages/komodo_cex_market_data" + relative: true + source: path + version: "0.0.3+1" komodo_coin_updates: - dependency: transitive + dependency: "direct overridden" description: - path: "packages/komodo_coin_updates" - ref: dev - resolved-ref: "68429b23dac43eddd53434dda1bd23296523f27d" - url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" - source: git - version: "1.0.0" + path: "sdk/packages/komodo_coin_updates" + relative: true + source: path + version: "1.1.1" komodo_coins: - dependency: transitive + dependency: "direct overridden" description: - path: "packages/komodo_coins" - ref: dev - resolved-ref: "68429b23dac43eddd53434dda1bd23296523f27d" - url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" - source: git - version: "0.3.0+0" + path: "sdk/packages/komodo_coins" + relative: true + source: path + version: "0.3.1+2" komodo_defi_framework: - dependency: transitive + dependency: "direct overridden" description: - path: "packages/komodo_defi_framework" - ref: dev - resolved-ref: "68429b23dac43eddd53434dda1bd23296523f27d" - url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" - source: git - version: "0.3.0+0" + path: "sdk/packages/komodo_defi_framework" + relative: true + source: path + version: "0.3.1+2" komodo_defi_local_auth: - dependency: transitive + dependency: "direct overridden" description: - path: "packages/komodo_defi_local_auth" - ref: dev - resolved-ref: "68429b23dac43eddd53434dda1bd23296523f27d" - url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" - source: git - version: "0.3.0+0" + path: "sdk/packages/komodo_defi_local_auth" + relative: true + source: path + version: "0.3.1+2" komodo_defi_rpc_methods: - dependency: transitive + dependency: "direct overridden" description: - path: "packages/komodo_defi_rpc_methods" - ref: dev - resolved-ref: "68429b23dac43eddd53434dda1bd23296523f27d" - url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" - source: git - version: "0.3.0+0" + path: "sdk/packages/komodo_defi_rpc_methods" + relative: true + source: path + version: "0.3.1+1" komodo_defi_sdk: dependency: "direct main" description: - path: "packages/komodo_defi_sdk" - ref: dev - resolved-ref: "68429b23dac43eddd53434dda1bd23296523f27d" - url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" - source: git - version: "0.3.0+0" + path: "sdk/packages/komodo_defi_sdk" + relative: true + source: path + version: "0.4.0+3" komodo_defi_types: dependency: "direct main" description: - path: "packages/komodo_defi_types" - ref: dev - resolved-ref: "68429b23dac43eddd53434dda1bd23296523f27d" - url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" - source: git - version: "0.3.0+0" - komodo_persistence_layer: - dependency: "direct main" - description: - path: "packages/komodo_persistence_layer" + path: "sdk/packages/komodo_defi_types" relative: true source: path - version: "0.0.1" + version: "0.3.2+1" komodo_ui: dependency: "direct main" description: - path: "packages/komodo_ui" - ref: dev - resolved-ref: "68429b23dac43eddd53434dda1bd23296523f27d" - url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" - source: git - version: "0.3.0+0" - komodo_ui_kit: - dependency: "direct main" - description: - path: "packages/komodo_ui_kit" + path: "sdk/packages/komodo_ui" relative: true source: path - version: "0.0.0" + version: "0.3.0+3" komodo_wallet_build_transformer: - dependency: transitive + dependency: "direct overridden" description: - path: "packages/komodo_wallet_build_transformer" - ref: dev - resolved-ref: "68429b23dac43eddd53434dda1bd23296523f27d" - url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" - source: git - version: "0.3.0+0" + path: "sdk/packages/komodo_wallet_build_transformer" + relative: true + source: path + version: "0.4.0" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.1" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -799,18 +820,18 @@ packages: dependency: transitive description: name: local_auth_android - sha256: "82b2bdeee2199a510d3b7716121e96a6609da86693bb0863edd8566355406b79" + sha256: "48924f4a8b3cc45994ad5993e2e232d3b00788a305c1bf1c7db32cef281ce9a3" url: "https://pub.dev" source: hosted - version: "1.0.50" + version: "1.0.52" local_auth_darwin: dependency: transitive description: name: local_auth_darwin - sha256: "25163ce60a5a6c468cf7a0e3dc8a165f824cabc2aa9e39a5e9fc5c2311b7686f" + sha256: "0e9706a8543a4a2eee60346294d6a633dd7c3ee60fae6b752570457c4ff32055" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.6.0" local_auth_platform_interface: dependency: transitive description: @@ -859,6 +880,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" + matomo_tracker: + dependency: "direct main" + description: + name: matomo_tracker + sha256: e5f179b3660193d62b7494abd2179c8dfcab8e66ffa76eeefe3b6f88fb3d8291 + url: "https://pub.dev" + source: hosted + version: "6.0.0" meta: dependency: transitive description: @@ -884,7 +913,7 @@ packages: source: hosted version: "7.0.1" mutex: - dependency: transitive + dependency: "direct main" description: name: mutex sha256: "8827da25de792088eb33e572115a5eb0d61d61a3c01acbc8bcbe76ed78f1a1f2" @@ -935,10 +964,10 @@ packages: dependency: transitive description: name: package_info_plus_platform_interface - sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "3.2.1" path: dependency: transitive description: @@ -967,18 +996,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" url: "https://pub.dev" source: hosted - version: "2.2.17" + version: "2.2.18" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" path_provider_linux: dependency: transitive description: @@ -1007,10 +1036,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "7.0.1" platform: dependency: transitive description: @@ -1047,18 +1076,18 @@ packages: dependency: transitive description: name: process - sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "5.0.5" provider: dependency: transitive description: name: provider - sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" url: "https://pub.dev" source: hosted - version: "6.1.5" + version: "6.1.5+1" pub_semver: dependency: transitive description: @@ -1067,6 +1096,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + pubspec_lock_parse: + dependency: transitive + description: + name: pubspec_lock_parse + sha256: "020cb470287124c936c30ebfc2f927b287f275b7bf7fc2ab11577e592c017764" + url: "https://pub.dev" + source: hosted + version: "2.2.0" qr: dependency: transitive description: @@ -1095,18 +1132,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + sha256: d7dc0630a923883c6328ca31b89aa682bacbf2f8304162d29f7c6aaff03a27a1 url: "https://pub.dev" source: hosted - version: "10.1.4" + version: "11.1.0" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "6.1.0" shared_preferences: dependency: "direct main" description: @@ -1119,10 +1156,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74 url: "https://pub.dev" source: hosted - version: "2.4.10" + version: "2.4.12" shared_preferences_foundation: dependency: transitive description: @@ -1284,26 +1321,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e" + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" url: "https://pub.dev" source: hosted - version: "1.25.15" + version: "1.26.2" test_api: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" test_core: dependency: transitive description: name: test_core - sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" url: "https://pub.dev" source: hosted - version: "0.6.8" + version: "0.6.11" typed_data: dependency: transitive description: @@ -1340,18 +1377,18 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "1d0eae19bd7606ef60fe69ef3b312a437a16549476c42321d5dc1506c9ca3bf4" + sha256: "69ee86740f2847b9a4ba6cffa74ed12ce500bbe2b07f3dc1e643439da60637b7" url: "https://pub.dev" source: hosted - version: "6.3.15" + version: "6.3.18" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.3.4" url_launcher_linux: dependency: transitive description: @@ -1364,10 +1401,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" url_launcher_platform_interface: dependency: transitive description: @@ -1380,10 +1417,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" url_launcher_windows: dependency: transitive description: @@ -1404,10 +1441,10 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de" + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 url: "https://pub.dev" source: hosted - version: "1.1.18" + version: "1.1.19" vector_graphics_codec: dependency: transitive description: @@ -1420,82 +1457,74 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" + sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc url: "https://pub.dev" source: hosted - version: "1.1.16" + version: "1.1.19" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" - very_good_analysis: - dependency: transitive - description: - name: very_good_analysis - sha256: c529563be4cbba1137386f2720fb7ed69e942012a28b13398d8a5e3e6ef551a7 - url: "https://pub.dev" - source: hosted - version: "8.0.0" + version: "2.2.0" video_player: dependency: "direct main" description: name: video_player - sha256: "7d78f0cfaddc8c19d4cb2d3bebe1bfef11f2103b0a03e5398b303a1bf65eeb14" + sha256: "0d55b1f1a31e5ad4c4967bfaa8ade0240b07d20ee4af1dfef5f531056512961a" url: "https://pub.dev" source: hosted - version: "2.9.5" + version: "2.10.0" video_player_android: dependency: transitive description: name: video_player_android - sha256: ae7d4f1b41e3ac6d24dd9b9d5d6831b52d74a61bdd90a7a6262a33d8bb97c29a + sha256: "59e5a457ddcc1688f39e9aef0efb62aa845cf0cbbac47e44ac9730dc079a2385" url: "https://pub.dev" source: hosted - version: "2.8.2" + version: "2.8.13" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "84b4752745eeccb6e75865c9aab39b3d28eb27ba5726d352d45db8297fbd75bc" + sha256: f9a780aac57802b2892f93787e5ea53b5f43cc57dc107bee9436458365be71cd url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "2.8.4" video_player_platform_interface: dependency: transitive description: name: video_player_platform_interface - sha256: df534476c341ab2c6a835078066fc681b8265048addd853a1e3c78740316a844 + sha256: cf2a1d29a284db648fd66cbd18aacc157f9862d77d2cc790f6f9678a46c1db5a url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.4.0" video_player_web: dependency: transitive description: name: video_player_web - sha256: "3ef40ea6d72434edbfdba4624b90fd3a80a0740d260667d91e7ecd2d79e13476" + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.4.0" vm_service: dependency: transitive description: name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "15.0.2" watcher: dependency: transitive description: name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + sha256: "5bf046f41320ac97a469d506261797f35254fa61c641741ef32dacda98b7d39c" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.3" web: dependency: transitive description: @@ -1508,18 +1537,18 @@ packages: dependency: transitive description: name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" webdriver: dependency: transitive description: @@ -1544,6 +1573,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.14.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" window_size: dependency: "direct main" description: @@ -1565,10 +1602,10 @@ packages: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.1" yaml: dependency: transitive description: @@ -1579,4 +1616,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.8.1 <4.0.0" - flutter: ">=3.32.5" + flutter: ">=3.35.3" diff --git a/pubspec.yaml b/pubspec.yaml index 59d520111f..e5110333b9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ -name: web_dex # Use `lowercase_with_underscores` for package names -description: komodo atomicDEX web wallet +name: web_dex +description: Komodo Wallet # The following line prevents the package from being accidentally published to # pub.dev using `pub publish`. This is preferred for private packages. @@ -15,11 +15,16 @@ 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.2+0 +version: 0.9.3+0 environment: - sdk: ^3.8.1 - flutter: ^3.32.5 + sdk: ">=3.8.1 <4.0.0" + flutter: ">=3.35.3 <4.0.0" + +workspace: + - packages/komodo_ui_kit + - packages/komodo_persistence_layer + - app_theme dependencies: ## ---- Flutter SDK @@ -41,16 +46,23 @@ dependencies: komodo_persistence_layer: path: packages/komodo_persistence_layer - komodo_cex_market_data: - # path: sdk/packages/komodo_cex_market_data # Requires symlink to the SDK in the root of the project - git: - url: https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git - path: packages/komodo_cex_market_data - ref: dev - ## ---- KomodoPlatform pub.dev packages (First-party) - dragon_logs: 1.1.0 + komodo_cex_market_data: + path: sdk/packages/komodo_cex_market_data # Option 1: Local path via sdk submodule (pinned). See docs/SDK_SUBMODULE_MANAGEMENT.md + # git: # Option 2: Git dependency + # url: https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git + # path: packages/komodo_cex_market_data + # ref: dev + # ^0.0.3+1 # Option 3: Pub.dev dependency + + dragon_logs: + path: sdk/packages/dragon_logs # Option 1: Local path via sdk submodule (pinned). See docs/SDK_SUBMODULE_MANAGEMENT.md + # git: # Option 2: Git dependency + # url: https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git + # path: packages/dragon_logs + # ref: dev + # ^2.0.0 # Option 3: Pub.dev dependency ## ---- Dart.dev, Flutter.dev args: ^2.7.0 # dart.dev @@ -63,6 +75,10 @@ dependencies: cross_file: 0.3.4+2 # flutter.dev video_player: ^2.9.5 # flutter.dev logging: 1.3.0 + mutex: ^3.1.0 + + integration_test: # SDK (moved from dev_dependencies to ensure Android release build includes plugin) + sdk: flutter ## ---- google.com @@ -89,7 +105,7 @@ dependencies: package_info_plus: 8.3.0 # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 (Outdated) - share_plus: 10.1.4 + share_plus: 11.1.0 ## ---- 3d party @@ -116,6 +132,7 @@ dependencies: badges: 3.1.2 flutter_slidable: 4.0.0 + cupertino_icons: ^1.0.8 # Embedded web view # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/3 @@ -125,9 +142,16 @@ dependencies: formz: 0.8.0 # TODO: review required - dragon_charts_flutter: 0.1.1-dev.1 + dragon_charts_flutter: + path: sdk/packages/dragon_charts_flutter # Option 1: Local path via sdk submodule (pinned). See docs/SDK_SUBMODULE_MANAGEMENT.md + # git: # Option 2: Git dependency + # url: https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git + # path: packages/dragon_charts_flutter + # ref: dev + # ^0.1.1-dev.3 # Option 3: Pub.dev dependency + bloc_concurrency: 0.3.0 - file_picker: ^10.0.0 + file_picker: ^10.3.2 # TODO: review required - SDK integration path_provider: 2.1.5 # flutter.dev @@ -137,33 +161,37 @@ dependencies: 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 - komodo_defi_sdk: # TODO: change to pub.dev version? - # path: sdk/packages/komodo_defi_sdk # Requires symlink to the SDK in the root of the project - git: - url: https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git - path: packages/komodo_defi_sdk - ref: dev + + komodo_defi_sdk: + path: sdk/packages/komodo_defi_sdk # Option 1: Local path via sdk submodule (pinned). See docs/SDK_SUBMODULE_MANAGEMENT.md + # git: # Option 2: Git dependency + # url: https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git + # path: packages/komodo_defi_sdk + # ref: dev + # ^0.4.0+3 # Option 3: Pub.dev dependency komodo_defi_types: - # path: sdk/packages/komodo_defi_types # Requires symlink to the SDK in the root of the project - git: - url: https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git - path: packages/komodo_defi_types - ref: dev + path: sdk/packages/komodo_defi_types # Option 1: Local path via sdk submodule (pinned). See docs/SDK_SUBMODULE_MANAGEMENT.md + # git: # Option 2: Git dependency + # url: https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git + # path: packages/komodo_defi_types + # ref: dev + # ^0.3.2+1 # Option 3: Pub.dev dependency komodo_ui: - # path: sdk/packages/komodo_ui # Requires symlink to the SDK in the root of the project - git: - url: https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git - path: packages/komodo_ui - ref: dev + path: sdk/packages/komodo_ui # Option 1: Local path via sdk submodule (pinned). See docs/SDK_SUBMODULE_MANAGEMENT.md + # git: # Option 2: Git dependency + # url: https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git + # path: packages/komodo_ui + # ref: dev + # ^0.3.0+3 # Option 3: Pub.dev dependency + feedback: ^3.1.0 ntp: ^2.0.0 + matomo_tracker: ^6.0.0 flutter_window_close: ^1.2.0 dev_dependencies: - integration_test: # SDK - sdk: flutter test: ^1.24.1 # dart.dev # The "flutter_lints" package below contains a set of recommended lints to @@ -172,6 +200,7 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^5.0.0 # flutter.dev + bloc_lint: ^0.2.1 dependency_overrides: # Temporary until Flutter's pinned version is updated diff --git a/pubspec_overrides.yaml b/pubspec_overrides.yaml new file mode 100644 index 0000000000..f3be1e7d05 --- /dev/null +++ b/pubspec_overrides.yaml @@ -0,0 +1,25 @@ +dependency_overrides: + dragon_charts_flutter: + path: ./sdk/packages/dragon_charts_flutter + dragon_logs: + path: ./sdk/packages/dragon_logs + komodo_cex_market_data: + path: ./sdk/packages/komodo_cex_market_data + komodo_coin_updates: + path: ./sdk/packages/komodo_coin_updates + komodo_coins: + path: ./sdk/packages/komodo_coins + komodo_defi_framework: + path: ./sdk/packages/komodo_defi_framework + komodo_defi_local_auth: + path: ./sdk/packages/komodo_defi_local_auth + komodo_defi_rpc_methods: + path: ./sdk/packages/komodo_defi_rpc_methods + komodo_defi_sdk: + path: ./sdk/packages/komodo_defi_sdk + komodo_defi_types: + path: ./sdk/packages/komodo_defi_types + komodo_ui: + path: ./sdk/packages/komodo_ui + komodo_wallet_build_transformer: + path: ./sdk/packages/komodo_wallet_build_transformer diff --git a/sdk b/sdk new file mode 160000 index 0000000000..36556ddaf0 --- /dev/null +++ b/sdk @@ -0,0 +1 @@ +Subproject commit 36556ddaf036c59e20d3eedcb69f994321862d8c 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 new file mode 100644 index 0000000000..aa4ffd11f9 --- /dev/null +++ b/test_integration/bloc/cex_market_data/common/update_frequency_backoff_strategy_integration_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; + +void main() { + group('UpdateFrequencyBackoffStrategy Integration Tests', () { + test('should demonstrate realistic backoff progression over time', () { + final strategy = UpdateFrequencyBackoffStrategy(); + final List actualIntervals = []; + + // Simulate 20 update attempts + for (int i = 0; i < 20; i++) { + 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 that all subsequent intervals are capped at max + for (int i = 12; 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; + + // 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('Backoff strategy: $backoffApiCalls API calls in 24h'); + 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: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 4)); + }); + + test('should handle custom intervals for different use cases', () { + // Test for a more aggressive backoff (shorter max interval) + final aggressiveStrategy = UpdateFrequencyBackoffStrategy( + baseInterval: const Duration(minutes: 1), + maxInterval: const Duration(minutes: 10), + ); + + // Test for a more conservative backoff (longer base interval) + final conservativeStrategy = UpdateFrequencyBackoffStrategy( + baseInterval: const Duration(minutes: 5), + maxInterval: const Duration(hours: 2), + ); + + // Aggressive should reach max quickly (after 6 attempts: 1,1,2,2,4,4,8...) + for (int i = 0; i < 6; i++) { + aggressiveStrategy.getNextInterval(); + } + expect(aggressiveStrategy.getCurrentInterval(), const Duration(minutes: 8)); + + // Conservative should start and progress more slowly + expect(conservativeStrategy.getNextInterval(), const Duration(minutes: 5)); + expect(conservativeStrategy.getNextInterval(), const Duration(minutes: 5)); + expect(conservativeStrategy.getNextInterval(), const Duration(minutes: 10)); + expect(conservativeStrategy.getNextInterval(), const Duration(minutes: 10)); + }); + + 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)); + + // Later updates should be less frequent + final lastInterval = firstHourIntervals.last; + expect(lastInterval, greaterThan(const Duration(minutes: 2))); + + print('Updates in first hour: ${firstHourIntervals.length}'); + 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 e9a49569b4..3447d57e9a 100644 --- a/test_integration/runners/integration_test_runner.dart +++ b/test_integration/runners/integration_test_runner.dart @@ -76,57 +76,54 @@ class IntegrationTestRunner { } Future _runNativeTest(String test) async { - return Process.run( - 'flutter', - [ - 'drive', - '--dart-define=testing_mode=true', - '--driver=test_driver/integration_test.dart', - '--target=$testsDirectory/$test', - if (_args.verbose) '-v', - '-d', - _args.device, - '--${_args.runMode}', - if (_args.runMode == 'profile') '--profile-memory=memory_profile.json', - '--${_args.pub ? '' : 'no-'}pub', - '--${_args.keepRunning ? '' : 'no-'}keep-app-running', - '--timeout=600', - ], - runInShell: true, - ); + return Process.run('flutter', [ + 'drive', + '--dart-define=testing_mode=true', + '--dart-define=CI=true', + '--dart-define=ANALYTICS_DISABLED=true', + '--driver=test_driver/integration_test.dart', + '--target=$testsDirectory/$test', + if (_args.verbose) '-v', + '-d', + _args.device, + '--${_args.runMode}', + if (_args.runMode == 'profile') '--profile-memory=memory_profile.json', + '--${_args.pub ? '' : 'no-'}pub', + '--${_args.keepRunning ? '' : 'no-'}keep-app-running', + '--timeout=600', + ], runInShell: true); } Future _runWebServerTest(String test) async { - return Process.run( - 'flutter', - [ - 'drive', - '--dart-define=testing_mode=true', - '--driver=test_driver/integration_test.dart', - '--target=$testsDirectory/$test', - if (_args.verbose) '-v', - '-d', - _args.device, - '--browser-dimension', - _args.browserDimension, - '--${_args.displayMode}', - '--${_args.runMode}', - if (_args.runMode == 'profile') '--profile-memory=memory_profile.json', - '--browser-name', - _args.browserName, - '--web-renderer', - 'canvaskit', - '--${_args.pub ? '' : 'no-'}pub', - '--${_args.keepRunning ? '' : 'no-'}keep-app-running', - '--driver-port=${_args.driverPort}', - '--timeout=600', - ], - runInShell: true, - ); + return Process.run('flutter', [ + 'drive', + '--dart-define=testing_mode=true', + '--dart-define=CI=true', + '--dart-define=ANALYTICS_DISABLED=true', + '--driver=test_driver/integration_test.dart', + '--target=$testsDirectory/$test', + if (_args.verbose) '-v', + '-d', + _args.device, + '--browser-dimension', + _args.browserDimension, + '--${_args.displayMode}', + '--${_args.runMode}', + if (_args.runMode == 'profile') '--profile-memory=memory_profile.json', + '--browser-name', + _args.browserName, + '--web-renderer', + 'canvaskit', + '--${_args.pub ? '' : 'no-'}pub', + '--${_args.keepRunning ? '' : 'no-'}keep-app-running', + '--driver-port=${_args.driverPort}', + '--timeout=600', + ], runInShell: true); } bool _didAnyTestFail(ProcessResult result) { - final caseInvariantConsoleOutput = result.stdout.toString().toLowerCase() + + final caseInvariantConsoleOutput = + result.stdout.toString().toLowerCase() + result.stderr.toString().toLowerCase(); return caseInvariantConsoleOutput.contains('failure details') || 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 new file mode 100644 index 0000000000..6dbb7c5fbc --- /dev/null +++ b/test_units/bloc/cex_market_data/common/update_frequency_backoff_strategy_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; + +void main() { + group('UpdateFrequencyBackoffStrategy', () { + late UpdateFrequencyBackoffStrategy strategy; + + setUp(() { + strategy = UpdateFrequencyBackoffStrategy(); + }); + + test('should start with attempt count 0', () { + expect(strategy.attemptCount, 0); + }); + + 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.attemptCount, 1); + + expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 2)); + 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)); + expect(strategy.attemptCount, 3); + + expect(strategy.getCurrentInterval(), const Duration(minutes: 4)); + expect(strategy.getNextInterval(), const Duration(minutes: 4)); + expect(strategy.attemptCount, 4); + }); + + test('should follow exponential backoff pattern: 2,2,4,4,8,8,16,16', () { + 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 + ]; + + for (int i = 0; i < expectedIntervals.length; i++) { + expect( + strategy.getNextInterval(), + expectedIntervals[i], + reason: 'Attempt $i should return ${expectedIntervals[i]}', + ); + } + }); + + test('should cap at maximum interval', () { + strategy = UpdateFrequencyBackoffStrategy( + baseInterval: const Duration(minutes: 1), + maxInterval: const Duration(minutes: 5), + ); + + // Skip to high attempt count to reach max + for (int i = 0; i < 10; i++) { + strategy.getNextInterval(); + } + + // Should be capped at 5 minutes + expect(strategy.getCurrentInterval(), const Duration(minutes: 5)); + }); + + test('should reset to initial state', () { + // Make some attempts + strategy.getNextInterval(); + strategy.getNextInterval(); + strategy.getNextInterval(); + + expect(strategy.attemptCount, 3); + expect(strategy.getCurrentInterval(), const Duration(minutes: 4)); + + // Reset + strategy.reset(); + + expect(strategy.attemptCount, 0); + expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); + }); + + test('should always return true for shouldUpdateCache', () { + // Test for various attempt counts + for (int i = 0; i < 10; i++) { + expect(strategy.shouldUpdateCache(), true); + strategy.getNextInterval(); + } + }); + + 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 + ]); + }); + + test('should preview intervals from current position', () { + // Advance to attempt 2 + strategy.getNextInterval(); // 2 min + strategy.getNextInterval(); // 2 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 + ]); + + // State should be unchanged + expect(strategy.attemptCount, 2); + }); + + test('should handle custom base and max intervals', () { + strategy = UpdateFrequencyBackoffStrategy( + baseInterval: const Duration(minutes: 1), + maxInterval: const Duration(minutes: 3), + ); + + final intervals = [ + strategy.getNextInterval(), // 1min + strategy.getNextInterval(), // 1min + strategy.getNextInterval(), // 2min + strategy.getNextInterval(), // 2min + strategy.getNextInterval(), // 3min (capped at max) + ]; + + expect(intervals, [ + const Duration(minutes: 1), + const Duration(minutes: 1), + const Duration(minutes: 2), + const Duration(minutes: 2), + const Duration(minutes: 3), // Capped + ]); + }); + }); +} \ No newline at end of file diff --git a/test_units/main.dart b/test_units/main.dart index 488b340301..857c20b25e 100644 --- a/test_units/main.dart +++ b/test_units/main.dart @@ -1,9 +1,5 @@ import 'package:test/test.dart'; -import 'tests/cex_market_data/binance_repository_test.dart'; -import 'tests/cex_market_data/charts_test.dart'; -import 'tests/cex_market_data/generate_demo_data_test.dart'; -import 'tests/cex_market_data/profit_loss_repository_test.dart'; import 'tests/encryption/encrypt_data_test.dart'; import 'tests/formatter/compare_dex_to_cex_tests.dart'; import 'tests/formatter/cut_trailing_zeros_test.dart'; @@ -19,11 +15,13 @@ 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/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'; @@ -33,8 +31,9 @@ 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/utils/get_fiat_amount_tests.dart'; +import 'tests/utils/get_usd_balance_test.dart'; +import 'tests/utils/ipfs_gateway_manager_test.dart'; import 'tests/utils/transaction_history/sanitize_transaction_test.dart'; -import 'tests/swaps/my_recent_swaps_response_test.dart'; /// Run in terminal flutter test test_units/main.dart /// More info at documentation "Unit and Widget testing" section @@ -65,20 +64,21 @@ void main() { group('Utils:', () { // TODO: re-enable or migrate to the SDK - // testUsdBalanceFormatter(); + testUsdBalanceFormatter(); testGetFiatAmount(); testCustomDoubleToString(); testRatToFracAndViseVersa(); testDoubleToString(); testSanitizeTransaction(); + testIpfsGatewayManager(); }); group('Helpers: ', () { testMaxMinRational(); testCalculateBuyAmount(); // TODO: re-enable or migrate to the SDK - // testGetTotal24Change(); + testGetTotal24Change(); testGetTotalFee(); testGetSellAmount(); testUpdateSellAmount(); @@ -92,13 +92,6 @@ void main() { testMyRecentSwapsResponse(); }); - group('CexMarketData: ', () { - testCharts(); - testFailingBinanceRepository(); - testProfitLossRepository(); - testGenerateDemoData(); - }); - group('SystemHealth: ', () { testHttpHeadTimeProvider(); testSystemClockRepository(); diff --git a/test_units/shared/widgets/quick_login_switch_test.dart b/test_units/shared/widgets/quick_login_switch_test.dart new file mode 100644 index 0000000000..dd80658a8b --- /dev/null +++ b/test_units/shared/widgets/quick_login_switch_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/shared/widgets/quick_login_switch.dart'; +import 'package:web_dex/shared/widgets/remember_wallet_service.dart'; + +void main() { + group('QuickLoginSwitch and RememberWalletService Integration Tests', () { + testWidgets( + 'QuickLoginSwitch should provide static access to service methods', + (WidgetTester tester) async { + // Test that static methods are accessible + expect(QuickLoginSwitch.hasShownRememberMeDialogThisSession, false); + expect(QuickLoginSwitch.hasBeenLoggedInThisSession, false); + + // Test tracking user login + QuickLoginSwitch.trackUserLoggedIn(); + expect(QuickLoginSwitch.hasBeenLoggedInThisSession, true); + + // Test reset functionality + QuickLoginSwitch.resetOnLogout(); + expect(QuickLoginSwitch.hasShownRememberMeDialogThisSession, false); + expect(QuickLoginSwitch.hasBeenLoggedInThisSession, false); + }, + ); + + test('RememberWalletService should maintain state correctly', () { + // Initial state + expect(RememberWalletService.hasShownRememberMeDialogThisSession, false); + expect(RememberWalletService.hasBeenLoggedInThisSession, false); + + // Track login + RememberWalletService.trackUserLoggedIn(); + expect(RememberWalletService.hasBeenLoggedInThisSession, true); + + // Reset state + RememberWalletService.resetOnLogout(); + expect(RememberWalletService.hasShownRememberMeDialogThisSession, false); + expect(RememberWalletService.hasBeenLoggedInThisSession, false); + }); + + testWidgets('QuickLoginSwitch widget should render correctly', ( + WidgetTester tester, + ) async { + bool switchValue = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: QuickLoginSwitch( + value: switchValue, + onChanged: (value) { + switchValue = value; + }, + ), + ), + ), + ); + + // Verify the widget renders + expect(find.byType(SwitchListTile), findsOneWidget); + expect(find.byType(Tooltip), findsOneWidget); + }); + }); +} diff --git a/test_units/tests/analytics_test.dart b/test_units/tests/analytics_test.dart new file mode 100644 index 0000000000..c29d77b120 --- /dev/null +++ b/test_units/tests/analytics_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/bloc/analytics/analytics_repo.dart'; +import 'package:web_dex/model/settings/analytics_settings.dart'; + +void main() { + group('AnalyticsRepository Tests', () { + late AnalyticsSettings testSettings; + + setUp(() { + testSettings = const AnalyticsSettings(isSendAllowed: true); + }); + + test('AnalyticsRepository implements AnalyticsRepo', () { + final repo = AnalyticsRepository(testSettings); + expect(repo, isA()); + }); + + test('AnalyticsRepository has correct initialization state', () { + final repo = AnalyticsRepository(testSettings); + + // Initially should not be initialized (async initialization) + expect(repo.isInitialized, false); + expect(repo.isEnabled, false); + }); + + testWidgets('AnalyticsRepository can send test event', ( + WidgetTester tester, + ) async { + final repo = AnalyticsRepository(testSettings); + + // Create a test event + final testEvent = TestAnalyticsEvent(); + + // This should not throw an exception + expect(() => repo.queueEvent(testEvent), returnsNormally); + }); + }); +} + +class TestAnalyticsEvent extends AnalyticsEventData { + @override + String get name => 'test_event'; + + @override + Map get parameters => { + 'test_parameter': 'test_value', + 'timestamp': DateTime.now().toIso8601String(), + }; +} diff --git a/test_units/tests/cex_market_data/binance_repository_test.dart b/test_units/tests/cex_market_data/binance_repository_test.dart deleted file mode 100644 index 554eaabd7f..0000000000 --- a/test_units/tests/cex_market_data/binance_repository_test.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; - -import 'mocks/mock_failing_binance_provider.dart'; - -void testFailingBinanceRepository() { - late BinanceRepository binanceRepository; - - setUp(() { - binanceRepository = BinanceRepository( - binanceProvider: const MockFailingBinanceProvider(), - ); - }); - - group('Failing BinanceRepository Requests', () { - test('Coin list is empty if all requests to binance fail', () async { - final response = await binanceRepository.getCoinList(); - expect(response, isEmpty); - }); - - test( - 'OHLC request rethrows [UnsupportedError] if all requests fail', - () async { - expect( - () async { - final response = await binanceRepository.getCoinOhlc( - const CexCoinPair.usdtPrice('KMD'), - GraphInterval.oneDay, - ); - return response; - }, - throwsUnsupportedError, - ); - }); - - test('Coin fiat price throws [UnsupportedError] if all requests fail', - () async { - expect( - () async { - final response = await binanceRepository.getCoinFiatPrice('KMD'); - return response; - }, - throwsUnsupportedError, - ); - }); - - test('Coin fiat prices throws [UnsupportedError] if all requests fail', - () async { - expect( - () async { - final response = await binanceRepository - .getCoinFiatPrices('KMD', [DateTime.now()]); - return response; - }, - throwsUnsupportedError, - ); - }); - }); -} diff --git a/test_units/tests/cex_market_data/charts_test.dart b/test_units/tests/cex_market_data/charts_test.dart deleted file mode 100644 index f6f09e1638..0000000000 --- a/test_units/tests/cex_market_data/charts_test.dart +++ /dev/null @@ -1,348 +0,0 @@ -import 'dart:math'; - -import 'package:test/test.dart'; -import 'package:web_dex/bloc/cex_market_data/charts.dart'; - -void testCharts() { - group('Charts', () { - test('merge with fullOuterJoin', () { - final chart1 = [ - const Point(1.0, 10.0), - const Point(2.0, 20.0), - const Point(3.0, 30.0), - ]; - final chart2 = [ - const Point(2.0, 5.0), - const Point(3.0, 15.0), - const Point(4.0, 25.0), - ]; - - final result = - Charts.merge([chart1, chart2], mergeType: MergeType.fullOuterJoin); - - expect(result, [ - const Point(1.0, 10.0), - const Point(2.0, 25.0), - const Point(3.0, 45.0), - const Point(4.0, 25.0), - ]); - }); - - test('merge with leftJoin', () { - final chart1 = [ - const Point(1.0, 10.0), - const Point(2.0, 20.0), - const Point(3.0, 30.0), - ]; - final chart2 = [ - const Point(1.5, 5.0), - const Point(2.5, 15.0), - const Point(3.5, 25.0), - ]; - - final result = - Charts.merge([chart1, chart2], mergeType: MergeType.leftJoin); - - expect(result, [ - const Point(1.0, 10.0), - const Point(2.0, 25.0), - const Point(3.0, 45.0), - ]); - }); - - test('merge with empty charts', () { - final chart1 = [const Point(1.0, 10.0), const Point(2.0, 20.0)]; - final chart2 = >[]; - - final result = Charts.merge([chart1, chart2]); - - expect(result, chart1); - }); - - test('interpolate', () { - final chart = [const Point(1.0, 10.0), const Point(5.0, 50.0)]; - - final result = Charts.interpolate(chart, 5); - - expect(result.length, 5); - expect(result.first, chart.first); - expect(result.last, chart.last); - expect(result[2], const Point(3.0, 30.0)); - }); - - test('interpolate with target length less than original length', () { - final chart = [ - const Point(1.0, 10.0), - const Point(2.0, 20.0), - const Point(3.0, 30.0), - ]; - - final result = Charts.interpolate(chart, 2); - - expect(result, chart); - }); - }); - - group('ChartExtension', () { - test('percentageIncrease with positive increase', () { - final chart = [const Point(1.0, 100.0), const Point(2.0, 150.0)]; - - expect(chart.percentageIncrease, 50.0); - }); - - test('percentageIncrease with negative increase', () { - final chart = [const Point(1.0, 100.0), const Point(2.0, 75.0)]; - - expect(chart.percentageIncrease, -25.0); - }); - - test('percentageIncrease with no change', () { - final chart = [const Point(1.0, 100.0), const Point(2.0, 100.0)]; - - expect(chart.percentageIncrease, 0.0); - }); - - test('percentageIncrease with initial value of zero', () { - final chart = [const Point(1.0, 0.0), const Point(2.0, 100.0)]; - - expect(chart.percentageIncrease, double.infinity); - }); - - test('percentageIncrease with less than two points', () { - final chart = [const Point(1.0, 100.0)]; - - expect(chart.percentageIncrease, 0.0); - }); - }); - - group('Left join merge tests', () { - test('Basic merge scenario', () { - final baseChart = >[ - const Point(0, 10), - const Point(1, 20), - const Point(2, 30), - ]; - final chartToMerge = >[ - const Point(0, 1), - const Point(1, 2), - const Point(2, 3), - ]; - final expected = >[ - const Point(0, 11), - const Point(1, 22), - const Point(2, 33), - ]; - expect( - Charts.merge( - [baseChart, chartToMerge], - mergeType: MergeType.leftJoin, - ), - equals(expected), - ); - }); - - test('Merge with different x values', () { - final baseChart = >[ - const Point(0, 10), - const Point(2, 20), - const Point(4, 30), - ]; - final chartToMerge = >[ - const Point(1, 1), - const Point(3, 2), - const Point(5, 3), - ]; - final expected = >[ - const Point(0, 10), - const Point(2, 21), - const Point(4, 32), - ]; - expect( - Charts.merge( - [baseChart, chartToMerge], - mergeType: MergeType.leftJoin, - ), - equals(expected), - ); - }); - - test('Merge with empty chartToMerge', () { - final baseChart = >[ - const Point(0, 10), - const Point(1, 20), - const Point(2, 30), - ]; - final chartToMerge = >[]; - expect( - Charts.merge( - [baseChart, chartToMerge], - mergeType: MergeType.leftJoin, - ), - equals(baseChart), - ); - }); - - test('Merge with empty baseChart', () { - final baseChart = >[]; - final chartToMerge = >[ - const Point(0, 1), - const Point(1, 2), - const Point(2, 3), - ]; - expect( - Charts.merge( - [baseChart, chartToMerge], - mergeType: MergeType.leftJoin, - ), - isEmpty, - ); - }); - - test('Merge with negative values', () { - final baseChart = >[ - const Point(0, -10), - const Point(1, -20), - const Point(2, -30), - ]; - final chartToMerge = >[ - const Point(0, -1), - const Point(1, -2), - const Point(2, -3), - ]; - final expected = >[ - const Point(0, -11), - const Point(1, -22), - const Point(2, -33), - ]; - expect( - Charts.merge( - [baseChart, chartToMerge], - mergeType: MergeType.leftJoin, - ), - equals(expected), - ); - }); - - test('Merge with chartToMerge having more points', () { - final baseChart = >[ - const Point(0, 10), - const Point(2, 20), - ]; - final chartToMerge = >[ - const Point(0, 1), - const Point(1, 2), - const Point(2, 3), - const Point(3, 4), - ]; - final expected = >[ - const Point(0, 11), - const Point(2, 23), - ]; - expect( - Charts.merge( - [baseChart, chartToMerge], - mergeType: MergeType.leftJoin, - ), - equals(expected), - ); - }); - - test('Merge with chartToMerge having fewer points', () { - final baseChart = >[ - const Point(0, 10), - const Point(1, 20), - const Point(2, 30), - const Point(3, 40), - ]; - final chartToMerge = >[ - const Point(0, 1), - const Point(2, 3), - ]; - final expected = >[ - const Point(0, 11), - const Point(1, 21), - const Point(2, 33), - const Point(3, 43), - ]; - expect( - Charts.merge( - [baseChart, chartToMerge], - mergeType: MergeType.leftJoin, - ), - equals(expected), - ); - }); - - test('Merge with non-overlapping x ranges', () { - final baseChart = >[ - const Point(0, 10), - const Point(1, 20), - const Point(2, 30), - ]; - final chartToMerge = >[ - const Point(3, 1), - const Point(4, 2), - const Point(5, 3), - ]; - expect( - Charts.merge( - [baseChart, chartToMerge], - mergeType: MergeType.leftJoin, - ), - equals(baseChart), - ); - }); - - test('Merge with partially overlapping x ranges', () { - final baseChart = >[ - const Point(0, 10), - const Point(1, 20), - const Point(2, 30), - const Point(3, 40), - ]; - final chartToMerge = >[ - const Point(2, 1), - const Point(3, 2), - const Point(4, 3), - ]; - final expected = >[ - const Point(0, 10), - const Point(1, 20), - const Point(2, 31), - const Point(3, 42), - ]; - expect( - Charts.merge( - [baseChart, chartToMerge], - mergeType: MergeType.leftJoin, - ), - equals(expected), - ); - }); - - test('Merge with decimal x values', () { - final baseChart = >[ - const Point(0.5, 10), - const Point(1.5, 20), - const Point(2.5, 30), - ]; - final chartToMerge = >[ - const Point(0.7, 1), - const Point(1.7, 2), - const Point(2.7, 3), - ]; - final expected = >[ - const Point(0.5, 10), - const Point(1.5, 21), - const Point(2.5, 32), - ]; - expect( - Charts.merge( - [baseChart, chartToMerge], - mergeType: MergeType.leftJoin, - ), - equals(expected), - ); - }); - }); -} diff --git a/test_units/tests/cex_market_data/generate_demo_data_test.dart b/test_units/tests/cex_market_data/generate_demo_data_test.dart deleted file mode 100644 index 386692f8ad..0000000000 --- a/test_units/tests/cex_market_data/generate_demo_data_test.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; -import 'package:web_dex/bloc/cex_market_data/mockup/generate_demo_data.dart'; -import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; - -import 'mocks/mock_binance_provider.dart'; - -void main() { - testGenerateDemoData(); -} - -void testGenerateDemoData() { - late DemoDataGenerator generator; - late CexRepository cexRepository; - - setUp(() async { - // TODO: Replace with a mock repository - cexRepository = BinanceRepository( - binanceProvider: const MockBinanceProvider(), - ); - // Pre-fetch & cache the coins list to avoid making multiple requests - await cexRepository.getCoinList(); - - generator = DemoDataGenerator( - cexRepository, - ); - }); - - group( - 'DemoDataGenerator with live BinanceAPI repository', - () { - test('generateTransactions returns correct number of transactions', - () async { - final transactions = - await generator.generateTransactions('BTC', PerformanceMode.good); - expect( - transactions.length, - closeTo(generator.transactionsPerMode[PerformanceMode.good] ?? 0, 4), - ); - }); - - test('generateTransactions returns empty list for invalid coin', - () async { - final transactions = await generator.generateTransactions( - 'INVALID_COIN', - PerformanceMode.good, - ); - expect(transactions, isEmpty); - }); - - test('generateTransactions respects performance mode', () async { - final goodTransactions = - await generator.generateTransactions('BTC', PerformanceMode.good); - final badTransactions = await generator.generateTransactions( - 'BTC', - PerformanceMode.veryBad, - ); - - double goodBalance = generator.initialBalance; - double badBalance = generator.initialBalance; - - for (final tx in goodTransactions) { - goodBalance += tx.balanceChanges.netChange.toDouble(); - } - - for (final tx in badTransactions) { - badBalance += tx.balanceChanges.netChange.toDouble(); - } - - expect(goodBalance, greaterThan(badBalance)); - }); - - test('generateTransactions produces valid transaction objects', () async { - final transactions = await generator.generateTransactions( - 'BTC', - PerformanceMode.mediocre, - ); - - for (final tx in transactions) { - expect(tx.assetId.id, equals('BTC')); - expect(tx.confirmations, inInclusiveRange(1, 3)); - expect(tx.from, isNotEmpty); - expect(tx.to, isNotEmpty); - expect(tx.internalId, isNotEmpty); - expect(tx.txHash, isNotEmpty); - } - }); - - test('fetchOhlcData returns data for all supported coin pairs', () async { - final ohlcData = await generator.fetchOhlcData(); - final supportedCoins = await cexRepository.getCoinList(); - - for (final coinPair in generator.coinPairs) { - final supportedCoin = supportedCoins.where( - (coin) => coin.id == coinPair.baseCoinTicker, - ); - if (supportedCoin.isEmpty) { - expect(ohlcData[coinPair], isNull); - continue; - } - - expect(ohlcData[coinPair], isNotNull); - expect(ohlcData[coinPair]!, isNotEmpty); - } - }); - }, - skip: true, - ); -} diff --git a/test_units/tests/cex_market_data/mocks/mock_binance_provider.dart b/test_units/tests/cex_market_data/mocks/mock_binance_provider.dart deleted file mode 100644 index 6e41b31646..0000000000 --- a/test_units/tests/cex_market_data/mocks/mock_binance_provider.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; -import 'package:komodo_cex_market_data/src/binance/models/binance_exchange_info_reduced.dart'; - -/// A mock class for testing a failing Binance provider -/// - all IPs blocked, or network issues -class MockBinanceProvider implements IBinanceProvider { - const MockBinanceProvider(); - - @override - Future fetchExchangeInfo({String? baseUrl}) { - throw UnsupportedError( - 'Full binance exchange info response is not supported', - ); - } - - @override - Future fetchExchangeInfoReduced({ - String? baseUrl, - }) { - return Future.value( - BinanceExchangeInfoResponseReduced( - timezone: 'utc+0', - serverTime: DateTime.now().millisecondsSinceEpoch, - symbols: [ - SymbolReduced( - baseAsset: 'BTC', - quoteAsset: 'USDT', - baseAssetPrecision: 8, - quotePrecision: 8, - status: 'TRADING', - isSpotTradingAllowed: true, - quoteAssetPrecision: 8, - symbol: 'BTCUSDT', - ), - SymbolReduced( - baseAsset: 'ETH', - quoteAsset: 'USDT', - baseAssetPrecision: 8, - quotePrecision: 8, - status: 'TRADING', - isSpotTradingAllowed: true, - quoteAssetPrecision: 8, - symbol: 'ETHUSDT', - ), - SymbolReduced( - baseAsset: 'KMD', - quoteAsset: 'USDT', - baseAssetPrecision: 8, - quotePrecision: 8, - status: 'TRADING', - isSpotTradingAllowed: true, - quoteAssetPrecision: 8, - symbol: 'KMDUSDT', - ), - SymbolReduced( - baseAsset: 'LTC', - quoteAsset: 'USDT', - baseAssetPrecision: 8, - quotePrecision: 8, - status: 'TRADING', - isSpotTradingAllowed: true, - quoteAssetPrecision: 8, - symbol: 'LTCUSDT', - ), - ], - ), - ); - } - - @override - Future fetchKlines( - String symbol, - String interval, { - int? startUnixTimestampMilliseconds, - int? endUnixTimestampMilliseconds, - int? limit, - String? baseUrl, - }) { - List ohlc = [ - const Ohlc( - openTime: 1708646400000, - open: 50740.50, - high: 50740.50, - low: 50740.50, - close: 50740.50, - closeTime: 1708646400000, - ), - const Ohlc( - openTime: 1708984800000, - open: 50740.50, - high: 50740.50, - low: 50740.50, - close: 50740.50, - closeTime: 1708984800000, - ), - const Ohlc( - openTime: 1714435200000, - open: 60666.60, - high: 60666.60, - low: 60666.60, - close: 60666.60, - closeTime: 1714435200000, - ), - Ohlc( - openTime: DateTime.now() - .subtract(const Duration(days: 1)) - .millisecondsSinceEpoch, - open: 60666.60, - high: 60666.60, - low: 60666.60, - close: 60666.60, - closeTime: DateTime.now() - .subtract(const Duration(days: 1)) - .millisecondsSinceEpoch, - ), - Ohlc( - openTime: DateTime.now().millisecondsSinceEpoch, - open: 60666.60, - high: 60666.60, - low: 60666.60, - close: 60666.60, - closeTime: DateTime.now().millisecondsSinceEpoch, - ), - Ohlc( - openTime: - DateTime.now().add(const Duration(days: 1)).millisecondsSinceEpoch, - open: 60666.60, - high: 60666.60, - low: 60666.60, - close: 60666.60, - closeTime: - DateTime.now().add(const Duration(days: 1)).millisecondsSinceEpoch, - ), - ]; - - if (startUnixTimestampMilliseconds != null) { - ohlc = ohlc - .where((ohlc) => ohlc.closeTime >= startUnixTimestampMilliseconds) - .toList(); - } - - if (endUnixTimestampMilliseconds != null) { - ohlc = ohlc - .where((ohlc) => ohlc.closeTime <= endUnixTimestampMilliseconds) - .toList(); - } - - if (limit != null && limit > 0) { - ohlc = ohlc.take(limit).toList(); - } - - ohlc.sort((a, b) => a.closeTime.compareTo(b.closeTime)); - - return Future.value( - CoinOhlc( - ohlc: ohlc, - ), - ); - } -} diff --git a/test_units/tests/cex_market_data/mocks/mock_failing_binance_provider.dart b/test_units/tests/cex_market_data/mocks/mock_failing_binance_provider.dart deleted file mode 100644 index 397a7c6e77..0000000000 --- a/test_units/tests/cex_market_data/mocks/mock_failing_binance_provider.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; -import 'package:komodo_cex_market_data/src/binance/models/binance_exchange_info_reduced.dart'; - -/// A mock class for testing a failing Binance provider -/// - all IPs blocked, or network issues -class MockFailingBinanceProvider implements IBinanceProvider { - const MockFailingBinanceProvider(); - - @override - Future fetchExchangeInfo({String? baseUrl}) { - throw UnsupportedError('Intentional exception'); - } - - @override - Future fetchExchangeInfoReduced({ - String? baseUrl, - }) { - throw UnsupportedError('Intentional exception'); - } - - @override - Future fetchKlines( - String symbol, - String interval, { - int? startUnixTimestampMilliseconds, - int? endUnixTimestampMilliseconds, - int? limit, - String? baseUrl, - }) { - throw UnsupportedError('Intentional exception'); - } -} diff --git a/test_units/tests/cex_market_data/profit_loss_repository_test.dart b/test_units/tests/cex_market_data/profit_loss_repository_test.dart deleted file mode 100644 index 0a4097bea8..0000000000 --- a/test_units/tests/cex_market_data/profit_loss_repository_test.dart +++ /dev/null @@ -1,253 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; -import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_calculator.dart'; - -import 'mocks/mock_binance_provider.dart'; -import 'transaction_generation.dart'; - -void main() { - testProfitLossRepository(); -} - -void testProfitLossRepository() { - testNetProfitLossRepository(); - testRealisedProfitLossRepository(); -} - -void testNetProfitLossRepository() { - group('getProfitFromTransactions', () { - late ProfitLossCalculator profitLossRepository; - late CexRepository cexRepository; - late double currentBtcPrice; - - setUp(() async { - cexRepository = BinanceRepository( - binanceProvider: const MockBinanceProvider(), - ); - // Pre-fetch & cache the coins list to avoid making multiple requests - await cexRepository.getCoinList(); - profitLossRepository = ProfitLossCalculator( - cexRepository, - ); - final currentDate = DateTime.now(); - final currentDateMidnight = DateTime( - currentDate.year, - currentDate.month, - currentDate.day, - ); - currentBtcPrice = await cexRepository.getCoinFiatPrice( - 'BTC', - priceDate: currentDateMidnight, - ); - }); - - test('should return empty list when transactions are empty', () async { - final result = await profitLossRepository.getProfitFromTransactions( - [], - coinId: 'BTC', - fiatCoinId: 'USD', - ); - - expect(result, isEmpty); - }); - - test('return the unrealised profit/loss for a single transaction', - () async { - final transactions = [createBuyTransaction(1.0)]; - - final result = await profitLossRepository.getProfitFromTransactions( - transactions, - coinId: 'BTC', - fiatCoinId: 'USD', - ); - - final expectedProfitLoss = (currentBtcPrice * 1.0) - (50740.50 * 1.0); - - expect(result.length, 1); - expect(result[0].profitLoss, closeTo(expectedProfitLoss, 1000)); - }); - - test('return profit/loss for a 50% sale', () async { - final transactions = [ - createBuyTransaction(1.0), - createSellTransaction(0.5), - ]; - - final result = await profitLossRepository.getProfitFromTransactions( - transactions, - coinId: 'BTC', - fiatCoinId: 'USD', - ); - final expectedProfitLossT1 = (currentBtcPrice * 1.0) - (50740.50 * 1.0); - - const t2CostBasis = 50740.50 * 0.5; - const t2SaleProceeds = 60666.60 * 0.5; - const t2RealizedProfitLoss = t2SaleProceeds - t2CostBasis; - final t2UnrealisedProfitLoss = (currentBtcPrice * 0.5) - t2CostBasis; - final expectedTotalProfitLoss = - t2UnrealisedProfitLoss + t2RealizedProfitLoss; - - expect(result.length, 2); - expect( - result[0].profitLoss, - closeTo(expectedProfitLossT1, 1000), - ); - expect( - result[1].profitLoss, - closeTo(expectedTotalProfitLoss, 1000), - ); - }); - - test('should skip transactions with zero amount', () async { - final transactions = [ - createBuyTransaction(1.0), - createBuyTransaction(0.0, timeStamp: 1708984800), - createSellTransaction(0.5), - ]; - - final result = await profitLossRepository.getProfitFromTransactions( - transactions, - coinId: 'BTC', - fiatCoinId: 'USD', - ); - - final expectedProfitLossT1 = (currentBtcPrice * 1.0) - (50740.50 * 1.0); - - const t3LeftoverBalance = 0.5; - const t3CostBasis = 50740.50 * t3LeftoverBalance; - const t3SaleProceeds = 60666.60 * 0.5; - const t3RealizedProfitLoss = t3SaleProceeds - t3CostBasis; - final t3CurrentBalancePrice = currentBtcPrice * t3LeftoverBalance; - final t3UnrealisedProfitLoss = t3CurrentBalancePrice - t3CostBasis; - final expectedTotalProfitLoss = - t3UnrealisedProfitLoss + t3RealizedProfitLoss; - - expect(result.length, 2); - expect( - result[0].profitLoss, - closeTo(expectedProfitLossT1, 1000), - ); - expect( - result[1].profitLoss, - closeTo(expectedTotalProfitLoss, 1000), - ); - }); - - test('should zero same day transfer of balance without fees', () async { - final transactions = [ - createBuyTransaction(1.0), - createSellTransaction(1.0, timeStamp: 1708646500), - ]; - - final result = await profitLossRepository.getProfitFromTransactions( - transactions, - coinId: 'BTC', - fiatCoinId: 'USD', - ); - - expect(result.length, 2); - expect( - result[1].profitLoss, - 0.0, - ); // No profit/loss as price is the same - }); - }); -} - -void testRealisedProfitLossRepository() { - group('getProfitFromTransactions', () { - late ProfitLossCalculator profitLossRepository; - late CexRepository cexRepository; - - setUp(() async { - cexRepository = BinanceRepository( - binanceProvider: const MockBinanceProvider(), - ); - profitLossRepository = RealisedProfitLossCalculator( - cexRepository, - ); - await cexRepository.getCoinList(); - }); - - test('return the unrealised profit/loss for a single transaction', - () async { - final transactions = [createBuyTransaction(1.0)]; - - final result = await profitLossRepository.getProfitFromTransactions( - transactions, - coinId: 'BTC', - fiatCoinId: 'USD', - ); - - expect(result.length, 1); - expect( - result[0].profitLoss, - 0.0, - ); - }); - - test('return profit/loss for a 50% sale', () async { - final transactions = [ - createBuyTransaction(1.0), - createSellTransaction(0.5), - ]; - - final result = await profitLossRepository.getProfitFromTransactions( - transactions, - coinId: 'BTC', - fiatCoinId: 'USD', - ); - - const t2CostBasis = 50740.50 * 0.5; - const t2SaleProceeds = 60666.60 * 0.5; - const expectedRealizedProfitLoss = t2SaleProceeds - t2CostBasis; - - expect(result.length, 2); - expect( - result[1].profitLoss, - closeTo(expectedRealizedProfitLoss, 1000), - ); - }); - - test('should skip transactions with zero amount', () async { - final transactions = [ - createBuyTransaction(1.0), - createBuyTransaction(0.0, timeStamp: 1708984800), - createSellTransaction(0.5), - ]; - - final result = await profitLossRepository.getProfitFromTransactions( - transactions, - coinId: 'BTC', - fiatCoinId: 'USD', - ); - - const t3LeftoverBalance = 0.5; - const t3CostBasis = 50740.50 * t3LeftoverBalance; - const t3SaleProceeds = 60666.60 * 0.5; - const t3RealizedProfitLoss = t3SaleProceeds - t3CostBasis; - - expect(result.length, 2); - expect( - result[1].profitLoss, - closeTo(t3RealizedProfitLoss, 1000), - ); - }); - - test('should zero same day transfer of balance without fees', () async { - final transactions = [ - createBuyTransaction(1.0), - createSellTransaction(1.0, timeStamp: 1708646500), - ]; - - final result = await profitLossRepository.getProfitFromTransactions( - transactions, - coinId: 'BTC', - fiatCoinId: 'USD', - ); - - expect(result.length, 2); - expect(result[1].profitLoss, 0.0); - }); - }); -} diff --git a/test_units/tests/cex_market_data/transaction_generation.dart b/test_units/tests/cex_market_data/transaction_generation.dart deleted file mode 100644 index 6af0b2f51f..0000000000 --- a/test_units/tests/cex_market_data/transaction_generation.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:decimal/decimal.dart'; -import 'package:komodo_defi_types/komodo_defi_types.dart'; - -Transaction createBuyTransaction( - double balanceChange, { - int timeStamp = 1708646400, // $50,740.50 usd -}) { - final String value = balanceChange.toString(); - return Transaction( - id: '0', - blockHeight: 10000, - assetId: AssetId( - id: 'BTC', - name: 'Bitcoin', - symbol: AssetSymbol(assetConfigId: 'BTC'), - chainId: AssetChainId(chainId: 9), - derivationPath: '', - subClass: CoinSubClass.utxo, - ), - confirmations: 6, - balanceChanges: BalanceChanges( - netChange: Decimal.parse(value), - receivedByMe: Decimal.parse(value), - spentByMe: Decimal.zero, - totalAmount: Decimal.parse(value), - ), - from: const ['1ABC...'], - internalId: 'internal1', - timestamp: DateTime.fromMillisecondsSinceEpoch(timeStamp * 1000), - to: const ['1XYZ...'], - txHash: 'hash1', - memo: 'Buy 1 BTC', - ); -} - -Transaction createSellTransaction( - double balanceChange, { - int timeStamp = 1714435200, // $60,666.60 usd -}) { - double adjustedBalanceChange = balanceChange; - if (!adjustedBalanceChange.isNegative) { - adjustedBalanceChange = -adjustedBalanceChange; - } - final String value = adjustedBalanceChange.toString(); - - return Transaction( - id: '0', - blockHeight: 100200, - assetId: AssetId( - id: 'BTC', - name: 'Bitcoin', - symbol: AssetSymbol(assetConfigId: 'BTC'), - chainId: AssetChainId(chainId: 9), - derivationPath: '', - subClass: CoinSubClass.utxo, - ), - confirmations: 6, - balanceChanges: BalanceChanges( - netChange: Decimal.parse(value), - receivedByMe: Decimal.zero, - spentByMe: Decimal.parse(adjustedBalanceChange.abs().toString()), - totalAmount: Decimal.parse(value), - ), - from: const ['1ABC...'], - internalId: 'internal3', - timestamp: DateTime.fromMillisecondsSinceEpoch(timeStamp * 1000), - to: const ['1GHI...'], - txHash: 'hash3', - memo: 'Sell 0.5 BTC', - ); -} diff --git a/test_units/tests/helpers/total_24_change_test.dart b/test_units/tests/helpers/total_24_change_test.dart index 99b6d87c75..f586b29e2c 100644 --- a/test_units/tests/helpers/total_24_change_test.dart +++ b/test_units/tests/helpers/total_24_change_test.dart @@ -1,4 +1,6 @@ // TODO: revisit or migrate to the SDK, since it mostly deals with the sdk +import 'package:test/test.dart'; + void testGetTotal24Change() { // late final KomodoDefiSdk sdk; @@ -6,162 +8,162 @@ void testGetTotal24Change() { // sdk = KomodoDefiSdk(); // }); - // test('getTotal24Change calculates total change', () { - // List coins = [ - // setCoin( - // balance: 1.0, - // usdPrice: 10, - // change24h: 0.05, - // ), - // ]; - - // double? result = getTotal24Change(coins, sdk); - // expect(result, equals(0.05)); - - // // Now total USD balance is 10*3.0 + 10*1.0 = 40 - // // -0.1*3.0 + 0.05*1.0 = -0.25 - // coins.add( - // setCoin( - // balance: 3.0, - // usdPrice: 10, - // change24h: -0.1, - // ), - // ); - - // double? result2 = getTotal24Change(coins, sdk); - // // -0.06250000000000001 if use double - // expect(result2, equals(-0.0625)); - // }); + test('getTotal24Change calculates total change', () { + // List coins = [ + // setCoin( + // balance: 1.0, + // usdPrice: 10, + // change24h: 0.05, + // ), + // ]; - // test('getTotal24Change calculates total change', () { - // List coins = [ - // setCoin( - // balance: 1.0, - // usdPrice: 1, - // change24h: 0.1, - // ), - // setCoin( - // balance: 1.0, - // usdPrice: 1, - // change24h: -0.1, - // ), - // ]; - - // double? result = getTotal24Change(coins, sdk); - // expect(result, equals(0.0)); - - // // Now total USD balance is 1.0 - // // 45.235*1.0 + -45.23*1.0 = 0.005 USD - // // 0.005 / 2.0 = 0.0025 - // List coins2 = [ - // setCoin( - // balance: 1.0, - // usdPrice: 1, - // change24h: 45.235, - // ), - // setCoin( - // balance: 1.0, - // usdPrice: 1, - // change24h: -45.23, - // ), - // ]; - - // double? result2 = getTotal24Change(coins2, sdk); - // expect(result2, equals(0.0025)); - // }); + // double? result = getTotal24Change(coins, sdk); + // expect(result, equals(0.05)); - // test('getTotal24Change and a huge input', () { - // List coins = [ - // setCoin( - // balance: 1.0, - // usdPrice: 10, - // change24h: 0.05, - // coinAbbr: 'KMD', - // ), - // setCoin( - // balance: 2.0, - // usdPrice: 10, - // change24h: 0.1, - // coinAbbr: 'BTC', - // ), - // setCoin( - // balance: 2.0, - // usdPrice: 10, - // change24h: 0.1, - // coinAbbr: 'LTC', - // ), - // setCoin( - // balance: 5.0, - // usdPrice: 12, - // change24h: -34.0, - // coinAbbr: 'ETH', - // ), - // setCoin( - // balance: 4.0, - // usdPrice: 12, - // change24h: 34.0, - // coinAbbr: 'XMR', - // ), - // setCoin( - // balance: 3.0, - // usdPrice: 12, - // change24h: 0.0, - // coinAbbr: 'XRP', - // ), - // setCoin( - // balance: 2.0, - // usdPrice: 12, - // change24h: 0.0, - // coinAbbr: 'DASH', - // ), - // setCoin( - // balance: 1.0, - // usdPrice: 12, - // change24h: 0.0, - // coinAbbr: 'ZEC', - // ), - // ]; - // double? result = getTotal24Change(coins, sdk); - // // -1.7543478260869563 if use double - // expect(result, equals(-1.7543478260869565)); - // }); + // // Now total USD balance is 10*3.0 + 10*1.0 = 40 + // // -0.1*3.0 + 0.05*1.0 = -0.25 + // coins.add( + // setCoin( + // balance: 3.0, + // usdPrice: 10, + // change24h: -0.1, + // ), + // ); - // test('getTotal24Change returns null for empty or null input', () { - // double? resultEmpty = getTotal24Change([], sdk); - // double? resultNull = getTotal24Change(null, sdk); - - // expect(resultEmpty, isNull); - // expect(resultNull, isNull); - - // List coins = [ - // setCoin( - // balance: 0.0, - // usdPrice: 10, - // change24h: 0.05, - // ), - // setCoin( - // balance: 0.0, - // usdPrice: 40, - // change24h: 0.05, - // ), - // ]; - // double? resultZeroBalance = getTotal24Change(coins, sdk); - // expect(resultZeroBalance, isNull); - - // List coins2 = [ - // setCoin( - // balance: 10.0, - // usdPrice: 10, - // change24h: 0, - // ), - // setCoin( - // balance: 10.0, - // usdPrice: 40, - // change24h: 0, - // ), - // ]; - - // double? resultNoChangeFor24h = getTotal24Change(coins2, sdk); - // expect(resultNoChangeFor24h, 0); - // }); + // double? result2 = getTotal24Change(coins, sdk); + // // -0.06250000000000001 if use double + // expect(result2, equals(-0.0625)); + // }); + + // test('getTotal24Change calculates total change', () { + // List coins = [ + // setCoin( + // balance: 1.0, + // usdPrice: 1, + // change24h: 0.1, + // ), + // setCoin( + // balance: 1.0, + // usdPrice: 1, + // change24h: -0.1, + // ), + // ]; + + // double? result = getTotal24Change(coins, sdk); + // expect(result, equals(0.0)); + + // // Now total USD balance is 1.0 + // // 45.235*1.0 + -45.23*1.0 = 0.005 USD + // // 0.005 / 2.0 = 0.0025 + // List coins2 = [ + // setCoin( + // balance: 1.0, + // usdPrice: 1, + // change24h: 45.235, + // ), + // setCoin( + // balance: 1.0, + // usdPrice: 1, + // change24h: -45.23, + // ), + // ]; + + // double? result2 = getTotal24Change(coins2, sdk); + // expect(result2, equals(0.0025)); + // }); + + // test('getTotal24Change and a huge input', () { + // List coins = [ + // setCoin( + // balance: 1.0, + // usdPrice: 10, + // change24h: 0.05, + // coinAbbr: 'KMD', + // ), + // setCoin( + // balance: 2.0, + // usdPrice: 10, + // change24h: 0.1, + // coinAbbr: 'BTC', + // ), + // setCoin( + // balance: 2.0, + // usdPrice: 10, + // change24h: 0.1, + // coinAbbr: 'LTC', + // ), + // setCoin( + // balance: 5.0, + // usdPrice: 12, + // change24h: -34.0, + // coinAbbr: 'ETH', + // ), + // setCoin( + // balance: 4.0, + // usdPrice: 12, + // change24h: 34.0, + // coinAbbr: 'XMR', + // ), + // setCoin( + // balance: 3.0, + // usdPrice: 12, + // change24h: 0.0, + // coinAbbr: 'XRP', + // ), + // setCoin( + // balance: 2.0, + // usdPrice: 12, + // change24h: 0.0, + // coinAbbr: 'DASH', + // ), + // setCoin( + // balance: 1.0, + // usdPrice: 12, + // change24h: 0.0, + // coinAbbr: 'ZEC', + // ), + // ]; + // double? result = getTotal24Change(coins, sdk); + // // -1.7543478260869563 if use double + // expect(result, equals(-1.7543478260869565)); + // }); + + // test('getTotal24Change returns null for empty or null input', () { + // double? resultEmpty = getTotal24Change([], sdk); + // double? resultNull = getTotal24Change(null, sdk); + + // expect(resultEmpty, isNull); + // expect(resultNull, isNull); + + // List coins = [ + // setCoin( + // balance: 0.0, + // usdPrice: 10, + // change24h: 0.05, + // ), + // setCoin( + // balance: 0.0, + // usdPrice: 40, + // change24h: 0.05, + // ), + // ]; + // double? resultZeroBalance = getTotal24Change(coins, sdk); + // expect(resultZeroBalance, isNull); + + // List coins2 = [ + // setCoin( + // balance: 10.0, + // usdPrice: 10, + // change24h: 0, + // ), + // setCoin( + // balance: 10.0, + // usdPrice: 40, + // change24h: 0, + // ), + // ]; + + // double? resultNoChangeFor24h = getTotal24Change(coins2, sdk); + // expect(resultNoChangeFor24h, 0); + }, skip: true); } diff --git a/test_units/tests/helpers/total_fee_test.dart b/test_units/tests/helpers/total_fee_test.dart index 5479ab1ec8..1fe53a7b69 100644 --- a/test_units/tests/helpers/total_fee_test.dart +++ b/test_units/tests/helpers/total_fee_test.dart @@ -1,88 +1,160 @@ -import 'package:rational/rational.dart'; +// import 'package:komodo_defi_sdk/komodo_defi_sdk.dart' show KomodoDefiSdk; +// import 'package:rational/rational.dart'; import 'package:test/test.dart'; -import 'package:web_dex/model/trade_preimage_extended_fee_info.dart'; -import 'package:web_dex/views/dex/dex_helpers.dart'; +// import 'package:web_dex/model/trade_preimage_extended_fee_info.dart'; +// import 'package:web_dex/views/dex/dex_helpers.dart'; -import '../utils/test_util.dart'; +// import '../utils/test_util.dart'; +// TODO: revisit or migrate these tests to the SDK package void testGetTotalFee() { test('Total fee positive test', () { - final List info = [ - TradePreimageExtendedFeeInfo( - coin: 'KMD', - amount: '0.00000001', - amountRational: Rational.parse('0.00000001'), - paidFromTradingVol: false, - ), - TradePreimageExtendedFeeInfo( - coin: 'BTC', - amount: '0.00000002', - amountRational: Rational.parse('0.00000002'), - paidFromTradingVol: false, - ), - TradePreimageExtendedFeeInfo( - coin: 'LTC', - amount: '0.00000003', - amountRational: Rational.parse('0.00000003'), - paidFromTradingVol: false, - ), - ]; - final String nbsp = String.fromCharCode(0x00A0); - expect( - getTotalFee(null, (abbr) => setCoin(coinAbbr: abbr, usdPrice: 12.12)), - '\$0.00'); - expect( - getTotalFee(info, (abbr) => setCoin(coinAbbr: abbr, usdPrice: 10.00)), - '\$0.0000006'); - expect(getTotalFee(info, (abbr) => setCoin(coinAbbr: abbr, usdPrice: 0.10)), - '\$0.000000006'); - expect(getTotalFee(info, (abbr) => setCoin(coinAbbr: abbr, usdPrice: 0.0)), - '0.00000001${nbsp}KMD +${nbsp}0.00000002${nbsp}BTC +${nbsp}0.00000003${nbsp}LTC'); - }); + // final List info = [ + // TradePreimageExtendedFeeInfo( + // coin: 'KMD', + // amount: '0.00000001', + // amountRational: Rational.parse('0.00000001'), + // paidFromTradingVol: false, + // ), + // TradePreimageExtendedFeeInfo( + // coin: 'BTC', + // amount: '0.00000002', + // amountRational: Rational.parse('0.00000002'), + // paidFromTradingVol: false, + // ), + // TradePreimageExtendedFeeInfo( + // coin: 'LTC', + // amount: '0.00000003', + // amountRational: Rational.parse('0.00000003'), + // paidFromTradingVol: false, + // ), + // ]; + // final String nbsp = String.fromCharCode(0x00A0); + // expect( + // getTotalFee( + // null, + // (abbr) => setCoin(coinAbbr: abbr, usdPrice: 12.12), + // mockSdk, + // ), + // '\$0.00', + // ); + // expect( + // getTotalFee( + // info, + // (abbr) => setCoin(coinAbbr: abbr, usdPrice: 10.00), + // mockSdk, + // ), + // '\$0.0000006', + // ); + // expect( + // getTotalFee( + // info, + // (abbr) => setCoin(coinAbbr: abbr, usdPrice: 0.10), + // mockSdk, + // ), + // '\$0.000000006', + // ); + // expect( + // getTotalFee( + // info, + // (abbr) => setCoin(coinAbbr: abbr, usdPrice: 0.0), + // mockSdk, + // ), + // '0.00000001${nbsp}KMD +${nbsp}0.00000002${nbsp}BTC +${nbsp}0.00000003${nbsp}LTC', + // ); + // }); - test('Total fee edge cases', () { - final List info = [ - TradePreimageExtendedFeeInfo( - coin: 'KMD', - amount: '0.00000000000001', - amountRational: Rational.parse('0.00000000000001'), - paidFromTradingVol: false, - ), - ]; - final String nbsp = String.fromCharCode(0x00A0); - // PR: #1218, toStringAmount should fix unexpected results for formatAmt method - expect(getTotalFee(info, (abbr) => setCoin(coinAbbr: abbr, usdPrice: 1.0)), - '\$1e-14'); - expect( - getTotalFee( - info, (abbr) => setCoin(coinAbbr: abbr, usdPrice: 0.000000001)), - '\$1.00000000000e-23'); - expect( - getTotalFee( - info, (abbr) => setCoin(coinAbbr: abbr, usdPrice: 0.0000000000001)), - '\$1e-27'); - expect( - getTotalFee(info, (abbr) => setCoin(coinAbbr: abbr, usdPrice: 1e-30)), - '\$1.00000000000e-44'); - expect( - getTotalFee(info, (abbr) => setCoin(coinAbbr: abbr, usdPrice: 1e-60)), - '\$1e-74'); - expect(getTotalFee(info, (abbr) => setCoin(coinAbbr: abbr, usdPrice: 0)), - '1e-14${nbsp}KMD'); + // test('Total fee edge cases', () { + // final List info = [ + // TradePreimageExtendedFeeInfo( + // coin: 'KMD', + // amount: '0.00000000000001', + // amountRational: Rational.parse('0.00000000000001'), + // paidFromTradingVol: false, + // ), + // ]; + // final String nbsp = String.fromCharCode(0x00A0); + // // PR: #1218, toStringAmount should fix unexpected results for formatAmt method + // expect( + // getTotalFee( + // info, + // (abbr) => setCoin(coinAbbr: abbr, usdPrice: 1.0), + // mockSdk, + // ), + // '\$1e-14', + // ); + // expect( + // getTotalFee( + // info, + // (abbr) => setCoin(coinAbbr: abbr, usdPrice: 0.000000001), + // mockSdk, + // ), + // '\$1.00000000000e-23', + // ); + // expect( + // getTotalFee( + // info, + // (abbr) => setCoin(coinAbbr: abbr, usdPrice: 0.0000000000001), + // mockSdk, + // ), + // '\$1e-27', + // ); + // expect( + // getTotalFee( + // info, + // (abbr) => setCoin(coinAbbr: abbr, usdPrice: 1e-30), + // mockSdk, + // ), + // '\$1.00000000000e-44', + // ); + // expect( + // getTotalFee( + // info, + // (abbr) => setCoin(coinAbbr: abbr, usdPrice: 1e-60), + // mockSdk, + // ), + // '\$1e-74', + // ); + // expect( + // getTotalFee( + // info, + // (abbr) => setCoin(coinAbbr: abbr, usdPrice: 0), + // mockSdk, + // ), + // '1e-14${nbsp}KMD', + // ); - final List info2 = [ - TradePreimageExtendedFeeInfo( - coin: 'BTC', - amount: '123456789012345678901234567890123456789012345678901234567890', - amountRational: Rational.parse( - '123456789012345678901234567890123456789012345678901234567890'), - paidFromTradingVol: false, - ), - ]; - expect(getTotalFee(info2, (abbr) => setCoin(coinAbbr: abbr, usdPrice: 1.0)), - '\$1.23456789012e+59'); - expect( - getTotalFee(info2, (abbr) => setCoin(coinAbbr: abbr, usdPrice: 1e-59)), - '\$1.23'); - }); + // final List info2 = [ + // TradePreimageExtendedFeeInfo( + // coin: 'BTC', + // amount: '123456789012345678901234567890123456789012345678901234567890', + // amountRational: Rational.parse( + // '123456789012345678901234567890123456789012345678901234567890', + // ), + // paidFromTradingVol: false, + // ), + // ]; + // expect( + // getTotalFee( + // info2, + // (abbr) => setCoin(coinAbbr: abbr, usdPrice: 1.0), + // mockSdk, + // ), + // '\$1.23456789012e+59', + // ); + // expect( + // getTotalFee( + // info2, + // (abbr) => setCoin(coinAbbr: abbr, usdPrice: 1e-59), + // mockSdk, + // ), + // '\$1.23', + // ); + // Skipping due to mocking issue with the SDK - requires internal interfaces + // to be exposed to be mocked properly (e.g. MarketDataManager for the priceIfKnown method) + }, skip: true); +} + +void main() { + testGetTotalFee(); } diff --git a/test_units/tests/utils/convert_fract_rat_test.dart b/test_units/tests/utils/convert_fract_rat_test.dart index 4955892a10..76731755b7 100644 --- a/test_units/tests/utils/convert_fract_rat_test.dart +++ b/test_units/tests/utils/convert_fract_rat_test.dart @@ -34,4 +34,25 @@ void testRatToFracAndViseVersa() { Rational? result = fract2rat(invalidFract, false); expect(result, isNull); }); + + test('fract2rat handles very large integers without precision loss', () { + // 10^50 / 10^20 = 10^30 + 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 back = rat2fract(rat, false)!; + expect(back['numer'], numer); + expect(back['denom'], denom); + }); + + test('fract2rat correctly parses strings that would overflow double', () { + final numer = '340282366920938463463374607431768211457'; // > 2^128 + final denom = '1'; + final rat = fract2rat({'numer': numer, 'denom': denom}, false)!; + expect(rat.numerator, BigInt.parse(numer)); + expect(rat.denominator, BigInt.one); + }); } diff --git a/test_units/tests/utils/get_fiat_amount_tests.dart b/test_units/tests/utils/get_fiat_amount_tests.dart index 88ec119028..fbd9ee793a 100644 --- a/test_units/tests/utils/get_fiat_amount_tests.dart +++ b/test_units/tests/utils/get_fiat_amount_tests.dart @@ -1,48 +1,56 @@ +// import 'package:rational/rational.dart'; import 'package:rational/rational.dart'; import 'package:test/test.dart'; -import 'package:web_dex/shared/utils/balances_formatter.dart'; +import 'package:web_dex/shared/utils/balances_formatter.dart' + show getFiatAmount; -import 'test_util.dart'; +import 'test_util.dart' show setCoin; void testGetFiatAmount() { test('formatting double DEX amount tests:', () { expect(getFiatAmount(setCoin(usdPrice: 10.12), Rational.one), 10.12); expect( - getFiatAmount( - setCoin(usdPrice: 10.12), - Rational(BigInt.from(1), BigInt.from(10)), - ), - 1.012); + getFiatAmount( + setCoin(usdPrice: 10.12), + Rational(BigInt.from(1), BigInt.from(10)), + ), + 1.012, + ); expect( - getFiatAmount( - setCoin(usdPrice: null), - Rational(BigInt.from(1), BigInt.from(10)), - ), - 0.0); + getFiatAmount( + setCoin(usdPrice: null), + Rational(BigInt.from(1), BigInt.from(10)), + ), + 0.0, + ); expect( - getFiatAmount( - setCoin(usdPrice: 0), - Rational(BigInt.from(1), BigInt.from(10)), - ), - 0.0); + getFiatAmount( + setCoin(usdPrice: 0), + Rational(BigInt.from(1), BigInt.from(10)), + ), + 0.0, + ); expect( - getFiatAmount( - setCoin(usdPrice: 1e-7), - Rational(BigInt.from(1), BigInt.from(1e10)), - ), - 1e-17); + getFiatAmount( + setCoin(usdPrice: 1e-7), + Rational(BigInt.from(1), BigInt.from(1e10)), + ), + 1e-17, + ); expect( - getFiatAmount( - setCoin(usdPrice: 1.23e40), - Rational(BigInt.from(2), BigInt.from(1e50)), - ), - 2.46e-10); + getFiatAmount( + setCoin(usdPrice: 1.23e40), + Rational(BigInt.from(2), BigInt.from(1e50)), + ), + 2.46e-10, + ); // Amount of atoms in the universe is ~10^80 expect( - getFiatAmount( - setCoin(usdPrice: 1.2345e40), - Rational(BigInt.from(1e50), BigInt.from(1)), - ), - 1.2345e90); + getFiatAmount( + setCoin(usdPrice: 1.2345e40), + Rational(BigInt.from(1e50), BigInt.from(1)), + ), + 1.2345e90, + ); }); } diff --git a/test_units/tests/utils/get_usd_balance_test.dart b/test_units/tests/utils/get_usd_balance_test.dart index 318cbb69a2..58b541c825 100644 --- a/test_units/tests/utils/get_usd_balance_test.dart +++ b/test_units/tests/utils/get_usd_balance_test.dart @@ -1,5 +1,5 @@ // TODO: revisit or migrate to the SDK, since it mostly deals with the sdk -// import 'package:test/test.dart'; +import 'package:test/test.dart'; // import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; // import 'package:mockito/mockito.dart'; @@ -10,42 +10,42 @@ // class MockBalanceManager extends Mock implements BalanceManager {} void testUsdBalanceFormatter() { -// late MockKomodoDefiSdk sdk; -// late MockBalanceManager balanceManager; - -// setUp(() { -// sdk = MockKomodoDefiSdk(); -// balanceManager = MockBalanceManager(); -// when(sdk.balances).thenReturn(balanceManager); -// }); - -// test('Get formatted USD balance using SDK balance', () async { -// final coin = setCoin(usdPrice: 10.12); -// when(balanceManager.getBalance(coin.id)) -// .thenAnswer((_) async => BalanceInfo(spendable: 1.0, unspendable: 0)); -// expect(await coin.getFormattedUsdBalance(sdk), '\$10.12'); - -// final zeroCoin = setCoin(usdPrice: 0); -// when(balanceManager.getBalance(zeroCoin.id)) -// .thenAnswer((_) async => BalanceInfo(spendable: 1.0, unspendable: 0)); -// expect(await zeroCoin.getFormattedUsdBalance(sdk), '\$0.00'); - -// final nullPriceCoin = setCoin(usdPrice: null); -// expect(await nullPriceCoin.getFormattedUsdBalance(sdk), '\$0.00'); - -// final smallPriceCoin = setCoin(usdPrice: 0.0000001); -// when(balanceManager.getBalance(smallPriceCoin.id)) -// .thenAnswer((_) async => BalanceInfo(spendable: 1.0, unspendable: 0)); -// expect(await smallPriceCoin.getFormattedUsdBalance(sdk), '\$0.0000001'); - -// final largePriceCoin = setCoin(usdPrice: 123456789); -// when(balanceManager.getBalance(largePriceCoin.id)) -// .thenAnswer((_) async => BalanceInfo(spendable: 1.0, unspendable: 0)); -// expect(await largePriceCoin.getFormattedUsdBalance(sdk), '\$123456789.00'); - -// final zeroBalanceCoin = setCoin(usdPrice: 123456789); -// when(balanceManager.getBalance(zeroBalanceCoin.id)) -// .thenAnswer((_) async => BalanceInfo(spendable: 0.0, unspendable: 0)); -// expect(await zeroBalanceCoin.getFormattedUsdBalance(sdk), '\$0.00'); -// }); + // late MockKomodoDefiSdk sdk; + // late MockBalanceManager balanceManager; + + // setUp(() { + // sdk = MockKomodoDefiSdk(); + // balanceManager = MockBalanceManager(); + // when(sdk.balances).thenReturn(balanceManager); + // }); + + test('Get formatted USD balance using SDK balance', () async { + // final coin = setCoin(usdPrice: 10.12); + // when(balanceManager.getBalance(coin.id)) + // .thenAnswer((_) async => BalanceInfo(spendable: 1.0, unspendable: 0)); + // expect(await coin.getFormattedUsdBalance(sdk), '\$10.12'); + + // final zeroCoin = setCoin(usdPrice: 0); + // when(balanceManager.getBalance(zeroCoin.id)) + // .thenAnswer((_) async => BalanceInfo(spendable: 1.0, unspendable: 0)); + // expect(await zeroCoin.getFormattedUsdBalance(sdk), '\$0.00'); + + // final nullPriceCoin = setCoin(usdPrice: null); + // expect(await nullPriceCoin.getFormattedUsdBalance(sdk), '\$0.00'); + + // final smallPriceCoin = setCoin(usdPrice: 0.0000001); + // when(balanceManager.getBalance(smallPriceCoin.id)) + // .thenAnswer((_) async => BalanceInfo(spendable: 1.0, unspendable: 0)); + // expect(await smallPriceCoin.getFormattedUsdBalance(sdk), '\$0.0000001'); + + // final largePriceCoin = setCoin(usdPrice: 123456789); + // when(balanceManager.getBalance(largePriceCoin.id)) + // .thenAnswer((_) async => BalanceInfo(spendable: 1.0, unspendable: 0)); + // expect(await largePriceCoin.getFormattedUsdBalance(sdk), '\$123456789.00'); + + // final zeroBalanceCoin = setCoin(usdPrice: 123456789); + // when(balanceManager.getBalance(zeroBalanceCoin.id)) + // .thenAnswer((_) async => BalanceInfo(spendable: 0.0, unspendable: 0)); + // expect(await zeroBalanceCoin.getFormattedUsdBalance(sdk), '\$0.00'); + }, skip: true); } diff --git a/test_units/tests/utils/ipfs_gateway_manager_test.dart b/test_units/tests/utils/ipfs_gateway_manager_test.dart new file mode 100644 index 0000000000..4649e485b5 --- /dev/null +++ b/test_units/tests/utils/ipfs_gateway_manager_test.dart @@ -0,0 +1,465 @@ +import 'package:flutter/foundation.dart'; +import 'package:test/test.dart'; +import 'package:web_dex/shared/constants/ipfs_constants.dart'; +import 'package:web_dex/shared/utils/ipfs_gateway_manager.dart'; + +void main() { + testIpfsGatewayManager(); +} + +void testIpfsGatewayManager() { + group('IpfsGatewayManager', () { + late IpfsGatewayManager manager; + + setUp(() { + manager = IpfsGatewayManager(); + }); + + group('Constructor and Configuration', () { + test('should use default gateways when none provided', () { + final manager = IpfsGatewayManager(); + + expect(manager.gateways, isNotEmpty); + // Should use web-optimized or standard based on platform + if (kIsWeb) { + expect(manager.gateways, + equals(IpfsConstants.defaultWebOptimizedGateways)); + } else { + expect( + manager.gateways, equals(IpfsConstants.defaultStandardGateways)); + } + }); + + test('should use custom gateways when provided', () { + final customWebGateways = ['https://custom-web.gateway.com/ipfs/']; + final customStandardGateways = [ + 'https://custom-standard.gateway.com/ipfs/' + ]; + + final manager = IpfsGatewayManager( + webOptimizedGateways: customWebGateways, + standardGateways: customStandardGateways, + ); + + if (kIsWeb) { + expect(manager.gateways, equals(customWebGateways)); + } else { + expect(manager.gateways, equals(customStandardGateways)); + } + }); + + test('should use custom failure cooldown when provided', () async { + const customCooldown = Duration(minutes: 10); + final manager = IpfsGatewayManager(failureCooldown: customCooldown); + + // Test cooldown by marking a URL as failed and checking timing + const testUrl = 'https://test.gateway.com/ipfs/QmTest'; + await manager.logGatewayAttempt(testUrl, false); + + expect(await manager.shouldSkipUrl(testUrl), isTrue); + }); + }); + + group('IPFS URL Detection', () { + test('should detect ipfs:// protocol URLs', () { + expect( + IpfsGatewayManager.isIpfsUrl( + 'ipfs://QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o'), + isTrue); + expect(IpfsGatewayManager.isIpfsUrl('ipfs://QmTest/image.png'), isTrue); + }); + + test('should detect gateway format URLs', () { + expect( + IpfsGatewayManager.isIpfsUrl( + 'https://ipfs.io/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o'), + isTrue); + expect( + IpfsGatewayManager.isIpfsUrl( + 'https://gateway.pinata.cloud/ipfs/QmTest/metadata.json'), + isTrue); + expect(IpfsGatewayManager.isIpfsUrl('https://dweb.link/ipfs/QmTest'), + isTrue); + }); + + test('should detect subdomain format URLs', () { + expect( + IpfsGatewayManager.isIpfsUrl( + 'https://QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o.ipfs.dweb.link'), + isTrue); + expect( + IpfsGatewayManager.isIpfsUrl( + 'https://QmTest.ipfs.gateway.com/image.png'), + isTrue); + }); + + test('should detect URLs with /ipfs/ path anywhere', () { + expect( + IpfsGatewayManager.isIpfsUrl('https://some.domain.com/ipfs/QmTest'), + isTrue); + expect( + IpfsGatewayManager.isIpfsUrl( + 'https://custom-gateway.com/ipfs/QmTest/file.json'), + isTrue); + }); + + test('should not detect regular HTTP URLs as IPFS', () { + expect(IpfsGatewayManager.isIpfsUrl('https://example.com/image.png'), + isFalse); + expect(IpfsGatewayManager.isIpfsUrl('https://api.example.com/data'), + isFalse); + expect(IpfsGatewayManager.isIpfsUrl('http://localhost:3000/test'), + isFalse); + }); + + test('should handle null and empty URLs', () { + expect(IpfsGatewayManager.isIpfsUrl(null), isFalse); + expect(IpfsGatewayManager.isIpfsUrl(''), isFalse); + expect(IpfsGatewayManager.isIpfsUrl(' '), isFalse); + }); + }); + + group('Content ID Extraction', () { + test('should extract CID from ipfs:// protocol', () { + final urls = manager.getGatewayUrls( + 'ipfs://QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o'); + expect(urls.isNotEmpty, isTrue); + expect( + urls.first + .contains('QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o'), + isTrue); + }); + + test('should extract CID and path from ipfs:// protocol', () { + final urls = manager.getGatewayUrls('ipfs://QmTest/metadata.json'); + expect(urls.isNotEmpty, isTrue); + expect(urls.first.contains('QmTest/metadata.json'), isTrue); + }); + + test('should extract CID from gateway format', () { + final urls = manager.getGatewayUrls( + 'https://ipfs.io/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o'); + expect(urls.isNotEmpty, isTrue); + expect( + urls.first + .contains('QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o'), + isTrue); + }); + + test('should extract CID and path from gateway format', () { + final urls = manager.getGatewayUrls( + 'https://gateway.pinata.cloud/ipfs/QmTest/image.png'); + expect(urls.isNotEmpty, isTrue); + expect(urls.first.contains('QmTest/image.png'), isTrue); + }); + + test('should extract CID from subdomain format', () { + final urls = manager.getGatewayUrls( + 'https://QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o.ipfs.dweb.link'); + expect(urls.isNotEmpty, isTrue); + expect( + urls.first + .contains('QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o'), + isTrue); + }); + + test('should extract CID and path from subdomain format', () { + final urls = manager.getGatewayUrls( + 'https://QmTest.ipfs.gateway.com/path/to/file.json'); + expect(urls.isNotEmpty, isTrue); + expect(urls.first.contains('QmTest/path/to/file.json'), isTrue); + }); + + test('should handle URLs with /ipfs/ path', () { + final urls = manager + .getGatewayUrls('https://custom.gateway.com/ipfs/QmTest/data'); + expect(urls.isNotEmpty, isTrue); + expect(urls.first.contains('QmTest/data'), isTrue); + }); + + test('should extract CID from case-insensitive URLs', () { + // Test case-insensitive protocol + final urls1 = manager.getGatewayUrls('IPFS://QmTest'); + expect(urls1.isNotEmpty, isTrue); + expect(urls1.first.contains('QmTest'), isTrue); + + // Test case-insensitive /ipfs/ path + final urls2 = manager.getGatewayUrls('https://gateway.com/IPFS/QmTest'); + expect(urls2.isNotEmpty, isTrue); + expect(urls2.first.contains('QmTest'), isTrue); + }); + }); + + group('Gateway URL Generation', () { + test('should generate multiple gateway URLs for IPFS content', () { + final urls = manager.getGatewayUrls('ipfs://QmTest'); + + expect(urls.length, equals(manager.gateways.length)); + for (int i = 0; i < urls.length; i++) { + expect(urls[i], equals('${manager.gateways[i]}QmTest')); + } + }); + + test('should return original URL for non-IPFS URLs', () { + const originalUrl = 'https://example.com/image.png'; + final urls = manager.getGatewayUrls(originalUrl); + + expect(urls.length, equals(1)); + expect(urls.first, equals(originalUrl)); + }); + + test('should return empty list for null/empty URLs', () { + expect(manager.getGatewayUrls(null), isEmpty); + expect(manager.getGatewayUrls(''), isEmpty); + }); + + test('should get primary gateway URL', () { + final primaryUrl = manager.getPrimaryGatewayUrl('ipfs://QmTest'); + expect(primaryUrl, equals('${manager.gateways.first}QmTest')); + }); + + test('should return null for primary URL when input is invalid', () { + expect(manager.getPrimaryGatewayUrl(null), isNull); + expect(manager.getPrimaryGatewayUrl(''), isNull); + }); + }); + + group('URL Normalization', () { + test('should normalize different IPFS URL formats to preferred gateway', + () { + const cid = 'QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o'; + final expectedUrl = '${manager.gateways.first}$cid'; + + expect(manager.normalizeIpfsUrl('ipfs://$cid'), equals(expectedUrl)); + expect(manager.normalizeIpfsUrl('https://ipfs.io/ipfs/$cid'), + equals(expectedUrl)); + expect(manager.normalizeIpfsUrl('https://$cid.ipfs.dweb.link'), + equals(expectedUrl)); + }); + + test('should preserve paths in normalized URLs', () { + const cidWithPath = 'QmTest/metadata.json'; + final expectedUrl = '${manager.gateways.first}$cidWithPath'; + + expect(manager.normalizeIpfsUrl('ipfs://$cidWithPath'), + equals(expectedUrl)); + expect( + manager.normalizeIpfsUrl( + 'https://gateway.pinata.cloud/ipfs/$cidWithPath'), + equals(expectedUrl)); + }); + }); + + group('Failure Tracking and Circuit Breaker', () { + test('should track failed URLs', () async { + const testUrl = 'https://test.gateway.com/ipfs/QmTest'; + + expect(await manager.shouldSkipUrl(testUrl), isFalse); + + await manager.logGatewayAttempt(testUrl, false); + expect(await manager.shouldSkipUrl(testUrl), isTrue); + }); + + test('should remove URLs from failed set on success', () async { + const testUrl = 'https://test.gateway.com/ipfs/QmTest'; + + await manager.logGatewayAttempt(testUrl, false); + expect(await manager.shouldSkipUrl(testUrl), isTrue); + + await manager.logGatewayAttempt(testUrl, true); + expect(await manager.shouldSkipUrl(testUrl), isFalse); + }); + + test('should respect failure cooldown period', () async { + const testUrl = 'https://test.gateway.com/ipfs/QmTest'; + final shortCooldownManager = IpfsGatewayManager( + failureCooldown: const Duration(milliseconds: 100), + ); + + await shortCooldownManager.logGatewayAttempt(testUrl, false); + expect(await shortCooldownManager.shouldSkipUrl(testUrl), isTrue); + + // Wait for cooldown to expire + await Future.delayed(const Duration(milliseconds: 150)); + expect(await shortCooldownManager.shouldSkipUrl(testUrl), isFalse); + }); + + test('should filter out failed URLs from reliable gateway URLs', + () async { + const originalUrl = 'ipfs://QmTest'; + final allUrls = manager.getGatewayUrls(originalUrl); + + if (allUrls.isNotEmpty) { + // Mark first gateway as failed + await manager.logGatewayAttempt(allUrls.first, false); + + final reliableUrls = + await manager.getReliableGatewayUrls(originalUrl); + expect(reliableUrls.length, equals(allUrls.length - 1)); + expect(reliableUrls.contains(allUrls.first), isFalse); + } + }); + }); + + group('Edge Cases and Error Handling', () { + test('should handle malformed URLs gracefully', () { + const malformedUrls = [ + 'ipfs://', + 'ipfs:///', + 'https://ipfs.io/ipfs/', + 'https://.ipfs.dweb.link', + 'not-a-url', + 'ftp://example.com/file', + ]; + + for (final url in malformedUrls) { + expect(() => manager.getGatewayUrls(url), returnsNormally); + expect(() => manager.normalizeIpfsUrl(url), returnsNormally); + } + }); + + test('should handle very long URLs', () { + final longCid = + 'QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o${'A' * 100}'; + final urls = manager.getGatewayUrls('ipfs://$longCid'); + + expect(urls.isNotEmpty, isTrue); + expect(urls.first.contains(longCid), isTrue); + }); + + test('should handle URLs with special characters', () { + const specialPath = + 'QmTest/file%20with%20spaces.json?param=value#anchor'; + final urls = manager.getGatewayUrls('ipfs://$specialPath'); + + expect(urls.isNotEmpty, isTrue); + expect(urls.first.contains(specialPath), isTrue); + }); + + test('should handle case variations in URL schemes', () { + // URL schemes and paths should be case-insensitive + expect(IpfsGatewayManager.isIpfsUrl('IPFS://QmTest'), isTrue); + expect(IpfsGatewayManager.isIpfsUrl('Ipfs://QmTest'), isTrue); + expect(IpfsGatewayManager.isIpfsUrl('ipfs://QmTest'), isTrue); + // Gateway URLs with different case should work + expect(IpfsGatewayManager.isIpfsUrl('HTTPS://gateway.com/IPFS/QmTest'), + isTrue); + expect(IpfsGatewayManager.isIpfsUrl('https://gateway.com/ipfs/QmTest'), + isTrue); + expect(IpfsGatewayManager.isIpfsUrl('https://gateway.com/Ipfs/QmTest'), + isTrue); + }); + }); + + group('Real-world Examples', () { + group('NFT Metadata URLs', () { + test('should handle typical NFT metadata IPFS URLs', () { + const examples = [ + 'ipfs://QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o', + 'ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/1', + 'https://ipfs.io/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o', + 'https://gateway.pinata.cloud/ipfs/QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/metadata.json', + ]; + + for (final example in examples) { + final urls = manager.getGatewayUrls(example); + expect(urls.isNotEmpty, isTrue, reason: 'Failed for: $example'); + expect(IpfsGatewayManager.isIpfsUrl(example), isTrue, + reason: 'Not detected as IPFS: $example'); + } + }); + + test('should handle subdomain IPFS URLs from popular services', () { + const examples = [ + 'https://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi.ipfs.dweb.link', + 'https://QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o.ipfs.nftstorage.link', + 'https://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS.ipfs.w3s.link/metadata.json', + ]; + + for (final example in examples) { + final urls = manager.getGatewayUrls(example); + expect(urls.isNotEmpty, isTrue, reason: 'Failed for: $example'); + expect(IpfsGatewayManager.isIpfsUrl(example), isTrue, + reason: 'Not detected as IPFS: $example'); + } + }); + }); + + group('Non-IPFS URLs', () { + test('should handle regular image URLs', () { + const examples = [ + 'https://example.com/image.png', + 'https://cdn.example.com/assets/logo.svg', + 'https://api.example.com/v1/image/123.jpg', + 'http://localhost:3000/test-image.gif', + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + ]; + + for (final example in examples) { + expect(IpfsGatewayManager.isIpfsUrl(example), isFalse, + reason: 'Incorrectly detected as IPFS: $example'); + final urls = manager.getGatewayUrls(example); + expect(urls.length, equals(1), + reason: 'Should return original URL: $example'); + expect(urls.first, equals(example), + reason: 'Should return unchanged: $example'); + } + }); + }); + + group('Invalid/Broken URLs', () { + test('should handle invalid URLs gracefully', () { + const invalidExamples = [ + '', + ' ', + 'not-a-url', + '://missing-scheme', + 'https://', + 'ipfs://', + 'javascript:alert("xss")', + 'file:///etc/passwd', + ]; + + for (final example in invalidExamples) { + expect(() => manager.getGatewayUrls(example), returnsNormally, + reason: 'Should not throw for: $example'); + expect(() => IpfsGatewayManager.isIpfsUrl(example), returnsNormally, + reason: 'Should not throw for: $example'); + } + }); + }); + }); + + group('Performance and Logging', () { + test('should log gateway attempts with success', () async { + const testUrl = 'https://test.gateway.com/ipfs/QmTest'; + const loadTime = Duration(milliseconds: 250); + + // Should not throw + await expectLater( + manager.logGatewayAttempt( + testUrl, + true, + loadTime: loadTime, + ), + completes, + ); + }); + + test('should log gateway attempts with failure', () async { + const testUrl = 'https://test.gateway.com/ipfs/QmTest'; + const errorMessage = 'Connection timeout'; + + // Should not throw + await expectLater( + manager.logGatewayAttempt( + testUrl, + false, + errorMessage: errorMessage, + ), + completes, + ); + }); + }); + }); +} diff --git a/test_units/tests/utils/test_util.dart b/test_units/tests/utils/test_util.dart index 4f3575c95b..8ec9940822 100644 --- a/test_units/tests/utils/test_util.dart +++ b/test_units/tests/utils/test_util.dart @@ -1,5 +1,6 @@ +import 'package:decimal/decimal.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; -import 'package:web_dex/model/cex_price.dart'; +import 'package:web_dex/model/cex_price.dart' show CexPrice; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_type.dart'; @@ -9,21 +10,23 @@ Coin setCoin({ String? coinAbbr, double? balance, }) { + final id = AssetId( + id: coinAbbr ?? 'KMD', + name: 'Komodo', + parentId: null, + symbol: AssetSymbol( + assetConfigId: coinAbbr ?? 'KMD', + coinGeckoId: 'komodo', + coinPaprikaId: 'kmd-komodo', + ), + derivationPath: "m/44'/141'/0'", + chainId: AssetChainId(chainId: 0), + subClass: CoinSubClass.smartChain, + ); + return Coin( abbr: coinAbbr ?? 'KMD', - id: AssetId( - id: coinAbbr ?? 'KMD', - name: 'Komodo', - parentId: null, - symbol: AssetSymbol( - assetConfigId: coinAbbr ?? 'KMD', - coinGeckoId: 'komodo', - coinPaprikaId: 'kmd-komodo', - ), - derivationPath: "m/44'/141'/0'", - chainId: AssetChainId(chainId: 0), - subClass: CoinSubClass.smartChain, - ), + id: id, activeByDefault: true, logoImageUrl: null, coingeckoId: "komodo", @@ -44,13 +47,11 @@ Coin setCoin({ swapContractAddress: null, type: CoinType.smartChain, walletOnly: false, - usdPrice: usdPrice != null - ? CexPrice( - price: usdPrice, - change24h: change24h ?? 0.0, - volume24h: 0.0, - ticker: 'USD', - ) - : null, + usdPrice: CexPrice( + assetId: id, + lastUpdated: DateTime.now(), + price: Decimal.tryParse(usdPrice.toString()), + change24h: Decimal.tryParse(change24h.toString()), + ), ); } 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 new file mode 100644 index 0000000000..1031f9015c --- /dev/null +++ b/test_units/tests/views/dex/simple/form/tables/table_utils_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: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/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'; +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'; + +Coin _buildCoin( + String abbr, { + double usdPrice = 0, + bool walletOnly = false, + bool isTestCoin = false, + int priority = 0, +}) { + 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: isTestCoin, + logoImageUrl: null, + coingeckoId: null, + fallbackSwapContract: null, + priority: priority, + state: CoinState.active, + swapContractAddress: null, + walletOnly: walletOnly, + mode: CoinMode.standard, + usdPrice: CexPrice( + assetId: assetId, + price: Decimal.parse(usdPrice.toString()), + change24h: Decimal.zero, + lastUpdated: DateTime.fromMillisecondsSinceEpoch(0), + ), + ); +} + +BestOrder _buildOrder(String coin, int price) { + return BestOrder( + price: Rational.fromInt(price), + maxVolume: Rational.fromInt(1), + minVolume: Rational.fromInt(1), + coin: coin, + address: OrderAddress.transparent(coin.toLowerCase()), + uuid: '$coin-$price', + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('buildOrderCoinCaches', () { + testWidgets('creates aligned caches for orders and coins', (tester) async { + 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]; + + final orders = >{ + 'BTC-KMD': [_buildOrder('BTC', 1)], + 'KMD-BTC': [_buildOrder('KMD', 2)], + }; + + late ({ + Map ordersByAssetId, + Map coinsByAssetId, + Map assetIdByAbbr, + }) + caches; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + caches = buildOrderCoinCaches( + context, + orders, + coinLookup: coinLookup, + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(caches.ordersByAssetId.length, 2); + expect(caches.coinsByAssetId.length, 2); + expect(caches.assetIdByAbbr['BTC'], btc.assetId); + expect(caches.ordersByAssetId[btc.assetId]?.uuid, 'BTC-1'); + }); + }); + + group('prepareOrdersForTable', () { + testWidgets('sorts by fiat value and filters wallet/test coins', ( + tester, + ) async { + final btc = _buildCoin('BTC', usdPrice: 30_000); + 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]; + + final orders = >{ + 'BTC-KMD': [_buildOrder('BTC', 1)], + 'KMD-BTC': [_buildOrder('KMD', 2)], + 'TBTC-KMD': [_buildOrder('TBTC', 3)], + }; + + late List sorted; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + sorted = prepareOrdersForTable( + context, + orders, + null, + AuthorizeMode.noLogin, + testCoinsEnabled: false, + coinLookup: coinLookup, + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(sorted, hasLength(1)); + expect(sorted.single.coin, 'BTC'); + }); + + testWidgets('uses fewer coin lookups than the legacy approach', ( + tester, + ) async { + final btc = _buildCoin('BTC', usdPrice: 30_000); + final kmd = _buildCoin('KMD', usdPrice: 1); + final coins = {'BTC': btc, 'KMD': kmd}; + + final orders = >{ + 'pair-1': [_buildOrder('BTC', 1)], + 'pair-2': [_buildOrder('KMD', 100)], + }; + + final legacyCalls = {}; + final optimisedCalls = {}; + + Coin? legacyLookup(String abbr) { + legacyCalls[abbr] = (legacyCalls[abbr] ?? 0) + 1; + return coins[abbr]; + } + + Coin? optimisedLookup(String abbr) { + optimisedCalls[abbr] = (optimisedCalls[abbr] ?? 0) + 1; + return coins[abbr]; + } + + List legacyPrepare( + Map> input, + Coin? Function(String) lookup, + ) { + final result = []; + input.forEach((_, list) { + if (list.isEmpty) return; + final order = list.first; + final coin = lookup(order.coin); + if (coin == null) return; + result.add(order); + }); + result.sort((a, b) { + final coinA = lookup(a.coin); + final coinB = lookup(b.coin); + final fiatA = + a.price.toDouble() * (coinA?.usdPrice?.price?.toDouble() ?? 0.0); + final fiatB = + b.price.toDouble() * (coinB?.usdPrice?.price?.toDouble() ?? 0.0); + return fiatB.compareTo(fiatA); + }); + return result; + } + + legacyPrepare(orders, legacyLookup); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + prepareOrdersForTable( + context, + orders, + null, + AuthorizeMode.noLogin, + coinLookup: optimisedLookup, + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(legacyCalls['BTC']! > optimisedCalls['BTC']!, isTrue); + expect(legacyCalls['KMD']! > optimisedCalls['KMD']!, isTrue); + }); + }); +} diff --git a/test_units/views/wallets_manager/widgets/wallet_login_test.dart b/test_units/views/wallets_manager/widgets/wallet_login_test.dart new file mode 100644 index 0000000000..94dff93b34 --- /dev/null +++ b/test_units/views/wallets_manager/widgets/wallet_login_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/views/wallets_manager/widgets/wallet_login.dart'; + +void main() { + group('PasswordTextField Auto-Submit Tests', () { + late TextEditingController controller; + bool submitCalled = false; + + setUp(() { + controller = TextEditingController(); + submitCalled = false; + }); + + tearDown(() { + controller.dispose(); + }); + + testWidgets( + 'should auto-submit when quick login is enabled and multi-character input detected', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PasswordTextField( + controller: controller, + onFieldSubmitted: () { + submitCalled = true; + }, + isQuickLoginEnabled: true, + ), + ), + ), + ); + + // 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)); + + expect(submitCalled, true); + }, + ); + + testWidgets('should not auto-submit when quick login is disabled', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PasswordTextField( + controller: controller, + onFieldSubmitted: () { + submitCalled = true; + }, + isQuickLoginEnabled: false, + ), + ), + ), + ); + + // Simulate password manager input + controller.text = 'mypassword123'; + + // Wait for potential auto-submit timer + await tester.pump(const Duration(milliseconds: 400)); + + expect(submitCalled, false); + }); + + testWidgets('should not auto-submit for single character input', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PasswordTextField( + controller: controller, + onFieldSubmitted: () { + submitCalled = true; + }, + isQuickLoginEnabled: true, + ), + ), + ), + ); + + // Simulate typing one character at a time + controller.text = 'm'; + await tester.pump(const Duration(milliseconds: 100)); + controller.text = 'my'; + await tester.pump(const Duration(milliseconds: 100)); + controller.text = 'myp'; + + // Wait for potential auto-submit timer + await tester.pump(const Duration(milliseconds: 400)); + + expect(submitCalled, false); + }); + + testWidgets( + 'should not auto-submit when field is empty after multi-character input', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PasswordTextField( + controller: controller, + onFieldSubmitted: () { + submitCalled = true; + }, + isQuickLoginEnabled: true, + ), + ), + ), + ); + + // Simulate password manager input then clearing + controller.text = 'mypassword123'; + await tester.pump(const Duration(milliseconds: 100)); + controller.text = ''; + + // Wait for the auto-submit timer period + await tester.pump(const Duration(milliseconds: 400)); + + expect(submitCalled, false); + }, + ); + }); +} diff --git a/web/icons/logo_icon.png b/web/icons/logo_icon.png deleted file mode 100644 index 445e2ca862..0000000000 Binary files a/web/icons/logo_icon.png and /dev/null differ diff --git a/web/icons/logo_icon.webp b/web/icons/logo_icon.webp new file mode 100644 index 0000000000..86232aa46b Binary files /dev/null and b/web/icons/logo_icon.webp differ diff --git a/web/index.html b/web/index.html index 85e85c64c0..b01251c185 100644 --- a/web/index.html +++ b/web/index.html @@ -39,7 +39,7 @@ crossorigin> - + @@ -136,7 +136,7 @@ transform: scale(.01) } } - Komodo Wallet is starting... Please wait.